From ee33c543387809600ff387a7efbd555e291c16d9 Mon Sep 17 00:00:00 2001 From: Chris Denneen Date: Fri, 6 Mar 2026 12:12:08 -0500 Subject: [PATCH 01/81] feat(cli): add provider-agnostic channel bridge core --- .../channels/core/channelBridgeWorker.test.ts | 1922 +++++++++++++++++ .../src/channels/core/channelBridgeWorker.ts | 943 ++++++++ docs/README.md | 1 + docs/channel-bridge.md | 57 + 4 files changed, 2923 insertions(+) create mode 100644 apps/cli/src/channels/core/channelBridgeWorker.test.ts create mode 100644 apps/cli/src/channels/core/channelBridgeWorker.ts create mode 100644 docs/channel-bridge.md diff --git a/apps/cli/src/channels/core/channelBridgeWorker.test.ts b/apps/cli/src/channels/core/channelBridgeWorker.test.ts new file mode 100644 index 0000000000..e6e25ab4fe --- /dev/null +++ b/apps/cli/src/channels/core/channelBridgeWorker.test.ts @@ -0,0 +1,1922 @@ +import { describe, expect, it } from 'vitest'; + +import { + createInMemoryChannelBindingStore, + executeChannelBridgeTick, + startChannelBridgeWorker, + createChannelBridgeInboundDeduper, + type ChannelBridgeAdapter, + type ChannelBindingStore, + type ChannelBridgeDeps, + type ChannelBridgeInboundMessage, +} from '@/channels/core/channelBridgeWorker'; + +interface SentConversationMessage { + conversationId: string; + threadId: string | null; + text: string; +} + +interface SentSessionMessage { + sessionId: string; + text: string; + sentFrom: string; + providerId: string; + conversationId: string; + threadId: string | null; +} + +interface WarningRecord { + message: string; + error?: unknown; +} + +interface DepsHarness { + deps: ChannelBridgeDeps; + sentToSession: SentSessionMessage[]; + warnings: WarningRecord[]; +} + +interface DeferredPromise { + promise: Promise; + resolve: (value: T | PromiseLike) => void; + reject: (reason?: unknown) => void; +} + +function createDepsHarness(options?: { + sessions?: Array<{ sessionId: string; label: string | null }>; + listSessions?: ChannelBridgeDeps['listSessions']; + resolveSessionIdOrPrefix?: ChannelBridgeDeps['resolveSessionIdOrPrefix']; + sendUserMessageToSession?: ChannelBridgeDeps['sendUserMessageToSession']; + resolveLatestSessionSeq?: ChannelBridgeDeps['resolveLatestSessionSeq']; + fetchAgentMessagesAfterSeq?: ChannelBridgeDeps['fetchAgentMessagesAfterSeq']; + authorizeCommand?: ChannelBridgeDeps['authorizeCommand']; +}): DepsHarness { + const sentToSession: SentSessionMessage[] = []; + const warnings: WarningRecord[] = []; + const deps: ChannelBridgeDeps = { + listSessions: options?.listSessions ?? (async () => options?.sessions ?? []), + resolveSessionIdOrPrefix: + options?.resolveSessionIdOrPrefix ?? + (async () => ({ ok: false as const, code: 'session_not_found' as const })), + sendUserMessageToSession: + options?.sendUserMessageToSession ?? + (async (params) => { + sentToSession.push({ ...params }); + }), + resolveLatestSessionSeq: options?.resolveLatestSessionSeq ?? (async () => 0), + fetchAgentMessagesAfterSeq: options?.fetchAgentMessagesAfterSeq ?? (async () => []), + authorizeCommand: options?.authorizeCommand, + onWarning: (message, error) => { + warnings.push({ message, error }); + }, + }; + return { deps, sentToSession, warnings }; +} + +function createAdapterHarness(providerId: string = 'telegram'): { + adapter: ChannelBridgeAdapter; + pushInbound: (event: ChannelBridgeInboundMessage) => void; + sent: SentConversationMessage[]; + failPullOnce: (error: Error) => void; + stopCalls: () => number; + pendingInboundCount: () => number; +} { + const queue: ChannelBridgeInboundMessage[] = []; + const sent: SentConversationMessage[] = []; + let pullError: Error | null = null; + let stopCallCount = 0; + + return { + adapter: { + providerId, + pullInboundMessages: async () => { + if (pullError) { + const error = pullError; + pullError = null; + throw error; + } + const items = queue.slice(); + queue.length = 0; + return items; + }, + sendMessage: async (params) => { + sent.push({ + conversationId: params.conversationId, + threadId: params.threadId, + text: params.text, + }); + }, + stop: async () => { + stopCallCount += 1; + }, + }, + pushInbound: (event) => { + queue.push(event); + }, + sent, + failPullOnce: (error) => { + pullError = error; + }, + stopCalls: () => stopCallCount, + pendingInboundCount: () => queue.length, + }; +} + +function createDeferredPromise(): DeferredPromise { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +async function waitFor(condition: () => boolean, timeoutMs: number = 2_000): Promise { + const startedAt = Date.now(); + while (!condition()) { + if (Date.now() - startedAt > timeoutMs) { + throw new Error('Timed out waiting for condition'); + } + await new Promise((resolve) => setTimeout(resolve, 10)); + } +} + +describe('createInMemoryChannelBindingStore', () => { + it('normalizes non-finite cursor values and ignores invalid cursor updates', async () => { + const store = createInMemoryChannelBindingStore(); + const ref = { + providerId: 'telegram', + conversationId: '-100-cursor', + threadId: null, + } as const; + + await store.upsertBinding({ + ...ref, + sessionId: 'sess-cursor', + lastForwardedSeq: Number.NaN, + }); + + const created = await store.getBinding(ref); + expect(created?.lastForwardedSeq).toBe(0); + + await store.updateLastForwardedSeq(ref, 7); + await store.updateLastForwardedSeq(ref, Number.NaN); + + const updated = await store.getBinding(ref); + expect(updated?.lastForwardedSeq).toBe(7); + }); +}); + +describe('executeChannelBridgeTick', () => { + it('supports /attach then forwards inbound user messages into the bound session', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + + const { deps, sentToSession } = createDepsHarness({ + resolveSessionIdOrPrefix: async (idOrPrefix: string) => { + if (idOrPrefix === 'abc123') { + return { ok: true as const, sessionId: 'sess-abc123' }; + } + return { ok: false as const, code: 'session_not_found' as const }; + }, + resolveLatestSessionSeq: async () => 41, + }); + + harness.pushInbound({ + providerId: 'telegram', + conversationId: '-1001', + threadId: '88', + text: '/attach abc123', + messageId: 'm1', + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + const [binding] = await store.listBindings(); + expect(binding).toMatchObject({ + providerId: 'telegram', + conversationId: '-1001', + threadId: '88', + sessionId: 'sess-abc123', + lastForwardedSeq: 41, + }); + expect(harness.sent.some((row) => row.text.includes('Attached'))).toBe(true); + + harness.pushInbound({ + providerId: 'telegram', + conversationId: '-1001', + threadId: '88', + text: 'Ship it', + messageId: 'm2', + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(sentToSession).toEqual([ + { + sessionId: 'sess-abc123', + text: 'Ship it', + sentFrom: 'telegram', + providerId: 'telegram', + conversationId: '-1001', + threadId: '88', + }, + ]); + }); + + it('includes previous session id when /attach replaces an existing binding', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + + await store.upsertBinding({ + providerId: 'telegram', + conversationId: '-1001', + threadId: '88', + sessionId: 'sess-old', + lastForwardedSeq: 12, + }); + + const { deps } = createDepsHarness({ + resolveSessionIdOrPrefix: async () => ({ ok: true as const, sessionId: 'sess-new' }), + resolveLatestSessionSeq: async () => 41, + }); + + harness.pushInbound({ + providerId: 'telegram', + conversationId: '-1001', + threadId: '88', + text: '/attach sess-new', + messageId: 'm-attach-replace', + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(harness.sent.some((row) => row.text.includes('replaced previous session sess-old'))).toBe(true); + }); + + it('supports /sessions and /detach command flow', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + + await store.upsertBinding({ + providerId: 'telegram', + conversationId: '-1001', + threadId: '99', + sessionId: 'sess-old', + lastForwardedSeq: 3, + }); + + const { deps } = createDepsHarness({ + sessions: [ + { sessionId: 'sess-1', label: 'build-docs' }, + { sessionId: 'sess-2', label: null }, + ], + }); + + harness.pushInbound({ + providerId: 'telegram', + conversationId: '-1001', + threadId: '99', + text: '/sessions', + messageId: 'm-sessions', + }); + harness.pushInbound({ + providerId: 'telegram', + conversationId: '-1001', + threadId: '99', + text: '/detach', + messageId: 'm-detach', + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(harness.sent.some((row) => row.text.includes('Active sessions'))).toBe(true); + expect(harness.sent.some((row) => row.text.includes('Detached'))).toBe(true); + + const remaining = await store.listBindings(); + expect(remaining).toHaveLength(0); + }); + + it('warns and replies when /sessions fails to list sessions', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + + const { deps, warnings } = createDepsHarness({ + listSessions: async () => { + throw new Error('list unavailable'); + }, + }); + + harness.pushInbound({ + providerId: 'telegram', + conversationId: '-1001', + threadId: '99', + text: '/sessions', + messageId: 'm-sessions-fail', + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(warnings.some((row) => row.message.includes('Failed to list sessions for /sessions command'))).toBe(true); + expect(harness.sent.some((row) => row.text.includes('Failed to retrieve sessions'))).toBe(true); + }); + + it('supports /session command for attached conversations', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + const { deps } = createDepsHarness(); + + await store.upsertBinding({ + providerId: 'telegram', + conversationId: '-1001', + threadId: '99', + sessionId: 'sess-bound', + lastForwardedSeq: 3, + }); + + harness.pushInbound({ + providerId: 'telegram', + conversationId: '-1001', + threadId: '99', + text: '/session', + messageId: 'm-session-bound', + }); + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(harness.sent.some((row) => row.text.includes('Attached session: sess-bound'))).toBe(true); + }); + + it('supports /session command for non-attached conversations', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + const { deps } = createDepsHarness(); + + harness.pushInbound({ + providerId: 'telegram', + conversationId: '-1001', + threadId: '100', + text: '/session', + messageId: 'm-session-unbound', + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(harness.sent.some((row) => row.text.includes('No session is attached here'))).toBe(true); + }); + + it('warns and replies when /session cannot read binding from store', async () => { + const baseStore = createInMemoryChannelBindingStore(); + const store: ChannelBindingStore = { + ...baseStore, + getBinding: async () => { + throw new Error('binding read failed'); + }, + }; + const harness = createAdapterHarness(); + const { deps, warnings } = createDepsHarness(); + + harness.pushInbound({ + providerId: 'telegram', + conversationId: '-1001', + threadId: '99', + text: '/session', + messageId: 'm-session-store-fail', + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(warnings.some((row) => row.message.includes('Failed to read binding for /session command'))).toBe(true); + expect(harness.sent.some((row) => row.text.includes('Failed to read current session binding'))).toBe(true); + }); + + it('warns and replies when /detach fails to remove a binding from store', async () => { + const baseStore = createInMemoryChannelBindingStore(); + const store: ChannelBindingStore = { + ...baseStore, + removeBinding: async () => { + throw new Error('binding remove failed'); + }, + }; + const harness = createAdapterHarness(); + const { deps, warnings } = createDepsHarness(); + + harness.pushInbound({ + providerId: 'telegram', + conversationId: '-1001', + threadId: '99', + text: '/detach', + messageId: 'm-detach-store-fail', + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(warnings.some((row) => row.message.includes('Failed to remove binding for /detach command'))).toBe(true); + expect(harness.sent.some((row) => row.text.includes('Failed to detach current session binding'))).toBe(true); + }); + + it('supports /help and /start command aliases', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + const { deps, sentToSession } = createDepsHarness(); + + await store.upsertBinding({ + providerId: 'telegram', + conversationId: '-1001', + threadId: '99', + sessionId: 'sess-bound', + lastForwardedSeq: 3, + }); + + harness.pushInbound({ + providerId: 'telegram', + conversationId: '-1001', + threadId: '99', + text: '/help', + messageId: 'm-help', + }); + harness.pushInbound({ + providerId: 'telegram', + conversationId: '-1001', + threadId: '99', + text: '/start', + messageId: 'm-start', + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + const helpReplies = harness.sent.filter((row) => row.text.includes('Happier bridge commands:')); + expect(helpReplies).toHaveLength(2); + for (const reply of helpReplies) { + expect(reply.text.includes('/help - show command help')).toBe(true); + expect(reply.text.includes('/start - alias for /help')).toBe(true); + } + expect(sentToSession).toHaveLength(0); + }); + + it('replies for unknown slash commands instead of forwarding them', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + const { deps, sentToSession } = createDepsHarness(); + + await store.upsertBinding({ + providerId: 'telegram', + conversationId: '-1001', + threadId: '99', + sessionId: 'sess-bound', + lastForwardedSeq: 3, + }); + + harness.pushInbound({ + providerId: 'telegram', + conversationId: '-1001', + threadId: '99', + text: '/bogus-command', + messageId: 'm-unknown-command', + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(harness.sent.some((row) => row.text.includes('Unknown command: /bogus-command'))).toBe(true); + expect(sentToSession).toHaveLength(0); + }); + + it('replies for malformed slash command tokens instead of forwarding them', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + const { deps, sentToSession } = createDepsHarness(); + + await store.upsertBinding({ + providerId: 'telegram', + conversationId: '-1001', + threadId: '99', + sessionId: 'sess-bound', + lastForwardedSeq: 3, + }); + + harness.pushInbound({ + providerId: 'telegram', + conversationId: '-1001', + threadId: '99', + text: '/@', + messageId: 'm-malformed-command', + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(harness.sent.some((row) => row.text.includes('Unknown command. Use /help'))).toBe(true); + expect(sentToSession).toHaveLength(0); + }); + + it('replies with no-binding hint for non-command inbound text when conversation is unbound', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + const { deps, sentToSession } = createDepsHarness(); + + harness.pushInbound({ + providerId: 'telegram', + conversationId: 'unbound-room', + threadId: null, + text: 'hello from unbound thread', + messageId: 'unbound-non-command', + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(harness.sent.some((row) => row.text.includes('No session is attached here'))).toBe(true); + expect(sentToSession).toHaveLength(0); + }); + + it('warns and replies when non-command forwarding cannot read binding from store', async () => { + const baseStore = createInMemoryChannelBindingStore(); + const store: ChannelBindingStore = { + ...baseStore, + getBinding: async () => { + throw new Error('binding read failed'); + }, + }; + const harness = createAdapterHarness(); + const { deps, warnings, sentToSession } = createDepsHarness(); + + harness.pushInbound({ + providerId: 'telegram', + conversationId: 'bound-room', + threadId: null, + text: 'hello from channel', + messageId: 'non-command-getbinding-fail', + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(sentToSession).toHaveLength(0); + expect(warnings.some((row) => row.message.includes('Failed to read binding for inbound message forwarding'))).toBe(true); + expect(harness.sent.some((row) => row.text.includes('Failed to read current session binding'))).toBe(true); + }); + + it('indicates when /sessions output is truncated', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + const { deps } = createDepsHarness({ + sessions: Array.from({ length: 21 }, (_, index) => ({ + sessionId: `sess-${index + 1}`, + label: null, + })), + }); + + harness.pushInbound({ + providerId: 'telegram', + conversationId: '-1001', + threadId: '99', + text: '/sessions', + messageId: 'm-sessions-truncated', + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(harness.sent.some((row) => row.text.includes('…and 1 more.'))).toBe(true); + }); + + it('forwards agent replies to the bound conversation and advances cursor', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + + await store.upsertBinding({ + providerId: 'telegram', + conversationId: '-1005', + threadId: null, + sessionId: 'sess-a', + lastForwardedSeq: 9, + }); + + const { deps } = createDepsHarness({ + fetchAgentMessagesAfterSeq: async ({ afterSeq }: { afterSeq: number }) => { + if (afterSeq === 9) { + return [ + { seq: 10, text: 'First agent reply' }, + { seq: 11, text: 'Second agent reply' }, + ]; + } + return []; + }, + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(harness.sent).toEqual([ + { conversationId: '-1005', threadId: null, text: 'First agent reply' }, + { conversationId: '-1005', threadId: null, text: 'Second agent reply' }, + ]); + + const [binding] = await store.listBindings(); + expect(binding?.lastForwardedSeq).toBe(11); + }); + + it('does not attach when latest session sequence cannot be resolved', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + + const { deps } = createDepsHarness({ + resolveSessionIdOrPrefix: async () => ({ ok: true as const, sessionId: 'sess-invalid-seq' }), + resolveLatestSessionSeq: async () => { + throw new Error('sequence unavailable'); + }, + }); + + harness.pushInbound({ + providerId: 'telegram', + conversationId: '-1007', + threadId: null, + text: '/attach sess-invalid-seq', + messageId: 'attach-fails', + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(await store.listBindings()).toHaveLength(0); + expect(harness.sent.some((row) => row.text.includes('Failed to attach'))).toBe(true); + }); + + it('does not attach when session id/prefix resolution throws', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + + const { deps, warnings } = createDepsHarness({ + resolveSessionIdOrPrefix: async () => { + throw new Error('resolver unavailable'); + }, + }); + + harness.pushInbound({ + providerId: 'telegram', + conversationId: '-1007', + threadId: null, + text: '/attach sess-resolver-error', + messageId: 'attach-resolver-throws', + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(await store.listBindings()).toHaveLength(0); + expect(warnings.some((row) => row.message.includes('Failed to resolve session by id/prefix for attach'))).toBe(true); + expect(harness.sent.some((row) => row.text.includes('Failed to attach to session sess-resolver-error'))).toBe(true); + }); + + it('does not attach when latest session sequence resolves to an invalid value', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + + const { deps, warnings } = createDepsHarness({ + resolveSessionIdOrPrefix: async () => ({ ok: true as const, sessionId: 'sess-invalid-seq-value' }), + resolveLatestSessionSeq: async () => Number.NaN, + }); + + harness.pushInbound({ + providerId: 'telegram', + conversationId: '-1007', + threadId: null, + text: '/attach sess-invalid-seq-value', + messageId: 'attach-invalid-seq-value', + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(await store.listBindings()).toHaveLength(0); + expect(harness.sent.some((row) => row.text.includes('Failed to attach'))).toBe(true); + expect(warnings.some((row) => row.message.includes('resolveLatestSessionSeq returned an invalid value'))).toBe(true); + }); + + it('does not attach when binding persistence fails', async () => { + const baseStore = createInMemoryChannelBindingStore(); + const store: ChannelBindingStore = { + ...baseStore, + upsertBinding: async () => { + throw new Error('binding upsert failed'); + }, + }; + const harness = createAdapterHarness(); + + const { deps, warnings } = createDepsHarness({ + resolveSessionIdOrPrefix: async () => ({ ok: true as const, sessionId: 'sess-upsert-fail' }), + resolveLatestSessionSeq: async () => 10, + }); + + harness.pushInbound({ + providerId: 'telegram', + conversationId: '-1007', + threadId: null, + text: '/attach sess-upsert-fail', + messageId: 'attach-upsert-throws', + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(await baseStore.listBindings()).toHaveLength(0); + expect(warnings.some((row) => row.message.includes('Failed to persist binding during /attach'))).toBe(true); + expect(harness.sent.some((row) => row.text.includes('unable to persist binding'))).toBe(true); + }); + + it('does not attach when reading an existing binding fails before persistence', async () => { + const baseStore = createInMemoryChannelBindingStore(); + const store: ChannelBindingStore = { + ...baseStore, + getBinding: async () => { + throw new Error('prior binding read failed'); + }, + }; + const harness = createAdapterHarness(); + + const { deps, warnings } = createDepsHarness({ + resolveSessionIdOrPrefix: async () => ({ ok: true as const, sessionId: 'sess-prior-fail' }), + resolveLatestSessionSeq: async () => 10, + }); + + harness.pushInbound({ + providerId: 'telegram', + conversationId: '-1007', + threadId: null, + text: '/attach sess-prior-fail', + messageId: 'attach-getbinding-throws', + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(await baseStore.listBindings()).toHaveLength(0); + expect(warnings.some((row) => row.message.includes('Failed to read existing binding during /attach'))).toBe(true); + expect(harness.sent.some((row) => row.text.includes('Failed to read current binding before attach'))).toBe(true); + }); + + it('returns ambiguous attach message even when resolver omits candidate ids', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + + const { deps } = createDepsHarness({ + resolveSessionIdOrPrefix: async () => ({ + ok: false as const, + code: 'session_id_ambiguous' as const, + candidates: [], + }), + }); + + harness.pushInbound({ + providerId: 'telegram', + conversationId: '-1007', + threadId: null, + text: '/attach sess-ambiguous', + messageId: 'attach-ambiguous-empty-candidates', + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(await store.listBindings()).toHaveLength(0); + expect(harness.sent.some((row) => row.text.includes('Ambiguous session prefix'))).toBe(true); + }); + + it('returns unsupported attach message when resolver does not support attach by id/prefix', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + + const { deps } = createDepsHarness({ + resolveSessionIdOrPrefix: async () => ({ ok: false as const, code: 'unsupported' as const }), + }); + + harness.pushInbound({ + providerId: 'telegram', + conversationId: '-1007', + threadId: null, + text: '/attach sess-unsupported', + messageId: 'attach-unsupported', + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(await store.listBindings()).toHaveLength(0); + expect(harness.sent.some((row) => row.text.includes('Attaching by session ID or prefix is not supported'))).toBe(true); + }); + + it('returns session-not-found attach message when resolver cannot find target', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + + const { deps } = createDepsHarness({ + resolveSessionIdOrPrefix: async () => ({ ok: false as const, code: 'session_not_found' as const }), + }); + + harness.pushInbound({ + providerId: 'telegram', + conversationId: '-1007', + threadId: null, + text: '/attach sess-missing', + messageId: 'attach-session-not-found', + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(await store.listBindings()).toHaveLength(0); + expect(harness.sent.some((row) => row.text.includes('Session not found'))).toBe(true); + }); + + it('replies with usage hint when /attach is called without arguments', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + const { deps } = createDepsHarness(); + + harness.pushInbound({ + providerId: 'telegram', + conversationId: '-1007', + threadId: null, + text: '/attach', + messageId: 'attach-no-args', + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(await store.listBindings()).toHaveLength(0); + expect(harness.sent.some((row) => row.text.includes('Usage: /attach'))).toBe(true); + }); + + it('enforces sender-scoped command authorization via deps hook', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + + const { deps } = createDepsHarness({ + authorizeCommand: async ({ commandName, actor }) => { + if (commandName === 'attach' && actor.senderId === 'blocked-user') { + return { allowed: false as const, message: 'Not authorized for attach.' }; + } + return true; + }, + resolveSessionIdOrPrefix: async () => ({ ok: true as const, sessionId: 'sess-new' }), + resolveLatestSessionSeq: async () => 5, + }); + + harness.pushInbound({ + providerId: 'telegram', + conversationId: '-1008', + threadId: null, + senderId: 'blocked-user', + text: '/attach sess-new', + messageId: 'attach-authz-blocked', + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(await store.listBindings()).toHaveLength(0); + expect(harness.sent.some((row) => row.text.includes('Not authorized for attach.'))).toBe(true); + }); + + it('denies command and warns when authorizeCommand hook throws', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + + const { deps, warnings } = createDepsHarness({ + authorizeCommand: async () => { + throw new Error('auth service unavailable'); + }, + }); + + harness.pushInbound({ + providerId: 'telegram', + conversationId: '-1009', + threadId: null, + text: '/sessions', + messageId: 'authz-throws', + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(harness.sent.some((row) => row.text.includes('Unable to authorize this command right now.'))).toBe(true); + expect(warnings.some((row) => row.message.includes('Authorization check failed'))).toBe(true); + }); + + it('normalizes inbound provider identity to adapter provider and warns on mismatch', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness('telegram'); + + await store.upsertBinding({ + providerId: 'telegram', + conversationId: 'chat-42', + threadId: null, + sessionId: 'sess-42', + lastForwardedSeq: 0, + }); + + const { deps, sentToSession, warnings } = createDepsHarness(); + + harness.pushInbound({ + providerId: 'discord', + conversationId: 'chat-42', + threadId: null, + text: 'Hello from spoofed provider', + messageId: 'm-spoof', + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(sentToSession).toHaveLength(1); + expect(sentToSession[0]).toMatchObject({ + providerId: 'telegram', + sentFrom: 'telegram', + conversationId: 'chat-42', + threadId: null, + }); + expect(warnings.some((row) => row.message.includes('Inbound provider mismatch'))).toBe(true); + }); + + it('continues processing other adapters when one adapter pull fails', async () => { + const store = createInMemoryChannelBindingStore(); + const failing = createAdapterHarness('telegram'); + const healthy = createAdapterHarness('discord'); + + await store.upsertBinding({ + providerId: 'discord', + conversationId: 'discord-room', + threadId: null, + sessionId: 'sess-discord', + lastForwardedSeq: 0, + }); + + const { deps, sentToSession, warnings } = createDepsHarness(); + + failing.failPullOnce(new Error('telegram pull failed')); + healthy.pushInbound({ + providerId: 'discord', + conversationId: 'discord-room', + threadId: null, + text: 'still processed', + messageId: 'discord-message', + }); + + await executeChannelBridgeTick({ + store, + adapters: [failing.adapter, healthy.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(sentToSession).toEqual([ + { + sessionId: 'sess-discord', + text: 'still processed', + sentFrom: 'discord', + providerId: 'discord', + conversationId: 'discord-room', + threadId: null, + }, + ]); + expect(warnings.some((row) => row.message.includes('Failed to pull inbound messages for adapter telegram'))).toBe(true); + }); + + it('warns and ignores duplicate adapter provider ids', async () => { + const store = createInMemoryChannelBindingStore(); + const first = createAdapterHarness('telegram'); + const second = createAdapterHarness('telegram'); + + await store.upsertBinding({ + providerId: 'telegram', + conversationId: 'room-1', + threadId: null, + sessionId: 'sess-dup', + lastForwardedSeq: 0, + }); + + const { deps, sentToSession, warnings } = createDepsHarness(); + + first.pushInbound({ + providerId: 'telegram', + conversationId: 'room-1', + threadId: null, + text: 'from first adapter', + messageId: 'dup-1', + }); + second.pushInbound({ + providerId: 'telegram', + conversationId: 'room-1', + threadId: null, + text: 'from second adapter', + messageId: 'dup-2', + }); + + await executeChannelBridgeTick({ + store, + adapters: [first.adapter, second.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(sentToSession).toHaveLength(1); + expect(sentToSession[0]?.text).toBe('from first adapter'); + expect(warnings.some((row) => row.message.includes('Duplicate adapter providerId detected: telegram'))).toBe(true); + }); + + it('warns and replies when forwarding inbound text into session fails', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + + await store.upsertBinding({ + providerId: 'telegram', + conversationId: 'failing-room', + threadId: null, + sessionId: 'sess-fail', + lastForwardedSeq: 0, + }); + + const { deps, warnings } = createDepsHarness({ + sendUserMessageToSession: async () => { + throw new Error('session unavailable'); + }, + }); + + harness.pushInbound({ + providerId: 'telegram', + conversationId: 'failing-room', + threadId: null, + text: 'hello', + messageId: 'send-fail-msg', + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(warnings.some((row) => row.message.includes('Failed to forward channel message into session'))).toBe(true); + expect(harness.sent.some((row) => row.text.includes('Failed to send message to session sess-fail.'))).toBe(true); + }); + + it('persists cursor after successful sends when a later outbound row fails', async () => { + const store = createInMemoryChannelBindingStore(); + + await store.upsertBinding({ + providerId: 'telegram', + conversationId: '-2002', + threadId: null, + sessionId: 'sess-partial', + lastForwardedSeq: 9, + }); + + let sendCalls = 0; + const adapter: ChannelBridgeAdapter = { + providerId: 'telegram', + pullInboundMessages: async () => [], + sendMessage: async () => { + sendCalls += 1; + if (sendCalls >= 2) { + throw new Error('simulated send failure'); + } + }, + stop: async () => {}, + }; + + const { deps, warnings } = createDepsHarness({ + fetchAgentMessagesAfterSeq: async () => [ + { seq: 10, text: 'first row' }, + { seq: 11, text: 'second row fails' }, + ], + }); + + await executeChannelBridgeTick({ + store, + adapters: [adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + const [binding] = await store.listBindings(); + expect(binding?.lastForwardedSeq).toBe(10); + expect(warnings.some((row) => row.message.includes('Failed to forward agent output to channel'))).toBe(true); + }); + + it('warns when fetching outbound agent rows fails', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + + await store.upsertBinding({ + providerId: 'telegram', + conversationId: '-2003', + threadId: null, + sessionId: 'sess-fetch-fail', + lastForwardedSeq: 3, + }); + + const { deps, warnings } = createDepsHarness({ + fetchAgentMessagesAfterSeq: async () => { + throw new Error('transcript read failed'); + }, + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(harness.sent).toHaveLength(0); + expect(warnings.some((row) => row.message.includes('Failed to forward agent output to channel'))).toBe(true); + }); + + it('deduplicates repeated inbound messages across direct executeChannelBridgeTick calls', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + + await store.upsertBinding({ + providerId: 'telegram', + conversationId: 'direct-dedupe-room', + threadId: null, + sessionId: 'sess-direct-dedupe', + lastForwardedSeq: 0, + }); + + const { deps, sentToSession } = createDepsHarness(); + const repeated = { + providerId: 'telegram' as const, + conversationId: 'direct-dedupe-room', + threadId: null, + text: 'same payload', + messageId: 'direct-dedupe-id-1', + }; + + const deduper = createChannelBridgeInboundDeduper(); + + harness.pushInbound(repeated); + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: deduper, + }); + + harness.pushInbound(repeated); + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: deduper, + }); + + expect(sentToSession).toHaveLength(1); + }); + + it('does not dedupe messages when inbound messageId is empty', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + + await store.upsertBinding({ + providerId: 'telegram', + conversationId: 'empty-id-room', + threadId: null, + sessionId: 'sess-empty-id', + lastForwardedSeq: 0, + }); + + const { deps, sentToSession } = createDepsHarness(); + const messageWithEmptyId = { + providerId: 'telegram' as const, + conversationId: 'empty-id-room', + threadId: null, + text: 'same payload', + messageId: ' ', + }; + + const deduper = createChannelBridgeInboundDeduper(); + + harness.pushInbound(messageWithEmptyId); + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: deduper, + }); + + harness.pushInbound(messageWithEmptyId); + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: deduper, + }); + + expect(sentToSession).toHaveLength(2); + }); + + it('skips invalid outbound seq rows and warns without stalling valid cursor updates', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + + await store.upsertBinding({ + providerId: 'telegram', + conversationId: '-3001', + threadId: null, + sessionId: 'sess-invalid-seq', + lastForwardedSeq: 9, + }); + + const { deps, warnings } = createDepsHarness({ + fetchAgentMessagesAfterSeq: async () => [ + { seq: Number.NaN, text: 'invalid row' }, + { seq: 10, text: 'valid row' }, + ], + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + const [binding] = await store.listBindings(); + expect(binding?.lastForwardedSeq).toBe(10); + expect(harness.sent.some((row) => row.text.includes('valid row'))).toBe(true); + expect(harness.sent.some((row) => row.text.includes('invalid row'))).toBe(false); + expect(warnings.some((row) => row.message.includes('Skipped agent output row with invalid seq'))).toBe(true); + }); + + it('forwards outbound agent rows in ascending seq order even when source rows are unsorted', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + + await store.upsertBinding({ + providerId: 'telegram', + conversationId: '-3002', + threadId: null, + sessionId: 'sess-unsorted-seq', + lastForwardedSeq: 9, + }); + + const { deps } = createDepsHarness({ + fetchAgentMessagesAfterSeq: async () => [ + { seq: 12, text: 'third by seq' }, + { seq: 10, text: 'first by seq' }, + { seq: 11, text: 'second by seq' }, + ], + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(harness.sent.map((row) => row.text)).toEqual([ + 'first by seq', + 'second by seq', + 'third by seq', + ]); + + const [binding] = await store.listBindings(); + expect(binding?.lastForwardedSeq).toBe(12); + }); + + it('skips outbound rows at or below the current cursor', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + + await store.upsertBinding({ + providerId: 'telegram', + conversationId: '-3003', + threadId: null, + sessionId: 'sess-cursor-guard', + lastForwardedSeq: 10, + }); + + const { deps } = createDepsHarness({ + fetchAgentMessagesAfterSeq: async () => [ + { seq: 9, text: 'already forwarded before cursor' }, + { seq: 10, text: 'exactly at cursor' }, + { seq: 11, text: 'new row after cursor' }, + ], + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(harness.sent.map((row) => row.text)).toEqual(['new row after cursor']); + + const [binding] = await store.listBindings(); + expect(binding?.lastForwardedSeq).toBe(11); + }); + + it('warns and stops forwarding remaining rows when cursor persistence fails after send', async () => { + const baseStore = createInMemoryChannelBindingStore(); + const store: ChannelBindingStore = { + ...baseStore, + updateLastForwardedSeq: async () => { + throw new Error('cursor store unavailable'); + }, + }; + const harness = createAdapterHarness(); + + await baseStore.upsertBinding({ + providerId: 'telegram', + conversationId: '-3004', + threadId: null, + sessionId: 'sess-cursor-persist-fail', + lastForwardedSeq: 9, + }); + + const { deps, warnings } = createDepsHarness({ + fetchAgentMessagesAfterSeq: async () => [ + { seq: 10, text: 'first send succeeds' }, + { seq: 11, text: 'should not send after cursor failure' }, + ], + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(harness.sent.map((row) => row.text)).toEqual(['first send succeeds']); + expect(warnings.some((row) => row.message.includes('Failed to persist channel bridge cursor'))).toBe(true); + expect(warnings.some((row) => row.message.includes('Failed to forward agent output to channel'))).toBe(false); + + const [binding] = await baseStore.listBindings(); + expect(binding?.lastForwardedSeq).toBe(9); + }); + + it('warns when binding provider has no active adapter for outbound forwarding', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness('discord'); + + await store.upsertBinding({ + providerId: 'telegram', + conversationId: 'orphaned-room', + threadId: null, + sessionId: 'sess-orphaned', + lastForwardedSeq: 0, + }); + + const { deps, warnings } = createDepsHarness({ + fetchAgentMessagesAfterSeq: async () => [{ seq: 1, text: 'ignored' }], + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(warnings.some((row) => row.message.includes('No adapter registered for binding providerId=telegram'))).toBe(true); + }); + + it('warns once per binding when missing adapter warnings are tracked across ticks', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness('discord'); + + await store.upsertBinding({ + providerId: 'telegram', + conversationId: 'orphaned-room', + threadId: null, + sessionId: 'sess-orphaned', + lastForwardedSeq: 0, + }); + + const { deps, warnings } = createDepsHarness({ + fetchAgentMessagesAfterSeq: async () => [{ seq: 1, text: 'ignored' }], + }); + const warnedMissingAdapterBindings = new Set(); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + warnedMissingAdapterBindings, + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + warnedMissingAdapterBindings, + }); + + const missingAdapterWarnings = warnings.filter((row) => + row.message.includes('No adapter registered for binding providerId=telegram'), + ); + expect(missingAdapterWarnings).toHaveLength(1); + }); + + it('prunes stale missing-adapter warning keys when bindings are removed', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness('discord'); + + const bindingRef = { + providerId: 'telegram', + conversationId: 'orphaned-room', + threadId: null, + } as const; + + await store.upsertBinding({ + ...bindingRef, + sessionId: 'sess-orphaned', + lastForwardedSeq: 0, + }); + + const { deps, warnings } = createDepsHarness({ + fetchAgentMessagesAfterSeq: async () => [{ seq: 1, text: 'ignored' }], + }); + const warnedMissingAdapterBindings = new Set(); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + warnedMissingAdapterBindings, + }); + + await store.removeBinding(bindingRef); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + warnedMissingAdapterBindings, + }); + + await store.upsertBinding({ + ...bindingRef, + sessionId: 'sess-orphaned-2', + lastForwardedSeq: 0, + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + warnedMissingAdapterBindings, + }); + + const missingAdapterWarnings = warnings.filter((row) => + row.message.includes('No adapter registered for binding providerId=telegram'), + ); + expect(missingAdapterWarnings).toHaveLength(2); + }); + + it('warns and exits tick when listing bindings fails', async () => { + const baseStore = createInMemoryChannelBindingStore(); + const store: ChannelBindingStore = { + ...baseStore, + listBindings: async () => { + throw new Error('list bindings unavailable'); + }, + }; + const harness = createAdapterHarness(); + const { deps, warnings } = createDepsHarness(); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(warnings.some((row) => row.message.includes('Failed to list bindings for outbound forwarding'))).toBe(true); + }); +}); + +describe('startChannelBridgeWorker', () => { + it('runs the first tick on startup', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + const { deps } = createDepsHarness({ + sessions: [{ sessionId: 'sess-1', label: 'demo' }], + }); + + harness.pushInbound({ + providerId: 'telegram', + conversationId: '-2001', + threadId: null, + text: '/sessions', + messageId: 'startup-sessions', + }); + + const worker = startChannelBridgeWorker({ + store, + adapters: [harness.adapter], + deps, + tickMs: 60_000, + }); + + try { + await waitFor(() => harness.sent.length > 0); + expect(harness.sent.some((row) => row.text.includes('Active sessions'))).toBe(true); + } finally { + await worker.stop(); + } + }); + + it('deduplicates inbound messages across runtime ticks', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + + await store.upsertBinding({ + providerId: 'telegram', + conversationId: 'dedupe-room', + threadId: null, + sessionId: 'sess-dedupe', + lastForwardedSeq: 0, + }); + + const { deps, sentToSession } = createDepsHarness(); + + harness.pushInbound({ + providerId: 'telegram', + conversationId: 'dedupe-room', + threadId: null, + text: 'duplicate payload', + messageId: 'dedupe-id-1', + }); + + const worker = startChannelBridgeWorker({ + store, + adapters: [harness.adapter], + deps, + tickMs: 60_000, + }); + + try { + await waitFor(() => harness.pendingInboundCount() === 0 && sentToSession.length === 1); + expect(sentToSession).toHaveLength(1); + + harness.pushInbound({ + providerId: 'telegram', + conversationId: 'dedupe-room', + threadId: null, + text: 'duplicate payload', + messageId: 'dedupe-id-1', + }); + worker.trigger(); + + await waitFor(() => harness.pendingInboundCount() === 0 && sentToSession.length === 1); + expect(sentToSession).toHaveLength(1); + } finally { + await worker.stop(); + } + }); + + it('stops idempotently and waits for in-flight tick before stopping adapters', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + let startedTick = false; + + await store.upsertBinding({ + providerId: 'telegram', + conversationId: 'stop-room', + threadId: null, + sessionId: 'sess-stop', + lastForwardedSeq: 0, + }); + + const gate = createDeferredPromise(); + const { deps } = createDepsHarness({ + sendUserMessageToSession: async () => { + startedTick = true; + await gate.promise; + }, + }); + + harness.pushInbound({ + providerId: 'telegram', + conversationId: 'stop-room', + threadId: null, + text: 'block until released', + messageId: 'stop-message', + }); + + const worker = startChannelBridgeWorker({ + store, + adapters: [harness.adapter], + deps, + tickMs: 60_000, + }); + + try { + await waitFor(() => startedTick); + + const stopFirst = worker.stop(); + const stopSecond = worker.stop(); + expect(harness.stopCalls()).toBe(0); + + gate.resolve(); + await stopFirst; + await stopSecond; + + expect(harness.stopCalls()).toBe(1); + } finally { + gate.resolve(); + await worker.stop(); + } + }); + + it('waits for startup-triggered tick when stop is called immediately', async () => { + const store = createInMemoryChannelBindingStore(); + const gate = createDeferredPromise(); + let startedTick = false; + let stopCallCount = 0; + + const adapter: ChannelBridgeAdapter = { + providerId: 'telegram', + pullInboundMessages: async () => { + startedTick = true; + await gate.promise; + return []; + }, + sendMessage: async () => {}, + stop: async () => { + stopCallCount += 1; + }, + }; + + const { deps } = createDepsHarness(); + const worker = startChannelBridgeWorker({ + store, + adapters: [adapter], + deps, + tickMs: 60_000, + }); + + try { + const stopPromise = worker.stop(); + + await Promise.resolve(); + await waitFor(() => startedTick); + expect(stopCallCount).toBe(0); + + gate.resolve(); + await stopPromise; + + expect(stopCallCount).toBe(1); + } finally { + gate.resolve(); + await worker.stop(); + } + }); + + it('continues stopping remaining adapters if one stop fails', async () => { + const store = createInMemoryChannelBindingStore(); + let secondaryStopped = false; + const adapters: ChannelBridgeAdapter[] = [ + { + providerId: 'telegram', + pullInboundMessages: async () => [], + sendMessage: async () => {}, + stop: async () => { + throw new Error('primary stop failed'); + }, + }, + { + providerId: 'discord', + pullInboundMessages: async () => [], + sendMessage: async () => {}, + stop: async () => { + secondaryStopped = true; + }, + }, + ]; + + const { deps, warnings } = createDepsHarness(); + const worker = startChannelBridgeWorker({ + store, + adapters, + deps, + tickMs: 60_000, + }); + + try { + await worker.stop(); + + expect(secondaryStopped).toBe(true); + expect(warnings.some((row) => row.message.includes('Failed to stop channel adapter telegram'))).toBe(true); + } finally { + await worker.stop(); + } + }); + + it('stops distinct adapter instances even when provider ids are duplicated', async () => { + const store = createInMemoryChannelBindingStore(); + const firstStop = { calls: 0 }; + const duplicateStop = { calls: 0 }; + + const adapters: ChannelBridgeAdapter[] = [ + { + providerId: 'telegram', + pullInboundMessages: async () => [], + sendMessage: async () => {}, + stop: async () => { + firstStop.calls += 1; + }, + }, + { + providerId: 'telegram', + pullInboundMessages: async () => [], + sendMessage: async () => {}, + stop: async () => { + duplicateStop.calls += 1; + }, + }, + ]; + + const { deps } = createDepsHarness(); + const worker = startChannelBridgeWorker({ + store, + adapters, + deps, + tickMs: 60_000, + }); + + try { + await worker.stop(); + expect(firstStop.calls).toBe(1); + expect(duplicateStop.calls).toBe(1); + } finally { + await worker.stop(); + } + }); + + it('stops shared adapter references once during shutdown', async () => { + const store = createInMemoryChannelBindingStore(); + const stopCounter = { calls: 0 }; + + const sharedAdapter: ChannelBridgeAdapter = { + providerId: 'telegram', + pullInboundMessages: async () => [], + sendMessage: async () => {}, + stop: async () => { + stopCounter.calls += 1; + }, + }; + + const { deps, warnings } = createDepsHarness(); + const worker = startChannelBridgeWorker({ + store, + adapters: [sharedAdapter, sharedAdapter], + deps, + tickMs: 60_000, + }); + + try { + await worker.stop(); + expect(stopCounter.calls).toBe(1); + expect(warnings.some((row) => row.message.includes('Duplicate adapter providerId detected: telegram'))).toBe(true); + } finally { + await worker.stop(); + } + }); +}); diff --git a/apps/cli/src/channels/core/channelBridgeWorker.ts b/apps/cli/src/channels/core/channelBridgeWorker.ts new file mode 100644 index 0000000000..3d73831a53 --- /dev/null +++ b/apps/cli/src/channels/core/channelBridgeWorker.ts @@ -0,0 +1,943 @@ +/** + * Provider-agnostic channel bridge worker. + * + * Responsibilities: + * - Pull inbound channel messages from adapters + * - Handle slash commands (`/sessions`, `/attach`, `/detach`, `/session`, `/help`, `/start`) + * - Forward non-command inbound messages into attached Happier sessions + * - Forward agent output back into the mapped channel conversation + * + * Cursor semantics: + * - `lastForwardedSeq` tracks the highest transcript sequence that has been delivered + * to the channel for a given binding. + * - `fetchAgentMessagesAfterSeq` is treated as an exclusive cursor (`seq > afterSeq`). + * - `updateLastForwardedSeq` must persist the maximum forwarded sequence. + */ +import { startSingleFlightIntervalLoop, type SingleFlightIntervalLoopHandle } from '@/daemon/lifecycle/singleFlightIntervalLoop'; + +/** + * Logical channel conversation reference. + * + * For thread-capable providers, `threadId` identifies the topic/thread; for non-threaded + * conversations it is `null`. + */ +export type ChannelBridgeConversationRef = Readonly<{ + providerId: string; + conversationId: string; + threadId: string | null; +}>; + +/** + * Inbound message event produced by an adapter. + * + * `messageId` is used for best-effort duplicate suppression in the worker runtime. + */ +export type ChannelBridgeInboundMessage = ChannelBridgeConversationRef & Readonly<{ + senderId?: string | null; + text: string; + messageId: string; +}>; + +export type ChannelBridgeActorContext = Readonly<{ + providerId: string; + conversationId: string; + threadId: string | null; + senderId: string | null; +}>; + +/** + * Adapter contract for a specific provider (Telegram, Discord, etc.). + * + * Expectations: + * - `pullInboundMessages` should return available inbound items without throwing for + * normal empty states (return `[]` instead). + * - `sendMessage` should deliver text into a target conversation/thread. + * - `sendMessage` should tolerate at-least-once delivery attempts. Timeout races may + * trigger retries, so provider adapters should be idempotent when possible. + * - `stop` is optional and should tear down adapter resources. + */ +export type ChannelBridgeAdapter = Readonly<{ + providerId: string; + pullInboundMessages: () => Promise; + sendMessage: (params: Readonly<{ conversationId: string; threadId: string | null; text: string }>) => Promise; + stop?: () => void | Promise; +}>; + +/** + * Persisted conversation -> session mapping and agent cursor state. + */ +export type ChannelSessionBinding = ChannelBridgeConversationRef & Readonly<{ + sessionId: string; + lastForwardedSeq: number; + createdAtMs: number; + updatedAtMs: number; +}>; + +/** + * Resolution result for `/attach `. + */ +export type ResolveSessionIdResult = + | Readonly<{ ok: true; sessionId: string }> + | Readonly<{ ok: false; code: 'session_not_found' | 'session_id_ambiguous' | 'unsupported'; candidates?: string[] }>; + +/** + * Bridge dependencies supplied by runtime integration. + * + * - `resolveLatestSessionSeq` should return the latest valid non-negative transcript cursor. + * - `fetchAgentMessagesAfterSeq` should return rows with `seq > afterSeq`. + * Results may be unsorted; the worker enforces ascending `seq` delivery before forwarding. + * - `onWarning` receives non-fatal operational issues; worker continues best-effort. + */ +export type ChannelBridgeDeps = Readonly<{ + listSessions: () => Promise>>; + resolveSessionIdOrPrefix: (idOrPrefix: string) => Promise; + sendUserMessageToSession: (params: Readonly<{ + sessionId: string; + text: string; + sentFrom: string; + providerId: string; + conversationId: string; + threadId: string | null; + }>) => Promise; + resolveLatestSessionSeq: (sessionId: string) => Promise; + fetchAgentMessagesAfterSeq: (params: Readonly<{ sessionId: string; afterSeq: number }>) => Promise>>; + authorizeCommand?: (params: Readonly<{ commandName: string; actor: ChannelBridgeActorContext }>) => Promise>; + onWarning?: (message: string, error?: unknown) => void; +}>; + +/** + * Binding persistence contract used by the bridge worker. + * + * `updateLastForwardedSeq` is monotonic: implementations should keep the highest cursor. + */ +export type ChannelBindingStore = Readonly<{ + listBindings: () => Promise; + getBinding: (ref: ChannelBridgeConversationRef) => Promise; + upsertBinding: (binding: Readonly<{ + providerId: string; + conversationId: string; + threadId: string | null; + sessionId: string; + lastForwardedSeq: number; + }>) => Promise; + updateLastForwardedSeq: (ref: ChannelBridgeConversationRef, seq: number) => Promise; + removeBinding: (ref: ChannelBridgeConversationRef) => Promise; +}>; + +export type ChannelBridgeWorkerHandle = Readonly<{ + /** Stops the worker. Idempotent; safe to call multiple times. */ + stop: () => Promise; + /** Requests an immediate tick; no-op once `stop()` has been called. */ + trigger: () => void; +}>; + +/** + * Key encoding for in-memory binding map. + * + * Uses JSON array encoding to avoid delimiter collision risks. + */ +function bindingKey(ref: ChannelBridgeConversationRef): string { + return JSON.stringify([ref.providerId, ref.conversationId, ref.threadId]); +} + +function toNonNegativeInt(value: unknown): number | null { + if (typeof value !== 'number' || !Number.isFinite(value)) return null; + const parsed = Math.trunc(value); + if (parsed < 0) return null; + return parsed; +} + +const DEFAULT_EXTERNAL_IO_TIMEOUT_MS = 30_000; + +function resolveExternalIoTimeoutMs(): number { + const raw = (process.env.HAPPIER_CHANNEL_BRIDGE_IO_TIMEOUT_MS ?? '').trim(); + if (!raw) { + return DEFAULT_EXTERNAL_IO_TIMEOUT_MS; + } + + const parsed = Number.parseInt(raw, 10); + if (!Number.isFinite(parsed) || parsed <= 0) { + return DEFAULT_EXTERNAL_IO_TIMEOUT_MS; + } + + return parsed; +} + +const EXTERNAL_IO_TIMEOUT_MS = resolveExternalIoTimeoutMs(); + +async function withTimeout(promise: Promise, timeoutMs: number, label: string): Promise { + // Promise.race does not cancel the underlying operation. Attach a no-op rejection + // handler so late failures do not surface as unhandled rejections after timeout. + void promise.catch(() => undefined); + + let timeoutHandle: ReturnType | null = null; + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timeoutHandle = setTimeout(() => { + reject(new Error(`${label} timed out after ${timeoutMs}ms`)); + }, timeoutMs); + }), + ]); + } finally { + if (timeoutHandle) { + clearTimeout(timeoutHandle); + } + } +} + +type ChannelBridgeInboundDeduper = Readonly<{ + isDuplicate: (message: ChannelBridgeInboundMessage) => boolean; +}>; + +/** + * Create an inbound deduper for channel messages. + * + * Use this to isolate dedupe state across independent bridge instances sharing the same process. + */ +export function createChannelBridgeInboundDeduper(now: () => number = () => Date.now()): ChannelBridgeInboundDeduper { + const recent = new Map(); + const ttlMs = 5 * 60 * 1000; + const maxEntries = 20_000; + const minPruneIntervalMs = 1_000; + let lastPrunedAtMs = 0; + + const prune = (currentNow: number) => { + if (recent.size <= maxEntries && currentNow - lastPrunedAtMs < minPruneIntervalMs) { + return; + } + lastPrunedAtMs = currentNow; + + for (const [key, seenAtMs] of recent) { + if (currentNow - seenAtMs > ttlMs) { + recent.delete(key); + } + } + while (recent.size > maxEntries) { + const [oldest] = recent.keys(); + if (oldest === undefined) break; + recent.delete(oldest); + } + }; + + return { + isDuplicate: (message) => { + const normalizedMessageId = String(message.messageId).trim(); + if (normalizedMessageId.length === 0) { + return false; + } + + const key = JSON.stringify([message.providerId, message.conversationId, message.threadId, normalizedMessageId]); + const currentNow = now(); + prune(currentNow); + if (recent.has(key)) return true; + recent.set(key, currentNow); + return false; + }, + }; +} + +/** + * Create an in-memory binding store. + * + * `now` is injectable for deterministic tests. + */ +export function createInMemoryChannelBindingStore(now: () => number = () => Date.now()): ChannelBindingStore { + const byKey = new Map(); + + return { + listBindings: async () => Array.from(byKey.values()), + getBinding: async (ref) => byKey.get(bindingKey(ref)) ?? null, + upsertBinding: async (binding) => { + const key = bindingKey(binding); + const existing = byKey.get(key); + const normalizedLastForwardedSeq = toNonNegativeInt(binding.lastForwardedSeq) ?? 0; + const next: ChannelSessionBinding = { + providerId: binding.providerId, + conversationId: binding.conversationId, + threadId: binding.threadId, + sessionId: binding.sessionId, + lastForwardedSeq: normalizedLastForwardedSeq, + createdAtMs: existing?.createdAtMs ?? now(), + updatedAtMs: now(), + }; + byKey.set(key, next); + return next; + }, + updateLastForwardedSeq: async (ref, seq) => { + const key = bindingKey(ref); + const existing = byKey.get(key); + if (!existing) return; + const parsedSeq = toNonNegativeInt(seq); + if (parsedSeq === null) return; + const nextSeq = Math.max(existing.lastForwardedSeq, parsedSeq); + byKey.set(key, { + ...existing, + lastForwardedSeq: nextSeq, + updatedAtMs: now(), + }); + }, + removeBinding: async (ref) => byKey.delete(bindingKey(ref)), + }; +} + +function parseSlashCommand(text: string): Readonly<{ name: string; args: string[] }> | null { + const trimmed = text.trim(); + if (!trimmed.startsWith('/')) return null; + const [rawName, ...args] = trimmed.slice(1).split(/\s+/g); + const normalized = String(rawName).trim().toLowerCase(); + if (!normalized) return null; + const name = normalized.split('@')[0]!.trim(); + if (!name) return null; + return { name, args }; +} + +function formatSessionsMessage(rows: Array>): string { + if (rows.length === 0) { + return 'No active sessions found.'; + } + const limit = 20; + const truncated = rows.length > limit; + const body = rows + .slice(0, limit) + .map((row) => `• ${row.sessionId}${row.label ? ` (${row.label})` : ''}`) + .join('\n'); + const suffix = truncated ? `\n…and ${rows.length - limit} more.` : ''; + return `Active sessions:\n${body}${suffix}`; +} + +async function replyToConversation( + adapter: ChannelBridgeAdapter, + conversation: Readonly<{ conversationId: string; threadId: string | null }>, + text: string, +): Promise { + await withTimeout( + adapter.sendMessage({ + conversationId: conversation.conversationId, + threadId: conversation.threadId, + text, + }), + EXTERNAL_IO_TIMEOUT_MS, + `replyToConversation(${adapter.providerId}:${conversation.conversationId})`, + ); +} + +async function authorizeCommand(params: Readonly<{ + commandName: string; + event: ChannelBridgeInboundMessage; + deps: ChannelBridgeDeps; +}>): Promise> { + const authorize = params.deps.authorizeCommand; + if (!authorize) { + return { allowed: true, message: null }; + } + + const senderRaw = params.event.senderId; + const senderId = typeof senderRaw === 'string' && senderRaw.trim().length > 0 ? senderRaw.trim() : null; + const actor: ChannelBridgeActorContext = { + providerId: params.event.providerId, + conversationId: params.event.conversationId, + threadId: params.event.threadId, + senderId, + }; + + try { + const result = await authorize({ + commandName: params.commandName, + actor, + }); + if (typeof result === 'boolean') { + return { + allowed: result, + message: null, + }; + } + + const allowed = Boolean(result.allowed); + const message = typeof result.message === 'string' && result.message.trim().length > 0 ? result.message.trim() : null; + return { allowed, message }; + } catch (error) { + params.deps.onWarning?.(`Authorization check failed for command /${params.commandName}`, error); + return { + allowed: false, + message: 'Unable to authorize this command right now.', + }; + } +} + +async function handleCommand(params: Readonly<{ + command: Readonly<{ name: string; args: string[] }>; + event: ChannelBridgeInboundMessage; + adapter: ChannelBridgeAdapter; + store: ChannelBindingStore; + deps: ChannelBridgeDeps; +}>): Promise { + const { command, event, adapter, store, deps } = params; + const ref: ChannelBridgeConversationRef = { + providerId: event.providerId, + conversationId: event.conversationId, + threadId: event.threadId, + }; + + if (command.name !== 'help' && command.name !== 'start') { + const authz = await authorizeCommand({ + commandName: command.name, + event, + deps, + }); + if (!authz.allowed) { + await replyToConversation( + adapter, + ref, + authz.message ?? 'You are not authorized to run this command here.', + ); + return true; + } + } + + if (command.name === 'help' || command.name === 'start') { + await replyToConversation( + adapter, + ref, + [ + 'Happier bridge commands:', + '/sessions - list active sessions', + '/attach - bind this DM/topic', + '/detach - unbind this DM/topic', + '/session - show current binding', + '/help - show command help', + '/start - alias for /help', + ].join('\n'), + ); + return true; + } + + if (command.name === 'sessions') { + let sessions: Array>; + try { + sessions = await deps.listSessions(); + } catch (error) { + deps.onWarning?.('Failed to list sessions for /sessions command', error); + await replyToConversation(adapter, ref, 'Failed to retrieve sessions. Please try again later.'); + return true; + } + await replyToConversation(adapter, ref, formatSessionsMessage(sessions)); + return true; + } + + if (command.name === 'session') { + let existing: ChannelSessionBinding | null; + try { + existing = await store.getBinding(ref); + } catch (error) { + deps.onWarning?.('Failed to read binding for /session command', error); + await replyToConversation(adapter, ref, 'Failed to read current session binding. Please try again later.'); + return true; + } + + if (!existing) { + await replyToConversation(adapter, ref, 'No session is attached here. Use /attach .'); + return true; + } + await replyToConversation(adapter, ref, `Attached session: ${existing.sessionId}`); + return true; + } + + if (command.name === 'attach') { + const idOrPrefix = String(command.args[0] ?? '').trim(); + if (!idOrPrefix) { + await replyToConversation(adapter, ref, 'Usage: /attach '); + return true; + } + + let resolved: ResolveSessionIdResult; + try { + resolved = await deps.resolveSessionIdOrPrefix(idOrPrefix); + } catch (error) { + deps.onWarning?.('Failed to resolve session by id/prefix for attach', error); + await replyToConversation( + adapter, + ref, + `Failed to attach to session ${idOrPrefix}: unable to resolve session identifier.`, + ); + return true; + } + + if (!resolved.ok) { + if (resolved.code === 'session_id_ambiguous') { + if (resolved.candidates && resolved.candidates.length > 0) { + await replyToConversation( + adapter, + ref, + `Ambiguous session prefix. Candidates:\n${resolved.candidates.map((id) => `• ${id}`).join('\n')}`, + ); + return true; + } + + await replyToConversation(adapter, ref, 'Ambiguous session prefix. Use /sessions to list active sessions.'); + return true; + } + + if (resolved.code === 'unsupported') { + await replyToConversation(adapter, ref, 'Attaching by session ID or prefix is not supported in this environment.'); + return true; + } + + await replyToConversation(adapter, ref, 'Session not found. Use /sessions to list active sessions.'); + return true; + } + + let latestSeq: number; + try { + const resolvedSeq = toNonNegativeInt(await deps.resolveLatestSessionSeq(resolved.sessionId)); + if (resolvedSeq === null) { + deps.onWarning?.( + `resolveLatestSessionSeq returned an invalid value for session ${resolved.sessionId}; expected a non-negative integer`, + ); + await replyToConversation( + adapter, + ref, + `Failed to attach to session ${resolved.sessionId}: unable to resolve latest sequence cursor.`, + ); + return true; + } + latestSeq = resolvedSeq; + } catch (error) { + deps.onWarning?.('Failed to resolve latest session sequence for attach', error); + await replyToConversation( + adapter, + ref, + `Failed to attach to session ${resolved.sessionId}: unable to resolve latest sequence cursor.`, + ); + return true; + } + + let previousBinding: ChannelSessionBinding | null; + try { + previousBinding = await store.getBinding(ref); + } catch (error) { + deps.onWarning?.('Failed to read existing binding during /attach', error); + await replyToConversation(adapter, ref, 'Failed to read current binding before attach. Please try again later.'); + return true; + } + const previousSessionId = previousBinding?.sessionId ?? null; + + try { + await store.upsertBinding({ + providerId: ref.providerId, + conversationId: ref.conversationId, + threadId: ref.threadId, + sessionId: resolved.sessionId, + lastForwardedSeq: latestSeq, + }); + } catch (error) { + deps.onWarning?.('Failed to persist binding during /attach', error); + await replyToConversation(adapter, ref, `Failed to attach to session ${resolved.sessionId}: unable to persist binding.`); + return true; + } + + const switchedFrom = + previousSessionId && previousSessionId !== resolved.sessionId + ? ` (replaced previous session ${previousSessionId})` + : ''; + await replyToConversation(adapter, ref, `Attached this conversation to session ${resolved.sessionId}${switchedFrom}.`); + return true; + } + + if (command.name === 'detach') { + let removed = false; + try { + removed = await store.removeBinding(ref); + } catch (error) { + deps.onWarning?.('Failed to remove binding for /detach command', error); + await replyToConversation(adapter, ref, 'Failed to detach current session binding. Please try again later.'); + return true; + } + + if (removed) { + await replyToConversation(adapter, ref, 'Detached this conversation from Happier session.'); + } else { + await replyToConversation(adapter, ref, 'No session was attached here.'); + } + return true; + } + + await replyToConversation(adapter, ref, `Unknown command: /${command.name}. Use /help for supported commands.`); + return true; +} + +/** + * Execute one bridge tick. + * + * Flow: + * 1) Pull inbound messages per adapter + * 2) Handle commands or forward user text to attached session + * 3) Fetch agent output after each binding cursor and send to channel + * 4) Advance cursors monotonically + * + * Deduper behavior: + * - `inboundDeduper` is required to make dedupe-state ownership explicit. + * - Use `createChannelBridgeInboundDeduper()` to construct per-worker dedupe state. + * + * Missing-adapter warning deduplication: + * - `warnedMissingAdapterBindings` is optional. When omitted, missing-adapter + * warnings are emitted per binding on each call. + * - Pass a stable `Set` across calls to dedupe warning spam in + * long-running loops (as `startChannelBridgeWorker` does). + */ +export async function executeChannelBridgeTick(params: Readonly<{ + store: ChannelBindingStore; + adapters: readonly ChannelBridgeAdapter[]; + deps: ChannelBridgeDeps; + inboundDeduper: Readonly<{ + isDuplicate: (message: ChannelBridgeInboundMessage) => boolean; + }>; + warnedMissingAdapterBindings?: Set; +}>): Promise { + const activeAdapters: ChannelBridgeAdapter[] = []; + const adapterByProvider = new Map(); + for (const adapter of params.adapters) { + if (adapterByProvider.has(adapter.providerId)) { + params.deps.onWarning?.(`Duplicate adapter providerId detected: ${adapter.providerId}; ignoring later adapter instance.`); + continue; + } + adapterByProvider.set(adapter.providerId, adapter); + activeAdapters.push(adapter); + } + + const deduper = params.inboundDeduper; + + for (const adapter of activeAdapters) { + let inbound: ChannelBridgeInboundMessage[]; + try { + inbound = await withTimeout( + adapter.pullInboundMessages(), + EXTERNAL_IO_TIMEOUT_MS, + `pullInboundMessages(${adapter.providerId})`, + ); + } catch (error) { + params.deps.onWarning?.(`Failed to pull inbound messages for adapter ${adapter.providerId}`, error); + continue; + } + + for (const rawEvent of inbound) { + const event: ChannelBridgeInboundMessage = + rawEvent.providerId === adapter.providerId + ? rawEvent + : { + ...rawEvent, + providerId: adapter.providerId, + }; + + if (rawEvent.providerId !== adapter.providerId) { + params.deps.onWarning?.( + `Inbound provider mismatch; using adapter providerId=${adapter.providerId} instead of event providerId=${rawEvent.providerId}`, + ); + } + + if (deduper.isDuplicate(event)) { + continue; + } + + try { + const command = parseSlashCommand(event.text); + if (command) { + await handleCommand({ + command, + event, + adapter, + store: params.store, + deps: params.deps, + }); + continue; + } + + if (event.text.trim().startsWith('/')) { + await replyToConversation(adapter, { + conversationId: event.conversationId, + threadId: event.threadId, + }, 'Unknown command. Use /help for supported commands.'); + continue; + } + + const ref: ChannelBridgeConversationRef = { + providerId: adapter.providerId, + conversationId: event.conversationId, + threadId: event.threadId, + }; + let binding: ChannelSessionBinding | null; + try { + binding = await params.store.getBinding(ref); + } catch (error) { + params.deps.onWarning?.( + `Failed to read binding for inbound message forwarding (provider=${adapter.providerId} conversation=${event.conversationId} thread=${event.threadId ?? 'null'})`, + error, + ); + await replyToConversation( + adapter, + ref, + 'Failed to read current session binding. Please try again later.', + ); + continue; + } + + if (!binding) { + await replyToConversation( + adapter, + ref, + 'No session is attached here. Use /attach first.', + ); + continue; + } + + try { + await withTimeout( + params.deps.sendUserMessageToSession({ + sessionId: binding.sessionId, + text: event.text, + sentFrom: adapter.providerId, + providerId: adapter.providerId, + conversationId: event.conversationId, + threadId: event.threadId, + }), + EXTERNAL_IO_TIMEOUT_MS, + `sendUserMessageToSession(${binding.sessionId})`, + ); + } catch (error) { + params.deps.onWarning?.( + `Failed to forward channel message into session ${binding.sessionId} (provider=${adapter.providerId} conversation=${event.conversationId} thread=${event.threadId ?? 'null'} messageId=${event.messageId})`, + error, + ); + await replyToConversation( + adapter, + ref, + `Failed to send message to session ${binding.sessionId}.`, + ); + } + } catch (error) { + params.deps.onWarning?.(`Failed to process inbound message for adapter ${adapter.providerId}`, error); + } + } + } + + let bindings: ChannelSessionBinding[]; + try { + bindings = await params.store.listBindings(); + } catch (error) { + params.deps.onWarning?.('Failed to list bindings for outbound forwarding', error); + return; + } + + const warnedMissingAdapterBindings = params.warnedMissingAdapterBindings; + if (warnedMissingAdapterBindings) { + const activeBindingKeys = new Set(bindings.map((binding) => bindingKey(binding))); + for (const warnedKey of warnedMissingAdapterBindings) { + if (!activeBindingKeys.has(warnedKey)) { + warnedMissingAdapterBindings.delete(warnedKey); + } + } + } + + for (const binding of bindings) { + const missingBindingWarningKey = bindingKey(binding); + const adapter = adapterByProvider.get(binding.providerId); + if (!adapter) { + if (!warnedMissingAdapterBindings || !warnedMissingAdapterBindings.has(missingBindingWarningKey)) { + params.deps.onWarning?.( + `No adapter registered for binding providerId=${binding.providerId} conversationId=${binding.conversationId}; skipping outbound forwarding`, + ); + warnedMissingAdapterBindings?.add(missingBindingWarningKey); + } + continue; + } + + warnedMissingAdapterBindings?.delete(missingBindingWarningKey); + + try { + const messages = await withTimeout( + params.deps.fetchAgentMessagesAfterSeq({ + sessionId: binding.sessionId, + afterSeq: binding.lastForwardedSeq, + }), + EXTERNAL_IO_TIMEOUT_MS, + `fetchAgentMessagesAfterSeq(${binding.sessionId})`, + ); + + const orderedMessages: Array> = []; + for (const row of messages) { + const parsedSeq = toNonNegativeInt(row.seq); + if (parsedSeq === null) { + params.deps.onWarning?.( + `Skipped agent output row with invalid seq for session ${binding.sessionId}`, + ); + continue; + } + orderedMessages.push({ + seq: parsedSeq, + text: row.text, + }); + } + + orderedMessages.sort((left, right) => left.seq - right.seq); + + let maxSeq = binding.lastForwardedSeq; + const persistCursor = async (nextSeq: number): Promise => { + try { + maxSeq = nextSeq; + await params.store.updateLastForwardedSeq(binding, maxSeq); + return true; + } catch (error) { + params.deps.onWarning?.( + `Failed to persist channel bridge cursor for session=${binding.sessionId} provider=${binding.providerId} conversation=${binding.conversationId} seq=${nextSeq}`, + error, + ); + return false; + } + }; + + for (const row of orderedMessages) { + const parsedSeq = row.seq; + if (parsedSeq <= maxSeq) { + continue; + } + + const nextSeq = parsedSeq; + const text = String(row.text).trim(); + if (!text) { + const persisted = await persistCursor(nextSeq); + if (!persisted) { + break; + } + continue; + } + await withTimeout( + adapter.sendMessage({ + conversationId: binding.conversationId, + threadId: binding.threadId, + text, + }), + EXTERNAL_IO_TIMEOUT_MS, + `sendMessage(${adapter.providerId})`, + ); + const persisted = await persistCursor(nextSeq); + if (!persisted) { + break; + } + } + } catch (error) { + params.deps.onWarning?.( + `Failed to forward agent output to channel for session=${binding.sessionId} provider=${binding.providerId} conversation=${binding.conversationId}`, + error, + ); + } + } +} + +/** + * Start the bridge worker loop. + * + * - `tickMs` is clamped to a minimum of 250ms (default 2500ms) + * - Uses single-flight scheduling: only one tick runs at a time + * - `trigger()` requests an immediate tick + * - `stop()` is idempotent, drains in-flight tick, then stops adapters + * - Adapter shutdown deduplicates by object identity, not `providerId` + */ +export function startChannelBridgeWorker(params: Readonly<{ + store: ChannelBindingStore; + adapters: readonly ChannelBridgeAdapter[]; + deps: ChannelBridgeDeps; + tickMs?: number; +}>): ChannelBridgeWorkerHandle { + const tickMs = + typeof params.tickMs === 'number' && Number.isFinite(params.tickMs) && params.tickMs > 0 + ? Math.max(250, Math.trunc(params.tickMs)) + : 2_500; + + const inboundDeduper = createChannelBridgeInboundDeduper(); + const warnedMissingAdapterBindings = new Set(); + let inFlightTick: Promise | null = null; + + const runTick = async (): Promise => { + const tickRun = executeChannelBridgeTick({ + store: params.store, + adapters: params.adapters, + deps: params.deps, + inboundDeduper, + warnedMissingAdapterBindings, + }); + inFlightTick = tickRun; + try { + await tickRun; + } finally { + if (inFlightTick === tickRun) { + inFlightTick = null; + } + } + }; + + let loop: SingleFlightIntervalLoopHandle | null = startSingleFlightIntervalLoop({ + intervalMs: tickMs, + task: runTick, + onError: (error) => { + params.deps.onWarning?.('Channel bridge tick failed', error); + }, + }); + + loop.trigger(); + let stopPromise: Promise | null = null; + + return { + stop: async () => { + if (stopPromise) { + await stopPromise; + return; + } + + stopPromise = (async () => { + const activeLoop = loop; + loop = null; + activeLoop?.stop(); + + await Promise.resolve(); + + const currentTick = inFlightTick; + if (currentTick) { + try { + await currentTick; + } catch { + // Tick failures are already surfaced via loop onError while running. + // During shutdown we only drain the in-flight tick before adapter stop. + } + } + + const adaptersToStop: ChannelBridgeAdapter[] = []; + const seenAdapters = new Set(); + for (const adapter of params.adapters) { + if (seenAdapters.has(adapter)) { + continue; + } + seenAdapters.add(adapter); + adaptersToStop.push(adapter); + } + + const stopResults = await Promise.allSettled( + adaptersToStop.map(async (adapter) => { + if (typeof adapter.stop !== 'function') return; + await adapter.stop(); + }), + ); + + stopResults.forEach((result, index) => { + if (result.status === 'rejected') { + const providerId = adaptersToStop[index]?.providerId ?? 'unknown'; + params.deps.onWarning?.(`Failed to stop channel adapter ${providerId} during shutdown`, result.reason); + } + }); + })(); + + await stopPromise; + }, + trigger: () => loop?.trigger(), + }; +} diff --git a/docs/README.md b/docs/README.md index b08c940eaf..1f2ffa8f15 100644 --- a/docs/README.md +++ b/docs/README.md @@ -14,6 +14,7 @@ This folder documents how Happier works internally, with a focus on protocol, ba - opencode-feature-matrix.md: Low-level OpenCode implementation matrix and unified-architecture migration notes. - pi-feature-matrix.md: Low-level PI implementation matrix and unified-architecture migration notes. - acp-provider-feature-matrix.md: Low-level ACP-provider matrix and catalog migration notes. +- channel-bridge.md: Provider-agnostic bridge architecture and adapter contract. - issue-triage.md: How the GitHub issue triage workflows are wired to maintainer tooling. ## Conventions diff --git a/docs/channel-bridge.md b/docs/channel-bridge.md new file mode 100644 index 0000000000..c7cce7e03c --- /dev/null +++ b/docs/channel-bridge.md @@ -0,0 +1,57 @@ +# Channel Bridge Core + +The channel bridge core is a provider-agnostic runtime that maps external channel conversations to Happier sessions. + +Core implementation in this PR: + +- Worker/runtime loop: `apps/cli/src/channels/core/channelBridgeWorker.ts` + +## Core responsibilities + +- receive inbound messages from adapter implementations +- parse shared control commands (`/sessions`, `/attach`, `/session`, `/detach`, `/help`, `/start` as alias of `/help`) +- maintain conversation-to-session bindings by `(providerId, conversationId, threadId|null)` +- forward bound user text into the target session +- fetch assistant output after a cursor and forward back to channel conversations +- track per-binding cursor (`lastForwardedSeq`) to avoid replaying older assistant rows + +## Binding model + +Bindings are keyed by provider + conversation + optional thread/topic. + +- `providerId`: adapter namespace (for example, channel family) +- `conversationId`: channel-specific room/chat identifier +- `threadId`: optional sub-thread/topic identifier +- `sessionId`: Happier session bound to that conversation key +- `lastForwardedSeq`: last assistant transcript row sent through bridge + +## Tick loop behavior + +Each tick performs: + +1. pull inbound messages from each adapter +2. handle control commands (`/sessions`, `/attach`, `/session`, `/detach`, `/help`, `/start`) +3. route non-command user text to bound sessions +4. list current bindings +5. fetch assistant rows after each binding cursor +6. forward assistant messages back through the adapter +7. advance binding cursor after successful forwarding + +The worker uses single-flight scheduling in `startChannelBridgeWorker` so only one tick executes at a time. + +## Adapter contract + +Adapters plug into the core using a small interface: + +- `providerId` +- `pullInboundMessages()` +- `sendMessage({ conversationId, threadId, text })` +- optional `stop()` lifecycle hook + +This keeps command semantics, binding behavior, and session forwarding logic centralized in the core. + +## Out of scope for this PR + +This document covers core bridge runtime behavior only. + +Provider-specific transport details, bridge runtime bootstrapping, scoped config sources, and server-relay deployment modes are covered in follow-up stacked PRs. From 396a3b6aac542976f19f34d99c5fc719709d90d0 Mon Sep 17 00:00:00 2001 From: Chris Denneen Date: Fri, 6 Mar 2026 12:12:33 -0500 Subject: [PATCH 02/81] feat(cli): add telegram channel adapter, scoped bridge config, and diagnostics --- README.md | 5 + .../channelBindingStore.server.test.ts | 528 +++ .../channels/channelBindingStore.server.ts | 264 ++ .../channelBridgeAccountConfig.test.ts | 212 + .../channels/channelBridgeAccountConfig.ts | 344 ++ .../src/channels/channelBridgeConfig.test.ts | 231 ++ apps/cli/src/channels/channelBridgeConfig.ts | 203 + .../channelBridgeServerConfigOverlay.test.ts | 109 + .../channelBridgeServerConfigOverlay.ts | 50 + .../channels/channelBridgeServerKv.test.ts | 621 +++ .../cli/src/channels/channelBridgeServerKv.ts | 614 +++ .../channels/core/channelBridgeWorker.test.ts | 93 +- .../src/channels/core/channelBridgeWorker.ts | 18 +- ...ChannelBridgeWorker.startupFailure.test.ts | 144 + .../channels/startChannelBridgeWorker.test.ts | 446 ++ .../src/channels/startChannelBridgeWorker.ts | 375 ++ .../channels/telegram/telegramAdapter.test.ts | 295 ++ .../src/channels/telegram/telegramAdapter.ts | 255 ++ .../telegram/telegramWebhookRelay.test.ts | 122 + .../channels/telegram/telegramWebhookRelay.ts | 73 + apps/cli/src/cli/commandRegistry.ts | 2 + apps/cli/src/cli/commandSurfaceManifest.ts | 6 + apps/cli/src/cli/commands/bridge.test.ts | 162 + apps/cli/src/cli/commands/bridge.ts | 358 ++ ...startDaemon.automation.integration.test.ts | 9 + apps/cli/src/daemon/startDaemon.ts | 91 + apps/cli/src/persistence.ts | 5 + apps/cli/src/ui/doctor.test.ts | 125 +- apps/cli/src/ui/doctor.ts | 299 +- docs/README.md | 3 +- docs/assets/doctor-critical-summary.png | Bin 0 -> 77342 bytes .../doctor-missing-host-port-summary.png | Bin 0 -> 87113 bytes docs/channel-bridge-uat.md | 92 + docs/telegram-channel-bridge.md | 243 ++ yarn.lock | 3581 ++++++----------- 35 files changed, 7703 insertions(+), 2275 deletions(-) create mode 100644 apps/cli/src/channels/channelBindingStore.server.test.ts create mode 100644 apps/cli/src/channels/channelBindingStore.server.ts create mode 100644 apps/cli/src/channels/channelBridgeAccountConfig.test.ts create mode 100644 apps/cli/src/channels/channelBridgeAccountConfig.ts create mode 100644 apps/cli/src/channels/channelBridgeConfig.test.ts create mode 100644 apps/cli/src/channels/channelBridgeConfig.ts create mode 100644 apps/cli/src/channels/channelBridgeServerConfigOverlay.test.ts create mode 100644 apps/cli/src/channels/channelBridgeServerConfigOverlay.ts create mode 100644 apps/cli/src/channels/channelBridgeServerKv.test.ts create mode 100644 apps/cli/src/channels/channelBridgeServerKv.ts create mode 100644 apps/cli/src/channels/startChannelBridgeWorker.startupFailure.test.ts create mode 100644 apps/cli/src/channels/startChannelBridgeWorker.test.ts create mode 100644 apps/cli/src/channels/startChannelBridgeWorker.ts create mode 100644 apps/cli/src/channels/telegram/telegramAdapter.test.ts create mode 100644 apps/cli/src/channels/telegram/telegramAdapter.ts create mode 100644 apps/cli/src/channels/telegram/telegramWebhookRelay.test.ts create mode 100644 apps/cli/src/channels/telegram/telegramWebhookRelay.ts create mode 100644 apps/cli/src/cli/commands/bridge.test.ts create mode 100644 apps/cli/src/cli/commands/bridge.ts create mode 100644 docs/assets/doctor-critical-summary.png create mode 100644 docs/assets/doctor-missing-host-port-summary.png create mode 100644 docs/channel-bridge-uat.md create mode 100644 docs/telegram-channel-bridge.md diff --git a/README.md b/README.md index 367ec32bcd..7235f40df3 100644 --- a/README.md +++ b/README.md @@ -193,6 +193,11 @@ Happier acts as a secure bridge between your local development environment and y - it communicates with the daemon through the relay server - it receive daemon updates (sessions updates, messages, etc) through the relay server +## Channel Integrations + +- Telegram bi-directional bridge setup (BotFather, topics/DM mapping, optional webhook relay): + - [Telegram channel bridge guide](docs/telegram-channel-bridge.md) + ## Self-Hosting the Server Relay Happier is 100% self-hostable. It's even the most recommended way to run it, even if we also offer an end-to-end encrypted cloud server (app.happier.dev / api.happier.dev). diff --git a/apps/cli/src/channels/channelBindingStore.server.test.ts b/apps/cli/src/channels/channelBindingStore.server.test.ts new file mode 100644 index 0000000000..a3ffc0dcbc --- /dev/null +++ b/apps/cli/src/channels/channelBindingStore.server.test.ts @@ -0,0 +1,528 @@ +import { describe, expect, it } from 'vitest'; + +import { createServerBackedChannelBindingStore } from './channelBindingStore.server'; +import type { ChannelBridgeKvClient } from './channelBridgeServerKv'; + +function createStandardInMemoryKvState(): Readonly<{ + kv: ChannelBridgeKvClient; + mutateCallCount: () => number; +}> { + const byKey = new Map(); + let mutateCalls = 0; + + const kv: ChannelBridgeKvClient = { + get: async (key) => { + const row = byKey.get(key); + if (!row || row.value === null) { + return { status: 404, body: { error: 'Key not found' } }; + } + return { + status: 200, + body: { + key, + value: row.value, + version: row.version, + }, + }; + }, + mutate: async (mutations) => { + mutateCalls += 1; + const errors: Array<{ key: string; error: 'version-mismatch'; version: number; value: string | null }> = []; + for (const mutation of mutations) { + const row = byKey.get(mutation.key); + const currentVersion = row?.version ?? -1; + if (currentVersion !== mutation.version) { + errors.push({ + key: mutation.key, + error: 'version-mismatch', + version: currentVersion, + value: row?.value ?? null, + }); + } + } + + if (errors.length > 0) { + return { status: 409, body: { success: false, errors } }; + } + + const results: Array<{ key: string; version: number }> = []; + for (const mutation of mutations) { + const row = byKey.get(mutation.key); + const nextVersion = (row?.version ?? -1) + 1; + byKey.set(mutation.key, { + value: mutation.value, + version: nextVersion, + }); + results.push({ key: mutation.key, version: nextVersion }); + } + + return { status: 200, body: { success: true, results } }; + }, + }; + + return { + kv, + mutateCallCount: () => mutateCalls, + }; +} + +function createInMemoryKvClient(): ChannelBridgeKvClient { + return createStandardInMemoryKvState().kv; +} + +function createCountingKvClient(): Readonly<{ + kv: ChannelBridgeKvClient; + mutateCallCount: () => number; +}> { + return createStandardInMemoryKvState(); +} + +function createConflictPayloadKvClient(): Readonly<{ + kv: ChannelBridgeKvClient; + mutateCallCount: () => number; + getCallCount: () => number; +}> { + let getCalls = 0; + let mutateCalls = 0; + let currentVersion = -1; + let storedValue: string | null = null; + + const kv: ChannelBridgeKvClient = { + get: async (key) => { + getCalls += 1; + if (storedValue === null) { + return { status: 404, body: { error: 'Key not found' } }; + } + return { + status: 200, + body: { + key, + value: storedValue, + version: currentVersion, + }, + }; + }, + mutate: async (mutations) => { + mutateCalls += 1; + const [mutation] = mutations; + if (!mutation) { + return { status: 400, body: { success: false, errors: [{ key: 'missing', error: 'version-mismatch', version: currentVersion, value: storedValue }] } }; + } + + if (mutateCalls === 1) { + return { + status: 409, + body: { + success: false, + errors: [ + { + key: mutation.key, + error: 'version-mismatch', + version: currentVersion, + value: 'this-is-not-valid-base64', + }, + ], + }, + }; + } + + currentVersion += 1; + storedValue = mutation.value; + return { + status: 200, + body: { + success: true, + results: [ + { + key: mutation.key, + version: currentVersion, + }, + ], + }, + }; + }, + }; + + return { + kv, + getCallCount: () => getCalls, + mutateCallCount: () => mutateCalls, + }; +} + +function createValidConflictPayloadKvClient(): Readonly<{ + kv: ChannelBridgeKvClient; + mutateCallCount: () => number; + getCallCount: () => number; +}> { + let getCalls = 0; + let mutateCalls = 0; + let currentVersion = 0; + let storedValue = Buffer.from(JSON.stringify({ + schemaVersion: 1, + bindings: [], + }), 'utf8').toString('base64'); + + const conflictValue = Buffer.from(JSON.stringify({ + schemaVersion: 1, + bindings: [], + }), 'utf8').toString('base64'); + + const kv: ChannelBridgeKvClient = { + get: async (key) => { + getCalls += 1; + return { + status: 200, + body: { + key, + value: storedValue, + version: currentVersion, + }, + }; + }, + mutate: async (mutations) => { + mutateCalls += 1; + const [mutation] = mutations; + if (!mutation) { + return { + status: 400, + body: { + success: false, + errors: [{ key: 'missing', error: 'version-mismatch', version: currentVersion, value: storedValue }], + }, + }; + } + + if (mutateCalls === 1) { + currentVersion = 1; + storedValue = conflictValue; + return { + status: 409, + body: { + success: false, + errors: [{ key: mutation.key, error: 'version-mismatch', version: currentVersion, value: storedValue }], + }, + }; + } + + currentVersion += 1; + storedValue = mutation.value ?? storedValue; + return { + status: 200, + body: { + success: true, + results: [{ key: mutation.key, version: currentVersion }], + }, + }; + }, + }; + + return { + kv, + getCallCount: () => getCalls, + mutateCallCount: () => mutateCalls, + }; +} + +function createMalformedPrimaryReadKvClient(): ChannelBridgeKvClient { + const keySuffix = ':bindings'; + const malformedValue = Buffer.from(JSON.stringify({ + schemaVersion: 1, + bindings: [ + { + providerId: 'telegram', + conversationId: '-100bad', + sessionId: 'sess-bad', + lastForwardedSeq: 'oops', + }, + ], + }), 'utf8').toString('base64'); + + const byKey = new Map(); + + const kv: ChannelBridgeKvClient = { + get: async (key) => { + if (!byKey.has(key)) { + byKey.set(key, { value: malformedValue, version: 6 }); + } + const row = byKey.get(key); + if (!row || row.value === null) { + return { status: 404, body: { error: 'Key not found' } }; + } + + return { + status: 200, + body: { + key, + value: row.value, + version: row.version, + }, + }; + }, + mutate: async (mutations) => { + const errors: Array<{ key: string; error: 'version-mismatch'; version: number; value: string | null }> = []; + for (const mutation of mutations) { + const row = byKey.get(mutation.key); + const currentVersion = row?.version ?? -1; + if (currentVersion !== mutation.version) { + errors.push({ + key: mutation.key, + error: 'version-mismatch', + version: currentVersion, + value: row?.value ?? null, + }); + } + } + + if (errors.length > 0) { + return { status: 409, body: { success: false, errors } }; + } + + const results: Array<{ key: string; version: number }> = []; + for (const mutation of mutations) { + if (!mutation.key.endsWith(keySuffix)) continue; + const row = byKey.get(mutation.key); + const nextVersion = (row?.version ?? -1) + 1; + byKey.set(mutation.key, { + value: mutation.value, + version: nextVersion, + }); + results.push({ key: mutation.key, version: nextVersion }); + } + + return { status: 200, body: { success: true, results } }; + }, + }; + + return kv; +} + +describe('createServerBackedChannelBindingStore', () => { + it('persists bindings to server KV and reloads them', async () => { + const kv = createInMemoryKvClient(); + const storeA = createServerBackedChannelBindingStore({ + kv, + serverId: 'local-3005', + }); + + const storeB = createServerBackedChannelBindingStore({ + kv, + serverId: 'local-3005', + }); + + await storeA.upsertBinding({ + providerId: 'telegram', + conversationId: '-100111', + threadId: '22', + sessionId: 'sess-1', + lastForwardedSeq: 7, + }); + + const reloaded = await storeB.getBinding({ + providerId: 'telegram', + conversationId: '-100111', + threadId: '22', + }); + + expect(reloaded?.sessionId).toBe('sess-1'); + expect(reloaded?.lastForwardedSeq).toBe(7); + }); + + it('updates forwarded seq and removes bindings', async () => { + const kv = createInMemoryKvClient(); + const store = createServerBackedChannelBindingStore({ + kv, + serverId: 'local-3005', + }); + + await store.upsertBinding({ + providerId: 'telegram', + conversationId: '-100111', + threadId: null, + sessionId: 'sess-1', + lastForwardedSeq: 3, + }); + + await store.updateLastForwardedSeq({ + providerId: 'telegram', + conversationId: '-100111', + threadId: null, + }, 10); + + const updated = await store.getBinding({ + providerId: 'telegram', + conversationId: '-100111', + threadId: null, + }); + expect(updated?.lastForwardedSeq).toBe(10); + + const removed = await store.removeBinding({ + providerId: 'telegram', + conversationId: '-100111', + threadId: null, + }); + expect(removed).toBe(true); + + const after = await store.getBinding({ + providerId: 'telegram', + conversationId: '-100111', + threadId: null, + }); + expect(after).toBeNull(); + }); + + it('does not write to KV for no-op seq updates or missing removes', async () => { + const counting = createCountingKvClient(); + const store = createServerBackedChannelBindingStore({ + kv: counting.kv, + serverId: 'local-3005', + }); + + await store.upsertBinding({ + providerId: 'telegram', + conversationId: '-100222', + threadId: null, + sessionId: 'sess-2', + lastForwardedSeq: 12, + }); + const initialMutations = counting.mutateCallCount(); + + await store.updateLastForwardedSeq({ + providerId: 'telegram', + conversationId: '-100222', + threadId: null, + }, 10); + await store.updateLastForwardedSeq({ + providerId: 'telegram', + conversationId: '-100-missing', + threadId: null, + }, 1); + const missingRemoved = await store.removeBinding({ + providerId: 'telegram', + conversationId: '-100-missing', + threadId: null, + }); + + expect(missingRemoved).toBe(false); + expect(counting.mutateCallCount()).toBe(initialMutations); + }); + + it('normalizes non-finite seq values and skips invalid seq writes', async () => { + const counting = createCountingKvClient(); + const store = createServerBackedChannelBindingStore({ + kv: counting.kv, + serverId: 'local-3005', + }); + + await store.upsertBinding({ + providerId: 'telegram', + conversationId: '-100nf', + threadId: null, + sessionId: 'sess-nf', + lastForwardedSeq: Number.NaN, + }); + + const saved = await store.getBinding({ + providerId: 'telegram', + conversationId: '-100nf', + threadId: null, + }); + expect(saved?.lastForwardedSeq).toBe(0); + + const initialMutations = counting.mutateCallCount(); + + await store.updateLastForwardedSeq({ + providerId: 'telegram', + conversationId: '-100nf', + threadId: null, + }, Number.POSITIVE_INFINITY); + + await store.updateLastForwardedSeq({ + providerId: 'telegram', + conversationId: '-100nf', + threadId: null, + }, Number.NaN); + + expect(counting.mutateCallCount()).toBe(initialMutations); + + const afterInvalidUpdates = await store.getBinding({ + providerId: 'telegram', + conversationId: '-100nf', + threadId: null, + }); + expect(afterInvalidUpdates?.lastForwardedSeq).toBe(0); + }); + + it('fails fast when conflict payload cannot be decoded', async () => { + const conflict = createConflictPayloadKvClient(); + const store = createServerBackedChannelBindingStore({ + kv: conflict.kv, + serverId: 'local-3005', + maxWriteRetries: 3, + }); + + await expect(store.upsertBinding({ + providerId: 'telegram', + conversationId: '-100333', + threadId: null, + sessionId: 'sess-3', + lastForwardedSeq: 4, + })).rejects.toThrow('Conflict payload decode failed'); + + const binding = await store.getBinding({ + providerId: 'telegram', + conversationId: '-100333', + threadId: null, + }); + + expect(binding).toBeNull(); + expect(conflict.mutateCallCount()).toBe(1); + }); + + it('surfaces malformed primary KV payload conflicts instead of clobbering remote state', async () => { + const kv = createMalformedPrimaryReadKvClient(); + const store = createServerBackedChannelBindingStore({ + kv, + serverId: 'local-3005', + maxWriteRetries: 3, + }); + + await expect(store.listBindings()).resolves.toEqual([]); + + await expect(store.upsertBinding({ + providerId: 'telegram', + conversationId: '-100444', + threadId: null, + sessionId: 'sess-4', + lastForwardedSeq: 1, + })).rejects.toThrow('Conflict payload decode failed'); + + const binding = await store.getBinding({ + providerId: 'telegram', + conversationId: '-100444', + threadId: null, + }); + + expect(binding).toBeNull(); + }); + + it('uses conflict payload state for retry without extra primary read', async () => { + const conflict = createValidConflictPayloadKvClient(); + const store = createServerBackedChannelBindingStore({ + kv: conflict.kv, + serverId: 'local-3005', + maxWriteRetries: 3, + }); + + await store.upsertBinding({ + providerId: 'telegram', + conversationId: '-100555', + threadId: null, + sessionId: 'sess-5', + lastForwardedSeq: 2, + }); + + expect(conflict.mutateCallCount()).toBe(2); + expect(conflict.getCallCount()).toBe(1); + }); +}); diff --git a/apps/cli/src/channels/channelBindingStore.server.ts b/apps/cli/src/channels/channelBindingStore.server.ts new file mode 100644 index 0000000000..2fbb9b73fb --- /dev/null +++ b/apps/cli/src/channels/channelBindingStore.server.ts @@ -0,0 +1,264 @@ +import type { ChannelBindingStore, ChannelBridgeConversationRef, ChannelSessionBinding } from './core/channelBridgeWorker'; +import { + ChannelBridgeBadPayloadError, + ChannelBridgeKvVersionMismatchError, + decodeChannelBridgeBindingsDocFromBase64, + readChannelBridgeBindingsFromKv, + type ChannelBridgeKvClient, + type ChannelBridgeServerBindingsDocument, + writeChannelBridgeBindingsToKv, +} from './channelBridgeServerKv'; +import { logger } from '@/ui/logger'; + +type BindingCache = Readonly<{ + version: number; + bindings: ChannelSessionBinding[]; + fetchedAtMs: number; +}>; + +function bindingKey(ref: ChannelBridgeConversationRef): string { + return JSON.stringify([ref.providerId, ref.conversationId, ref.threadId]); +} + +function cloneBinding(binding: ChannelSessionBinding): ChannelSessionBinding { + return { + providerId: binding.providerId, + conversationId: binding.conversationId, + threadId: binding.threadId, + sessionId: binding.sessionId, + lastForwardedSeq: binding.lastForwardedSeq, + createdAtMs: binding.createdAtMs, + updatedAtMs: binding.updatedAtMs, + }; +} + +function cloneBindings(bindings: readonly ChannelSessionBinding[]): ChannelSessionBinding[] { + return bindings.map((binding) => cloneBinding(binding)); +} + +function toNonNegativeInt(value: unknown): number | null { + if (typeof value !== 'number' || !Number.isFinite(value)) return null; + const truncated = Math.trunc(value); + if (truncated < 0) return null; + return truncated; +} + +function toServerDocument(bindings: readonly ChannelSessionBinding[]): ChannelBridgeServerBindingsDocument { + return { + schemaVersion: 1, + bindings: bindings.map((binding) => ({ + providerId: binding.providerId, + conversationId: binding.conversationId, + threadId: binding.threadId, + sessionId: binding.sessionId, + lastForwardedSeq: binding.lastForwardedSeq, + createdAtMs: binding.createdAtMs, + updatedAtMs: binding.updatedAtMs, + })), + }; +} + +function fromServerDocument(doc: ChannelBridgeServerBindingsDocument): ChannelSessionBinding[] { + return doc.bindings.map((binding) => ({ + providerId: binding.providerId, + conversationId: binding.conversationId, + threadId: binding.threadId, + sessionId: binding.sessionId, + lastForwardedSeq: toNonNegativeInt(binding.lastForwardedSeq) ?? 0, + createdAtMs: Math.trunc(binding.createdAtMs), + updatedAtMs: Math.trunc(binding.updatedAtMs), + })); +} + +function isRecoverableBindingsReadError(error: unknown): boolean { + if (error instanceof SyntaxError) return true; + return error instanceof ChannelBridgeBadPayloadError; +} + +export function createServerBackedChannelBindingStore(params: Readonly<{ + kv: ChannelBridgeKvClient; + serverId: string; + cacheTtlMs?: number; + maxWriteRetries?: number; +}>): ChannelBindingStore { + const cacheTtlMs = typeof params.cacheTtlMs === 'number' ? Math.max(0, Math.trunc(params.cacheTtlMs)) : 1_000; + const maxWriteRetries = typeof params.maxWriteRetries === 'number' ? Math.max(1, Math.trunc(params.maxWriteRetries)) : 4; + + let cache: BindingCache | null = null; + + async function load(forceRefresh: boolean): Promise { + if (!forceRefresh && cache && Date.now() - cache.fetchedAtMs <= cacheTtlMs) { + return cache; + } + + try { + const fetched = await readChannelBridgeBindingsFromKv({ + kv: params.kv, + serverId: params.serverId, + }); + const next: BindingCache = { + version: fetched.version, + bindings: fromServerDocument(fetched.doc), + fetchedAtMs: Date.now(), + }; + cache = next; + return next; + } catch (error) { + if (!isRecoverableBindingsReadError(error)) { + throw error; + } + + logger.warn('[channelBindingStore] Failed to decode primary KV payload; using empty bindings fallback', error); + + const next: BindingCache = { + version: cache?.version ?? -1, + bindings: [], + fetchedAtMs: Date.now(), + }; + cache = next; + return next; + } + } + + function setCache(version: number, bindings: readonly ChannelSessionBinding[]): void { + cache = { + version, + bindings: cloneBindings(bindings), + fetchedAtMs: Date.now(), + }; + } + + async function withOptimisticWrite(operation: (currentBindings: ChannelSessionBinding[]) => Readonly<{ + nextBindings: ChannelSessionBinding[]; + result: T; + changed?: boolean; + }>): Promise { + let retryCurrent: BindingCache | null = null; + + for (let attempt = 0; attempt < maxWriteRetries; attempt += 1) { + const current = retryCurrent ?? await load(true); + retryCurrent = null; + const op = operation(cloneBindings(current.bindings)); + const changed = op.changed ?? true; + if (!changed) { + setCache(current.version, current.bindings); + return op.result; + } + + try { + const version = await writeChannelBridgeBindingsToKv({ + kv: params.kv, + serverId: params.serverId, + expectedVersion: current.version, + doc: toServerDocument(op.nextBindings), + }); + + setCache(version, op.nextBindings); + return op.result; + } catch (error) { + if (!(error instanceof ChannelBridgeKvVersionMismatchError)) { + throw error; + } + + let doc: ChannelBridgeServerBindingsDocument; + try { + doc = decodeChannelBridgeBindingsDocFromBase64(error.currentValueBase64); + } catch (decodeError) { + logger.warn('[channelBindingStore] Failed to decode conflict payload; aborting optimistic retry to avoid clobbering remote bindings', decodeError); + throw new Error( + '[channelBindingStore] Conflict payload decode failed; aborting optimistic retry to avoid clobbering remote bindings', + ); + } + const conflictBindings = fromServerDocument(doc); + setCache(error.currentVersion, conflictBindings); + retryCurrent = { + version: error.currentVersion, + bindings: cloneBindings(conflictBindings), + fetchedAtMs: Date.now(), + }; + } + } + + throw new Error('Failed to persist channel bridge bindings after retries'); + } + + return { + listBindings: async () => { + const current = await load(false); + return cloneBindings(current.bindings); + }, + getBinding: async (ref) => { + const current = await load(false); + const key = bindingKey(ref); + const found = current.bindings.find((binding) => bindingKey(binding) === key); + return found ? cloneBinding(found) : null; + }, + upsertBinding: async (binding) => { + return await withOptimisticWrite((currentBindings) => { + const nowMs = Date.now(); + const key = bindingKey(binding); + const existing = currentBindings.find((row) => bindingKey(row) === key) ?? null; + + const normalizedLastForwardedSeq = toNonNegativeInt(binding.lastForwardedSeq) ?? 0; + const nextBinding: ChannelSessionBinding = { + providerId: binding.providerId, + conversationId: binding.conversationId, + threadId: binding.threadId, + sessionId: binding.sessionId, + lastForwardedSeq: normalizedLastForwardedSeq, + createdAtMs: existing?.createdAtMs ?? nowMs, + updatedAtMs: nowMs, + }; + + const nextBindings = currentBindings + .filter((row) => bindingKey(row) !== key) + .concat([nextBinding]); + + return { + nextBindings, + result: cloneBinding(nextBinding), + }; + }); + }, + updateLastForwardedSeq: async (ref, seq) => { + await withOptimisticWrite((currentBindings) => { + const key = bindingKey(ref); + const parsedSeq = toNonNegativeInt(seq); + if (parsedSeq === null) { + return { + nextBindings: currentBindings, + result: undefined, + changed: false, + }; + } + let changed = false; + const nextBindings = currentBindings.map((binding) => { + if (bindingKey(binding) !== key) return binding; + if (parsedSeq <= binding.lastForwardedSeq) return binding; + changed = true; + return { + ...binding, + lastForwardedSeq: parsedSeq, + updatedAtMs: Date.now(), + }; + }); + return { + nextBindings, + result: undefined, + changed, + }; + }); + }, + removeBinding: async (ref) => { + return await withOptimisticWrite((currentBindings) => { + const key = bindingKey(ref); + const nextBindings = currentBindings.filter((binding) => bindingKey(binding) !== key); + return { + nextBindings, + result: nextBindings.length !== currentBindings.length, + changed: nextBindings.length !== currentBindings.length, + }; + }); + }, + }; +} diff --git a/apps/cli/src/channels/channelBridgeAccountConfig.test.ts b/apps/cli/src/channels/channelBridgeAccountConfig.test.ts new file mode 100644 index 0000000000..9f68328e0d --- /dev/null +++ b/apps/cli/src/channels/channelBridgeAccountConfig.test.ts @@ -0,0 +1,212 @@ +import { describe, expect, it } from 'vitest'; + +import { + hasSharedTelegramBridgeUpdate, + readScopedTelegramBridgeConfig, + removeScopedTelegramBridgeConfig, + splitScopedTelegramBridgeUpdate, + upsertScopedTelegramBridgeConfig, +} from './channelBridgeAccountConfig'; + +describe('channelBridgeAccountConfig', () => { + it('writes scoped telegram config under server/account with secrets in local-only block', () => { + const next = upsertScopedTelegramBridgeConfig({ + settings: {}, + serverId: 'local-3005', + accountId: 'acct-1', + update: { + tickMs: 2_200, + botToken: 'bot-token', + allowedChatIds: ['-100111'], + requireTopics: true, + webhookEnabled: true, + webhookSecret: 'secret-1', + webhookHost: '127.0.0.1', + webhookPort: 9_000, + }, + }); + + const telegram = readScopedTelegramBridgeConfig({ + settings: next, + serverId: 'local-3005', + accountId: 'acct-1', + }); + + expect(telegram).toEqual({ + tickMs: 2_200, + botToken: 'bot-token', + allowedChatIds: ['-100111'], + requireTopics: true, + webhook: { + enabled: true, + secret: 'secret-1', + host: '127.0.0.1', + port: 9_000, + }, + }); + + expect((next as any).channelBridge.byServerId['local-3005'].byAccountId['acct-1'].providers.telegram.secrets).toEqual({ + botToken: 'bot-token', + webhookSecret: 'secret-1', + }); + expect((next as any).channelBridge.byServerId['local-3005'].byAccountId['acct-1'].providers.telegram.botToken).toBeUndefined(); + expect((next as any).channelBridge.byServerId['local-3005'].byAccountId['acct-1'].providers.telegram.webhook.secret).toBeUndefined(); + }); + + it('splits local secret updates from shared update payload', () => { + const split = splitScopedTelegramBridgeUpdate({ + update: { + botToken: 'bot-token', + webhookSecret: 'secret-1', + allowedChatIds: ['-100111'], + requireTopics: true, + }, + }); + + expect(split.localUpdate).toEqual({ + botToken: 'bot-token', + webhookSecret: 'secret-1', + allowedChatIds: ['-100111'], + requireTopics: true, + }); + expect(split.sharedUpdate).toEqual({ + allowedChatIds: ['-100111'], + requireTopics: true, + }); + expect(hasSharedTelegramBridgeUpdate({ update: split.sharedUpdate })).toBe(true); + }); + + it('does not alias local and shared allowedChatIds arrays', () => { + const split = splitScopedTelegramBridgeUpdate({ + update: { + allowedChatIds: ['-100111'], + }, + }); + + split.localUpdate.allowedChatIds?.push('-100222'); + + expect(split.localUpdate.allowedChatIds).toEqual(['-100111', '-100222']); + expect(split.sharedUpdate.allowedChatIds).toEqual(['-100111']); + }); + + it('normalizes allowedChatIds when writing scoped config', () => { + const next = upsertScopedTelegramBridgeConfig({ + settings: {}, + serverId: 'local-3005', + accountId: 'acct-1', + update: { + allowedChatIds: [' -100111 ', '', ' ', '-100222'], + }, + }); + + const telegram = readScopedTelegramBridgeConfig({ + settings: next, + serverId: 'local-3005', + accountId: 'acct-1', + }); + + expect(telegram?.allowedChatIds).toEqual(['-100111', '-100222']); + }); + + it('reports no shared update when only secrets are provided', () => { + const split = splitScopedTelegramBridgeUpdate({ + update: { + botToken: 'bot-token', + webhookSecret: 'secret-1', + }, + }); + + expect(split.sharedUpdate).toEqual({}); + expect(hasSharedTelegramBridgeUpdate({ update: split.sharedUpdate })).toBe(false); + }); + + it('ignores undefined-valued shared fields when checking for shared updates', () => { + const update = { + webhookPort: undefined, + } as unknown as Parameters[0]['update']; + + expect(hasSharedTelegramBridgeUpdate({ update })).toBe(false); + expect(hasSharedTelegramBridgeUpdate({ update: { allowedChatIds: [] } })).toBe(true); + }); + + it('preserves webhook secret when only webhook host/port settings are updated', () => { + const configured = upsertScopedTelegramBridgeConfig({ + settings: {}, + serverId: 'local-3005', + accountId: 'acct-1', + update: { + webhookSecret: 'secret-1', + }, + }); + + const updated = upsertScopedTelegramBridgeConfig({ + settings: configured, + serverId: 'local-3005', + accountId: 'acct-1', + update: { + webhookHost: '127.0.0.1', + webhookPort: 8080, + }, + }); + + const telegram = readScopedTelegramBridgeConfig({ + settings: updated, + serverId: 'local-3005', + accountId: 'acct-1', + }); + const webhook = telegram?.webhook as { secret?: string; host?: string; port?: number } | undefined; + + expect(webhook?.secret).toBe('secret-1'); + expect(webhook?.host).toBe('127.0.0.1'); + expect(webhook?.port).toBe(8080); + }); + + it('removes scoped telegram config and prunes empty nesting', () => { + const configured = upsertScopedTelegramBridgeConfig({ + settings: {}, + serverId: 'local-3005', + accountId: 'acct-1', + update: { + tickMs: 2_200, + botToken: 'bot-token', + }, + }); + + const cleared = removeScopedTelegramBridgeConfig({ + settings: configured, + serverId: 'local-3005', + accountId: 'acct-1', + }); + + const telegram = readScopedTelegramBridgeConfig({ + settings: cleared, + serverId: 'local-3005', + accountId: 'acct-1', + }); + + expect(telegram).toBeNull(); + expect((cleared as any).channelBridge).toBeUndefined(); + }); + + it('removes stale scoped tickMs even when providers.telegram is already missing', () => { + const cleared = removeScopedTelegramBridgeConfig({ + settings: { + channelBridge: { + byServerId: { + 'local-3005': { + byAccountId: { + 'acct-1': { + tickMs: 2_500, + }, + }, + }, + }, + }, + }, + serverId: 'local-3005', + accountId: 'acct-1', + }); + + expect((cleared as any).channelBridge).toBeUndefined(); + }); +}); diff --git a/apps/cli/src/channels/channelBridgeAccountConfig.ts b/apps/cli/src/channels/channelBridgeAccountConfig.ts new file mode 100644 index 0000000000..d140c9aba1 --- /dev/null +++ b/apps/cli/src/channels/channelBridgeAccountConfig.ts @@ -0,0 +1,344 @@ +/** + * Scoped channel bridge account configuration helpers. + * + * Responsibilities: + * - split bridge updates into local-secret and shared KV-safe payloads + * - read normalized account-scoped bridge config from settings + * - upsert/remove account-scoped bridge config trees under server/account scope + * + * Secrets remain local-only and are never emitted into shared payload helpers. + */ +type RecordLike = Record; + +function asRecord(value: unknown): RecordLike | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + return value as RecordLike; +} + +function deepCloneRoot(value: unknown): RecordLike { + if (!value || typeof value !== 'object' || Array.isArray(value)) { + return {}; + } + return JSON.parse(JSON.stringify(value)) as RecordLike; +} + +function ensureRecord(parent: RecordLike, key: string): RecordLike { + const existing = asRecord(parent[key]); + if (existing) return existing; + const created: RecordLike = {}; + parent[key] = created; + return created; +} + +function isEmptyRecord(value: unknown): boolean { + const record = asRecord(value); + if (!record) return false; + return Object.keys(record).length === 0; +} + +function parseStringArray(value: unknown): string[] { + if (!Array.isArray(value)) return []; + return value + .map((entry) => (typeof entry === 'string' ? entry.trim() : '')) + .filter((entry) => entry.length > 0); +} + +export type ScopedTelegramBridgeUpdate = Readonly<{ + tickMs?: number; + botToken?: string; + allowedChatIds?: string[]; + requireTopics?: boolean; + webhookEnabled?: boolean; + webhookSecret?: string; + webhookHost?: string; + webhookPort?: number; +}>; + +export type SharedTelegramBridgeUpdate = Readonly<{ + tickMs?: number; + allowedChatIds?: string[]; + requireTopics?: boolean; + webhookEnabled?: boolean; + webhookHost?: string; + webhookPort?: number; +}>; + +function hasAnyUpdateValue(update: Record): boolean { + return Object.values(update).some((value) => value !== undefined); +} + +export function splitScopedTelegramBridgeUpdate(params: Readonly<{ + update: ScopedTelegramBridgeUpdate; +}>): Readonly<{ localUpdate: ScopedTelegramBridgeUpdate; sharedUpdate: SharedTelegramBridgeUpdate }> { + const source = params.update; + const localUpdate: { + tickMs?: number; + botToken?: string; + allowedChatIds?: string[]; + requireTopics?: boolean; + webhookEnabled?: boolean; + webhookSecret?: string; + webhookHost?: string; + webhookPort?: number; + } = {}; + const sharedUpdate: { + tickMs?: number; + allowedChatIds?: string[]; + requireTopics?: boolean; + webhookEnabled?: boolean; + webhookHost?: string; + webhookPort?: number; + } = {}; + + if (typeof source.tickMs === 'number' && Number.isFinite(source.tickMs)) { + const value = Math.trunc(source.tickMs); + localUpdate.tickMs = value; + sharedUpdate.tickMs = value; + } + if (typeof source.botToken === 'string') { + localUpdate.botToken = source.botToken; + } + if (Array.isArray(source.allowedChatIds)) { + localUpdate.allowedChatIds = [...source.allowedChatIds]; + sharedUpdate.allowedChatIds = [...source.allowedChatIds]; + } + if (typeof source.requireTopics === 'boolean') { + localUpdate.requireTopics = source.requireTopics; + sharedUpdate.requireTopics = source.requireTopics; + } + if (typeof source.webhookEnabled === 'boolean') { + localUpdate.webhookEnabled = source.webhookEnabled; + sharedUpdate.webhookEnabled = source.webhookEnabled; + } + if (typeof source.webhookSecret === 'string') { + localUpdate.webhookSecret = source.webhookSecret; + } + if (typeof source.webhookHost === 'string') { + localUpdate.webhookHost = source.webhookHost; + sharedUpdate.webhookHost = source.webhookHost; + } + if (typeof source.webhookPort === 'number' && Number.isFinite(source.webhookPort)) { + const value = Math.trunc(source.webhookPort); + localUpdate.webhookPort = value; + sharedUpdate.webhookPort = value; + } + + return { + localUpdate, + sharedUpdate, + }; +} + +export function hasSharedTelegramBridgeUpdate(params: Readonly<{ update: SharedTelegramBridgeUpdate }>): boolean { + return hasAnyUpdateValue(params.update as Record); +} + +export function readScopedTelegramBridgeConfig(params: Readonly<{ + settings: unknown; + serverId: string; + accountId: string; +}>): RecordLike | null { + const root = asRecord(params.settings); + const channelBridge = asRecord(root?.channelBridge); + const byServerId = asRecord(channelBridge?.byServerId); + const serverScope = asRecord(byServerId?.[params.serverId]); + const byAccountId = asRecord(serverScope?.byAccountId); + const accountScope = asRecord(byAccountId?.[params.accountId]); + const providers = asRecord(accountScope?.providers); + const telegram = asRecord(providers?.telegram); + if (!telegram) return null; + + const secrets = asRecord(telegram.secrets); + const botToken = + typeof secrets?.botToken === 'string' + ? secrets.botToken + : typeof telegram.botToken === 'string' + ? telegram.botToken + : undefined; + + const webhook = asRecord(telegram.webhook); + const webhookSecret = + typeof secrets?.webhookSecret === 'string' + ? secrets.webhookSecret + : typeof webhook?.secret === 'string' + ? webhook.secret + : undefined; + + const normalized: RecordLike = {}; + + if (typeof accountScope?.tickMs === 'number' && Number.isFinite(accountScope.tickMs)) { + normalized.tickMs = Math.trunc(accountScope.tickMs); + } + + if (typeof botToken === 'string') { + normalized.botToken = botToken; + } + + const allowedChatIds = parseStringArray(telegram.allowedChatIds); + if (allowedChatIds.length > 0 || Array.isArray(telegram.allowedChatIds)) { + normalized.allowedChatIds = allowedChatIds; + } + + if (typeof telegram.requireTopics === 'boolean') { + normalized.requireTopics = telegram.requireTopics; + } + + const normalizedWebhook: RecordLike = {}; + if (typeof webhook?.enabled === 'boolean') { + normalizedWebhook.enabled = webhook.enabled; + } + if (typeof webhookSecret === 'string') { + normalizedWebhook.secret = webhookSecret; + } + if (typeof webhook?.host === 'string') { + normalizedWebhook.host = webhook.host; + } + if (typeof webhook?.port === 'number' && Number.isFinite(webhook.port)) { + normalizedWebhook.port = Math.trunc(webhook.port); + } + + if (Object.keys(normalizedWebhook).length > 0) { + normalized.webhook = normalizedWebhook; + } + + return normalized; +} + +export function upsertScopedTelegramBridgeConfig(params: Readonly<{ + settings: T; + serverId: string; + accountId: string; + update: ScopedTelegramBridgeUpdate; +}>): T; + +export function upsertScopedTelegramBridgeConfig(params: Readonly<{ + settings: unknown; + serverId: string; + accountId: string; + update: ScopedTelegramBridgeUpdate; +}>): RecordLike; + +export function upsertScopedTelegramBridgeConfig(params: Readonly<{ + settings: unknown; + serverId: string; + accountId: string; + update: ScopedTelegramBridgeUpdate; +}>): RecordLike { + const root = deepCloneRoot(params.settings); + + const channelBridge = ensureRecord(root, 'channelBridge'); + const byServerId = ensureRecord(channelBridge, 'byServerId'); + const serverScope = ensureRecord(byServerId, params.serverId); + const byAccountId = ensureRecord(serverScope, 'byAccountId'); + const accountScope = ensureRecord(byAccountId, params.accountId); + + if (typeof params.update.tickMs === 'number' && Number.isFinite(params.update.tickMs)) { + accountScope.tickMs = Math.trunc(params.update.tickMs); + } + + const providers = ensureRecord(accountScope, 'providers'); + const telegram = ensureRecord(providers, 'telegram'); + + if (Array.isArray(params.update.allowedChatIds)) { + telegram.allowedChatIds = parseStringArray(params.update.allowedChatIds); + } + if (typeof params.update.requireTopics === 'boolean') { + telegram.requireTopics = params.update.requireTopics; + } + + const hasWebhookUpdate = + typeof params.update.webhookEnabled === 'boolean' + || typeof params.update.webhookHost === 'string' + || (typeof params.update.webhookPort === 'number' && Number.isFinite(params.update.webhookPort)); + + if (hasWebhookUpdate) { + const webhook = ensureRecord(telegram, 'webhook'); + if (typeof params.update.webhookEnabled === 'boolean') { + webhook.enabled = params.update.webhookEnabled; + } + if (typeof params.update.webhookHost === 'string') { + webhook.host = params.update.webhookHost; + } + if (typeof params.update.webhookPort === 'number' && Number.isFinite(params.update.webhookPort)) { + webhook.port = Math.trunc(params.update.webhookPort); + } + } + + const hasSecretUpdate = + typeof params.update.botToken === 'string' + || typeof params.update.webhookSecret === 'string'; + + if (hasSecretUpdate) { + const secrets = ensureRecord(telegram, 'secrets'); + if (typeof params.update.botToken === 'string') { + secrets.botToken = params.update.botToken; + if ('botToken' in telegram) { + delete telegram.botToken; + } + } + if (typeof params.update.webhookSecret === 'string') { + secrets.webhookSecret = params.update.webhookSecret; + const webhook = asRecord(telegram.webhook); + if (webhook && 'secret' in webhook) { + delete webhook.secret; + } + } + } + + return root; +} + +export function removeScopedTelegramBridgeConfig(params: Readonly<{ + settings: T; + serverId: string; + accountId: string; +}>): T; + +export function removeScopedTelegramBridgeConfig(params: Readonly<{ + settings: unknown; + serverId: string; + accountId: string; +}>): RecordLike; + +export function removeScopedTelegramBridgeConfig(params: Readonly<{ + settings: unknown; + serverId: string; + accountId: string; +}>): RecordLike { + const root = deepCloneRoot(params.settings); + + const channelBridge = asRecord(root.channelBridge); + const byServerId = asRecord(channelBridge?.byServerId); + const serverScope = asRecord(byServerId?.[params.serverId]); + const byAccountId = asRecord(serverScope?.byAccountId); + const accountScope = asRecord(byAccountId?.[params.accountId]); + const providers = asRecord(accountScope?.providers); + + if (accountScope && 'tickMs' in accountScope) { + delete accountScope.tickMs; + } + if (providers && 'telegram' in providers) { + delete providers.telegram; + } + + if (providers && isEmptyRecord(providers) && accountScope) { + delete accountScope.providers; + } + if (isEmptyRecord(accountScope) && byAccountId) { + delete byAccountId[params.accountId]; + } + if (isEmptyRecord(byAccountId) && serverScope) { + delete serverScope.byAccountId; + } + if (isEmptyRecord(serverScope) && byServerId) { + delete byServerId[params.serverId]; + } + if (isEmptyRecord(byServerId) && channelBridge) { + delete channelBridge.byServerId; + } + if (isEmptyRecord(channelBridge)) { + delete root.channelBridge; + } + + return root; +} diff --git a/apps/cli/src/channels/channelBridgeConfig.test.ts b/apps/cli/src/channels/channelBridgeConfig.test.ts new file mode 100644 index 0000000000..5e3e13902c --- /dev/null +++ b/apps/cli/src/channels/channelBridgeConfig.test.ts @@ -0,0 +1,231 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveChannelBridgeRuntimeConfig } from './channelBridgeConfig'; + +describe('resolveChannelBridgeRuntimeConfig', () => { + it('uses settings.json bridge values when env is not set', () => { + const config = resolveChannelBridgeRuntimeConfig({ + env: {}, + settings: { + channelBridge: { + tickMs: 3_100, + providers: { + telegram: { + botToken: 'settings-bot-token', + allowedChatIds: ['-100111', '-100222'], + requireTopics: true, + webhook: { + enabled: true, + secret: 'settings-secret', + host: '0.0.0.0', + port: 9_001, + }, + }, + }, + }, + }, + }); + + expect(config.tickMs).toBe(3_100); + expect(config.telegram.botToken).toBe('settings-bot-token'); + expect(config.telegram.allowedChatIds).toEqual(['-100111', '-100222']); + expect(config.telegram.requireTopics).toBe(true); + expect(config.telegram.webhookEnabled).toBe(true); + expect(config.telegram.webhookSecret).toBe('settings-secret'); + expect(config.telegram.webhookHost).toBe('0.0.0.0'); + expect(config.telegram.webhookPort).toBe(9_001); + }); + + it('reads secret fields from telegram.secrets local-only block', () => { + const config = resolveChannelBridgeRuntimeConfig({ + env: {}, + settings: { + channelBridge: { + providers: { + telegram: { + allowedChatIds: ['-100111'], + requireTopics: true, + webhook: { + enabled: true, + host: '127.0.0.1', + port: 8787, + }, + secrets: { + botToken: 'secret-bot-token', + webhookSecret: 'secret-webhook-token', + }, + }, + }, + }, + }, + }); + + expect(config.telegram.botToken).toBe('secret-bot-token'); + expect(config.telegram.webhookSecret).toBe('secret-webhook-token'); + expect(config.telegram.allowedChatIds).toEqual(['-100111']); + expect(config.telegram.requireTopics).toBe(true); + }); + + it('applies env overrides and falls back to settings for invalid env webhook port', () => { + const config = resolveChannelBridgeRuntimeConfig({ + env: { + HAPPIER_CHANNEL_BRIDGE_TICK_MS: '700', + HAPPIER_TELEGRAM_BOT_TOKEN: 'env-token', + HAPPIER_TELEGRAM_ALLOWED_CHAT_IDS: '-100333,-100444', + HAPPIER_TELEGRAM_REQUIRE_TOPICS: '0', + HAPPIER_TELEGRAM_WEBHOOK_ENABLED: '1', + HAPPIER_TELEGRAM_WEBHOOK_SECRET: 'env-secret', + HAPPIER_TELEGRAM_WEBHOOK_HOST: '127.0.0.9', + HAPPIER_TELEGRAM_WEBHOOK_PORT: '8_877', + }, + settings: { + channelBridge: { + tickMs: 5_000, + providers: { + telegram: { + botToken: 'settings-token', + allowedChatIds: ['-100111'], + requireTopics: true, + webhook: { + enabled: false, + secret: 'settings-secret', + host: '0.0.0.0', + port: 9_001, + }, + }, + }, + }, + }, + }); + + expect(config.tickMs).toBe(700); + expect(config.telegram.botToken).toBe('env-token'); + expect(config.telegram.allowedChatIds).toEqual(['-100333', '-100444']); + expect(config.telegram.requireTopics).toBe(false); + expect(config.telegram.webhookEnabled).toBe(true); + expect(config.telegram.webhookSecret).toBe('env-secret'); + expect(config.telegram.webhookHost).toBe('127.0.0.9'); + expect(config.telegram.webhookPort).toBe(9_001); + }); + + it('applies a valid env webhook port override', () => { + const config = resolveChannelBridgeRuntimeConfig({ + env: { + HAPPIER_TELEGRAM_WEBHOOK_PORT: '8877', + }, + settings: { + channelBridge: { + providers: { + telegram: { + webhook: { + port: 9_001, + }, + }, + }, + }, + }, + }); + + expect(config.telegram.webhookPort).toBe(8_877); + }); + + it('rejects webhook port zero and falls back to default', () => { + const config = resolveChannelBridgeRuntimeConfig({ + env: { + HAPPIER_TELEGRAM_WEBHOOK_PORT: '0', + }, + settings: { + channelBridge: { + providers: { + telegram: { + webhook: { + port: 0, + }, + }, + }, + }, + }, + }); + + expect(config.telegram.webhookPort).toBe(8_787); + }); + + it('resolves account-scoped bridge config with server/global fallback', () => { + const config = resolveChannelBridgeRuntimeConfig({ + env: {}, + serverId: 'local-test', + accountId: 'acct-123', + settings: { + channelBridge: { + tickMs: 2_500, + providers: { + telegram: { + botToken: 'global-token', + allowedChatIds: ['-100-global'], + requireTopics: false, + webhook: { + enabled: false, + secret: '', + host: '127.0.0.1', + port: 8_787, + }, + }, + }, + byServerId: { + 'local-test': { + providers: { + telegram: { + allowedChatIds: ['-100-server'], + requireTopics: true, + }, + }, + byAccountId: { + 'acct-123': { + tickMs: 1_800, + providers: { + telegram: { + botToken: 'account-token', + }, + }, + }, + }, + }, + }, + }, + }, + }); + + expect(config.tickMs).toBe(1_800); + expect(config.telegram.botToken).toBe('account-token'); + expect(config.telegram.allowedChatIds).toEqual(['-100-server']); + expect(config.telegram.requireTopics).toBe(true); + expect(config.telegram.webhookEnabled).toBe(false); + expect(config.telegram.webhookHost).toBe('127.0.0.1'); + expect(config.telegram.webhookPort).toBe(8_787); + }); + + it('falls back to global settings when scoped bridge config is missing', () => { + const config = resolveChannelBridgeRuntimeConfig({ + env: {}, + serverId: 'unknown-server', + accountId: 'acct-missing', + settings: { + channelBridge: { + tickMs: 4_200, + providers: { + telegram: { + botToken: 'global-token', + allowedChatIds: [], + requireTopics: true, + }, + }, + }, + }, + }); + + expect(config.tickMs).toBe(4_200); + expect(config.telegram.botToken).toBe('global-token'); + expect(config.telegram.allowedChatIds).toEqual([]); + expect(config.telegram.requireTopics).toBe(true); + }); +}); diff --git a/apps/cli/src/channels/channelBridgeConfig.ts b/apps/cli/src/channels/channelBridgeConfig.ts new file mode 100644 index 0000000000..671e52d235 --- /dev/null +++ b/apps/cli/src/channels/channelBridgeConfig.ts @@ -0,0 +1,203 @@ +type RecordLike = Record; + +function asRecord(value: unknown): RecordLike | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + return value as RecordLike; +} + +function parseBoolean(value: unknown): boolean | null { + if (typeof value === 'boolean') return value; + if (typeof value !== 'string') return null; + const normalized = value.trim().toLowerCase(); + if (normalized.length === 0) return null; + if (['1', 'true', 'yes', 'on'].includes(normalized)) return true; + if (['0', 'false', 'no', 'off'].includes(normalized)) return false; + return null; +} + +function parseStrictInteger(raw: string, min: number, max: number): number | null { + const trimmed = raw.trim(); + if (!/^[-]?\d+$/.test(trimmed)) return null; + const parsed = Number.parseInt(trimmed, 10); + if (!Number.isFinite(parsed) || parsed < min || parsed > max) return null; + return Math.trunc(parsed); +} + +function parseInteger(value: unknown, min: number, max: number): number | null { + if (typeof value === 'number' && Number.isFinite(value)) { + const candidate = Math.trunc(value); + if (candidate < min || candidate > max) return null; + return candidate; + } + if (typeof value === 'string') { + return parseStrictInteger(value, min, max); + } + return null; +} + +function parseCsv(raw: string): string[] { + return raw + .split(',') + .map((item) => item.trim()) + .filter((item) => item.length > 0); +} + +function parseStringArray(value: unknown): string[] | null { + if (typeof value === 'string') return parseCsv(value); + if (!Array.isArray(value)) return null; + const out = value + .map((entry) => (typeof entry === 'string' ? entry.trim() : '')) + .filter((entry) => entry.length > 0); + return out; +} + +function readTrimmedString(value: unknown): string | null { + if (typeof value !== 'string') return null; + return value.trim(); +} + +function mergeRecords(levels: Array): RecordLike | null { + const merged: RecordLike = {}; + let hasAny = false; + for (const level of levels) { + if (!level) continue; + hasAny = true; + Object.assign(merged, level); + } + return hasAny ? merged : null; +} + +type TelegramChannelBridgeRuntimeConfig = Readonly<{ + botToken: string; + allowedChatIds: string[]; + requireTopics: boolean; + webhookEnabled: boolean; + webhookSecret: string; + webhookHost: string; + webhookPort: number; +}>; + +export type ChannelBridgeRuntimeConfig = Readonly<{ + tickMs: number; + telegram: TelegramChannelBridgeRuntimeConfig; +}>; + +export function resolveChannelBridgeRuntimeConfig(params: Readonly<{ + env?: NodeJS.ProcessEnv; + settings?: unknown; + serverId?: string | null; + accountId?: string | null; +}>): ChannelBridgeRuntimeConfig { + const env = params.env ?? process.env; + const settingsRoot = asRecord(params.settings); + const channelBridgeGlobal = asRecord(settingsRoot?.channelBridge); + + const byServerId = asRecord(channelBridgeGlobal?.byServerId); + const scopedServerId = readTrimmedString(params.serverId) ?? ''; + const scopedAccountId = readTrimmedString(params.accountId) ?? ''; + + const channelBridgeServer = + scopedServerId.length > 0 && byServerId + ? asRecord(byServerId[scopedServerId]) + : null; + + const byAccountId = asRecord(channelBridgeServer?.byAccountId); + const channelBridgeAccount = + scopedAccountId.length > 0 && byAccountId + ? asRecord(byAccountId[scopedAccountId]) + : null; + + const providersGlobal = asRecord(channelBridgeGlobal?.providers); + const providersServer = asRecord(channelBridgeServer?.providers); + const providersAccount = asRecord(channelBridgeAccount?.providers); + + const telegramGlobal = asRecord(providersGlobal?.telegram); + const telegramServer = asRecord(providersServer?.telegram); + const telegramAccount = asRecord(providersAccount?.telegram); + const telegram = mergeRecords([telegramGlobal, telegramServer, telegramAccount]); + + const secretsGlobal = asRecord(telegramGlobal?.secrets); + const secretsServer = asRecord(telegramServer?.secrets); + const secretsAccount = asRecord(telegramAccount?.secrets); + const secrets = mergeRecords([secretsGlobal, secretsServer, secretsAccount]); + + const webhookGlobal = asRecord(telegramGlobal?.webhook); + const webhookServer = asRecord(telegramServer?.webhook); + const webhookAccount = asRecord(telegramAccount?.webhook); + const webhook = mergeRecords([webhookGlobal, webhookServer, webhookAccount]); + + const settingsTickMs = + parseInteger(channelBridgeAccount?.tickMs, 250, 60_000) + ?? parseInteger(channelBridgeServer?.tickMs, 250, 60_000) + ?? parseInteger(channelBridgeGlobal?.tickMs, 250, 60_000); + const envTickMs = + typeof env.HAPPIER_CHANNEL_BRIDGE_TICK_MS === 'string' + ? parseStrictInteger(env.HAPPIER_CHANNEL_BRIDGE_TICK_MS, 250, 60_000) + : null; + const tickMs = envTickMs ?? settingsTickMs ?? 2_500; + + const settingsBotToken = + readTrimmedString(secrets?.botToken) + ?? readTrimmedString(telegram?.botToken) + ?? ''; + const botToken = + typeof env.HAPPIER_TELEGRAM_BOT_TOKEN === 'string' + ? env.HAPPIER_TELEGRAM_BOT_TOKEN.trim() + : settingsBotToken; + + const settingsAllowedChatIds = parseStringArray(telegram?.allowedChatIds) ?? []; + const allowedChatIds = + typeof env.HAPPIER_TELEGRAM_ALLOWED_CHAT_IDS === 'string' + ? parseCsv(env.HAPPIER_TELEGRAM_ALLOWED_CHAT_IDS) + : settingsAllowedChatIds; + + const settingsRequireTopics = parseBoolean(telegram?.requireTopics) ?? false; + const envRequireTopics = + typeof env.HAPPIER_TELEGRAM_REQUIRE_TOPICS === 'string' + ? parseBoolean(env.HAPPIER_TELEGRAM_REQUIRE_TOPICS) + : null; + const requireTopics = envRequireTopics ?? settingsRequireTopics; + + const settingsWebhookEnabled = parseBoolean(webhook?.enabled) ?? false; + const envWebhookEnabled = + typeof env.HAPPIER_TELEGRAM_WEBHOOK_ENABLED === 'string' + ? parseBoolean(env.HAPPIER_TELEGRAM_WEBHOOK_ENABLED) + : null; + const webhookEnabled = envWebhookEnabled ?? settingsWebhookEnabled; + + const settingsWebhookSecret = + readTrimmedString(secrets?.webhookSecret) + ?? readTrimmedString(webhook?.secret) + ?? ''; + const webhookSecret = + typeof env.HAPPIER_TELEGRAM_WEBHOOK_SECRET === 'string' + ? env.HAPPIER_TELEGRAM_WEBHOOK_SECRET.trim() + : settingsWebhookSecret; + + const settingsWebhookHost = readTrimmedString(webhook?.host) || '127.0.0.1'; + const envWebhookHostRaw = + typeof env.HAPPIER_TELEGRAM_WEBHOOK_HOST === 'string' + ? env.HAPPIER_TELEGRAM_WEBHOOK_HOST.trim() + : null; + const webhookHost = envWebhookHostRaw ? envWebhookHostRaw : settingsWebhookHost; + + const settingsWebhookPort = parseInteger(webhook?.port, 1, 65_535) ?? 8_787; + const envWebhookPort = + typeof env.HAPPIER_TELEGRAM_WEBHOOK_PORT === 'string' + ? parseStrictInteger(env.HAPPIER_TELEGRAM_WEBHOOK_PORT, 1, 65_535) + : null; + const webhookPort = envWebhookPort ?? settingsWebhookPort; + + return { + tickMs, + telegram: { + botToken, + allowedChatIds, + requireTopics, + webhookEnabled, + webhookSecret, + webhookHost, + webhookPort, + }, + }; +} diff --git a/apps/cli/src/channels/channelBridgeServerConfigOverlay.test.ts b/apps/cli/src/channels/channelBridgeServerConfigOverlay.test.ts new file mode 100644 index 0000000000..8f87ab970d --- /dev/null +++ b/apps/cli/src/channels/channelBridgeServerConfigOverlay.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest'; + +import { resolveChannelBridgeRuntimeConfig } from './channelBridgeConfig'; +import { overlayServerKvTelegramConfigInSettings } from './channelBridgeServerConfigOverlay'; + +describe('overlayServerKvTelegramConfigInSettings', () => { + it('overlays non-secret server KV telegram config into scoped runtime settings', () => { + const merged = overlayServerKvTelegramConfigInSettings({ + settings: { + channelBridge: { + byServerId: { + 'local-3005': { + byAccountId: { + acct_123: { + providers: { + telegram: { + secrets: { + botToken: 'local-secret-token', + webhookSecret: 'local-secret-webhook', + }, + }, + }, + }, + }, + }, + }, + }, + }, + serverId: 'local-3005', + accountId: 'acct_123', + record: { + schemaVersion: 1, + tickMs: 3100, + telegram: { + allowedChatIds: ['-100111', '-100222'], + requireTopics: true, + webhook: { + enabled: true, + host: '0.0.0.0', + port: 9001, + }, + }, + updatedAtMs: Date.now(), + }, + }); + + const runtime = resolveChannelBridgeRuntimeConfig({ + env: {}, + settings: merged, + serverId: 'local-3005', + accountId: 'acct_123', + }); + + expect(runtime.tickMs).toBe(3100); + expect(runtime.telegram.botToken).toBe('local-secret-token'); + expect(runtime.telegram.allowedChatIds).toEqual(['-100111', '-100222']); + expect(runtime.telegram.requireTopics).toBe(true); + expect(runtime.telegram.webhookEnabled).toBe(true); + expect(runtime.telegram.webhookSecret).toBe('local-secret-webhook'); + expect(runtime.telegram.webhookHost).toBe('0.0.0.0'); + expect(runtime.telegram.webhookPort).toBe(9001); + }); + + it('returns original settings when no record is available', () => { + const settings = { + channelBridge: { + byServerId: { + 'local-3005': { + byAccountId: { + acct_123: { + providers: { + telegram: { + botToken: 'local-token', + }, + }, + }, + }, + }, + }, + }, + }; + + const merged = overlayServerKvTelegramConfigInSettings({ + settings, + serverId: 'local-3005', + accountId: 'acct_123', + record: null, + }); + + expect(merged).toBe(settings); + }); + + it('does not create empty scoped config when record has no usable fields', () => { + const settings = { schemaVersion: 7 }; + + const merged = overlayServerKvTelegramConfigInSettings({ + settings, + serverId: 'local-3005', + accountId: 'acct_123', + record: { + schemaVersion: 1, + telegram: {}, + updatedAtMs: Date.now(), + }, + }); + + expect(merged).toBe(settings); + }); +}); diff --git a/apps/cli/src/channels/channelBridgeServerConfigOverlay.ts b/apps/cli/src/channels/channelBridgeServerConfigOverlay.ts new file mode 100644 index 0000000000..4ddafa7482 --- /dev/null +++ b/apps/cli/src/channels/channelBridgeServerConfigOverlay.ts @@ -0,0 +1,50 @@ +import { + upsertScopedTelegramBridgeConfig, + type ScopedTelegramBridgeUpdate, +} from './channelBridgeAccountConfig'; +import type { ChannelBridgeServerTelegramConfigRecord } from './channelBridgeServerKv'; + +function hasUpdateFields(update: ScopedTelegramBridgeUpdate): boolean { + return ( + typeof update.tickMs === 'number' + || Array.isArray(update.allowedChatIds) + || typeof update.requireTopics === 'boolean' + || typeof update.webhookEnabled === 'boolean' + || typeof update.webhookHost === 'string' + || typeof update.webhookPort === 'number' + ); +} + +export function overlayServerKvTelegramConfigInSettings(params: Readonly<{ + settings: unknown; + serverId: string; + accountId: string; + record: ChannelBridgeServerTelegramConfigRecord | null; +}>): unknown { + if (!params.record) { + return params.settings; + } + + const telegram = params.record.telegram; + const webhook = telegram.webhook; + + const update: ScopedTelegramBridgeUpdate = { + ...(typeof params.record.tickMs === 'number' ? { tickMs: Math.trunc(params.record.tickMs) } : {}), + ...(Array.isArray(telegram.allowedChatIds) ? { allowedChatIds: [...telegram.allowedChatIds] } : {}), + ...(typeof telegram.requireTopics === 'boolean' ? { requireTopics: telegram.requireTopics } : {}), + ...(webhook && typeof webhook.enabled === 'boolean' ? { webhookEnabled: webhook.enabled } : {}), + ...(webhook && typeof webhook.host === 'string' ? { webhookHost: webhook.host } : {}), + ...(webhook && typeof webhook.port === 'number' ? { webhookPort: Math.trunc(webhook.port) } : {}), + }; + + if (!hasUpdateFields(update)) { + return params.settings; + } + + return upsertScopedTelegramBridgeConfig({ + settings: params.settings, + serverId: params.serverId, + accountId: params.accountId, + update, + }); +} diff --git a/apps/cli/src/channels/channelBridgeServerKv.test.ts b/apps/cli/src/channels/channelBridgeServerKv.test.ts new file mode 100644 index 0000000000..9fe16832ec --- /dev/null +++ b/apps/cli/src/channels/channelBridgeServerKv.test.ts @@ -0,0 +1,621 @@ +import { describe, expect, it } from 'vitest'; +import { reloadConfiguration } from '@/configuration'; + +import { + type ChannelBridgeKvClient, + createAxiosChannelBridgeKvClient, + clearChannelBridgeTelegramConfigInKv, + readChannelBridgeBindingsFromKv, + readChannelBridgeTelegramConfigFromKv, + upsertChannelBridgeTelegramConfigInKv, + writeChannelBridgeBindingsToKv, +} from './channelBridgeServerKv'; + +function createInMemoryKvClient(): ChannelBridgeKvClient { + const byKey = new Map(); + + return { + get: async (key) => { + const row = byKey.get(key); + if (!row || row.value === null) { + return { status: 404, body: { error: 'Key not found' } }; + } + return { + status: 200, + body: { + key, + value: row.value, + version: row.version, + }, + }; + }, + mutate: async (mutations) => { + const errors: Array<{ key: string; error: 'version-mismatch'; version: number; value: string | null }> = []; + for (const mutation of mutations) { + const row = byKey.get(mutation.key); + const currentVersion = row?.version ?? -1; + if (currentVersion !== mutation.version) { + errors.push({ + key: mutation.key, + error: 'version-mismatch', + version: currentVersion, + value: row?.value ?? null, + }); + } + } + + if (errors.length > 0) { + return { status: 409, body: { success: false, errors } }; + } + + const results: Array<{ key: string; version: number }> = []; + for (const mutation of mutations) { + const row = byKey.get(mutation.key); + const nextVersion = (row?.version ?? -1) + 1; + byKey.set(mutation.key, { + value: mutation.value, + version: nextVersion, + }); + results.push({ key: mutation.key, version: nextVersion }); + } + + return { status: 200, body: { success: true, results } }; + }, + }; +} + +describe('channelBridgeServerKv', () => { + it('rejects insecure non-loopback http KV base URLs', () => { + const prevServerUrl = process.env.HAPPIER_SERVER_URL; + try { + process.env.HAPPIER_SERVER_URL = 'http://example.com:3005'; + reloadConfiguration(); + + expect(() => createAxiosChannelBridgeKvClient({ token: 'token-1' })).toThrow( + 'Insecure channel bridge KV base URL', + ); + } finally { + if (prevServerUrl === undefined) { + delete process.env.HAPPIER_SERVER_URL; + } else { + process.env.HAPPIER_SERVER_URL = prevServerUrl; + } + reloadConfiguration(); + } + }); + + it('allows loopback http KV base URLs for local development', () => { + const prevServerUrl = process.env.HAPPIER_SERVER_URL; + try { + process.env.HAPPIER_SERVER_URL = 'http://127.0.0.1:3005'; + reloadConfiguration(); + + expect(() => createAxiosChannelBridgeKvClient({ token: 'token-1' })).not.toThrow(); + } finally { + if (prevServerUrl === undefined) { + delete process.env.HAPPIER_SERVER_URL; + } else { + process.env.HAPPIER_SERVER_URL = prevServerUrl; + } + reloadConfiguration(); + } + }); + + it('upserts and reads scoped telegram non-secret config from KV', async () => { + const kv = createInMemoryKvClient(); + + await upsertChannelBridgeTelegramConfigInKv({ + kv, + serverId: 'local-3005', + update: { + tickMs: 2400, + botToken: 'bot-token-1', + webhookSecret: 'secret-1', + allowedChatIds: ['-100111'], + requireTopics: true, + }, + }); + + const config = await readChannelBridgeTelegramConfigFromKv({ + kv, + serverId: 'local-3005', + }); + + expect(config.record).toMatchObject({ + tickMs: 2400, + telegram: { + allowedChatIds: ['-100111'], + requireTopics: true, + }, + }); + const telegram = config.record?.telegram as { botToken?: string } | undefined; + expect(telegram?.botToken).toBeUndefined(); + expect(config.version).toBe(0); + }); + + it('ignores secret-only updates and leaves KV untouched', async () => { + const kv = createInMemoryKvClient(); + + await upsertChannelBridgeTelegramConfigInKv({ + kv, + serverId: 'local-3005', + update: { + botToken: 'bot-token-1', + webhookSecret: 'secret-1', + }, + }); + + const config = await readChannelBridgeTelegramConfigFromKv({ + kv, + serverId: 'local-3005', + }); + + expect(config.record).toBeNull(); + expect(config.version).toBe(-1); + }); + + it('clears scoped telegram config from KV', async () => { + const kv = createInMemoryKvClient(); + + await upsertChannelBridgeTelegramConfigInKv({ + kv, + serverId: 'local-3005', + update: { + allowedChatIds: ['-100111'], + }, + }); + + await clearChannelBridgeTelegramConfigInKv({ + kv, + serverId: 'local-3005', + }); + + const config = await readChannelBridgeTelegramConfigFromKv({ + kv, + serverId: 'local-3005', + }); + + expect(config.record).toBeNull(); + }); + + it('upsert replaces unsupported telegram config schema instead of failing', async () => { + const unknownSchemaValueBase64 = Buffer.from(JSON.stringify({ + schemaVersion: 999, + telegram: { + allowedChatIds: ['-100legacy'], + }, + updatedAtMs: 111, + }), 'utf8').toString('base64'); + + const key = 'happier:channel-bridge:v1:server:local-3005:telegram-config'; + const byKey = new Map(); + byKey.set(key, { + value: unknownSchemaValueBase64, + version: 4, + }); + + const kv: ChannelBridgeKvClient = { + get: async (requestedKey) => { + const row = byKey.get(requestedKey); + if (!row || row.value === null) { + return { status: 404, body: { error: 'Key not found' } }; + } + return { + status: 200, + body: { + key: requestedKey, + value: row.value, + version: row.version, + }, + }; + }, + mutate: async (mutations) => { + const errors: Array<{ key: string; error: 'version-mismatch'; version: number; value: string | null }> = []; + for (const mutation of mutations) { + const row = byKey.get(mutation.key); + const currentVersion = row?.version ?? -1; + if (currentVersion !== mutation.version) { + errors.push({ + key: mutation.key, + error: 'version-mismatch', + version: currentVersion, + value: row?.value ?? null, + }); + } + } + + if (errors.length > 0) { + return { status: 409, body: { success: false, errors } }; + } + + const results: Array<{ key: string; version: number }> = []; + for (const mutation of mutations) { + const row = byKey.get(mutation.key); + const nextVersion = (row?.version ?? -1) + 1; + byKey.set(mutation.key, { + value: mutation.value, + version: nextVersion, + }); + results.push({ key: mutation.key, version: nextVersion }); + } + + return { status: 200, body: { success: true, results } }; + }, + }; + + await expect(upsertChannelBridgeTelegramConfigInKv({ + kv, + serverId: 'local-3005', + update: { + allowedChatIds: ['-100111'], + }, + })).resolves.toBeUndefined(); + + const config = await readChannelBridgeTelegramConfigFromKv({ + kv, + serverId: 'local-3005', + }); + + expect(config.record).toMatchObject({ + schemaVersion: 1, + telegram: { + allowedChatIds: ['-100111'], + }, + }); + expect(config.version).toBe(5); + }); + + it('retries upsert after version mismatch using conflict payload without extra read', async () => { + const key = 'happier:channel-bridge:v1:server:local-3005:telegram-config'; + const initialValueBase64 = Buffer.from(JSON.stringify({ + schemaVersion: 1, + telegram: { + allowedChatIds: ['-100111'], + }, + updatedAtMs: 101, + }), 'utf8').toString('base64'); + const conflictValueBase64 = Buffer.from(JSON.stringify({ + schemaVersion: 1, + telegram: { + allowedChatIds: ['-100111'], + requireTopics: false, + }, + updatedAtMs: 202, + }), 'utf8').toString('base64'); + + let storedVersion = 0; + let storedValue = initialValueBase64; + let getCalls = 0; + let mutateCalls = 0; + + const kv: ChannelBridgeKvClient = { + get: async (requestedKey) => { + getCalls += 1; + return { + status: 200, + body: { + key: requestedKey, + value: storedValue, + version: storedVersion, + }, + }; + }, + mutate: async (mutations) => { + mutateCalls += 1; + const [mutation] = mutations; + if (!mutation) { + return { + status: 400, + body: { + success: false, + errors: [{ key, error: 'version-mismatch', version: storedVersion, value: storedValue }], + }, + }; + } + + if (mutateCalls === 1) { + storedVersion = 1; + storedValue = conflictValueBase64; + return { + status: 409, + body: { + success: false, + errors: [{ key: mutation.key, error: 'version-mismatch', version: storedVersion, value: storedValue }], + }, + }; + } + + storedVersion += 1; + storedValue = mutation.value ?? ''; + return { + status: 200, + body: { + success: true, + results: [{ key: mutation.key, version: storedVersion }], + }, + }; + }, + }; + + await upsertChannelBridgeTelegramConfigInKv({ + kv, + serverId: 'local-3005', + update: { + requireTopics: true, + }, + }); + + expect(getCalls).toBe(1); + expect(mutateCalls).toBe(2); + const finalParsed = JSON.parse(Buffer.from(storedValue, 'base64').toString('utf8')) as { + telegram?: { requireTopics?: boolean }; + }; + expect(finalParsed.telegram?.requireTopics).toBe(true); + }); + + it('retries clear after version mismatch without extra reread', async () => { + const key = 'happier:channel-bridge:v1:server:local-3005:telegram-config'; + const initialValueBase64 = Buffer.from(JSON.stringify({ + schemaVersion: 1, + telegram: { + allowedChatIds: ['-100111'], + }, + updatedAtMs: 101, + }), 'utf8').toString('base64'); + const conflictValueBase64 = Buffer.from(JSON.stringify({ + schemaVersion: 1, + telegram: { + allowedChatIds: ['-100222'], + }, + updatedAtMs: 202, + }), 'utf8').toString('base64'); + + let storedVersion = 0; + let storedValue: string | null = initialValueBase64; + let getCalls = 0; + let mutateCalls = 0; + + const kv: ChannelBridgeKvClient = { + get: async (requestedKey) => { + getCalls += 1; + if (storedValue === null) { + return { status: 404, body: { error: 'Key not found' } }; + } + return { + status: 200, + body: { + key: requestedKey, + value: storedValue, + version: storedVersion, + }, + }; + }, + mutate: async (mutations) => { + mutateCalls += 1; + const [mutation] = mutations; + if (!mutation) { + return { + status: 400, + body: { + success: false, + errors: [{ key, error: 'version-mismatch', version: storedVersion, value: storedValue }], + }, + }; + } + + if (mutateCalls === 1) { + storedVersion = 1; + storedValue = conflictValueBase64; + return { + status: 409, + body: { + success: false, + errors: [{ key: mutation.key, error: 'version-mismatch', version: storedVersion, value: storedValue }], + }, + }; + } + + storedVersion += 1; + storedValue = mutation.value ?? null; + return { + status: 200, + body: { + success: true, + results: [{ key: mutation.key, version: storedVersion }], + }, + }; + }, + }; + + await clearChannelBridgeTelegramConfigInKv({ + kv, + serverId: 'local-3005', + }); + + expect(getCalls).toBe(1); + expect(mutateCalls).toBe(2); + expect(storedValue).toBeNull(); + }); + + it('writes and reads bindings document from KV', async () => { + const kv = createInMemoryKvClient(); + + const first = await readChannelBridgeBindingsFromKv({ + kv, + serverId: 'local-3005', + }); + expect(first.version).toBe(-1); + expect(first.doc.bindings).toEqual([]); + + const writtenVersion = await writeChannelBridgeBindingsToKv({ + kv, + serverId: 'local-3005', + expectedVersion: first.version, + doc: { + schemaVersion: 1, + bindings: [ + { + providerId: 'telegram', + conversationId: '-100111', + threadId: '12', + sessionId: 'sess-1', + lastForwardedSeq: 9, + createdAtMs: 111, + updatedAtMs: 222, + }, + ], + }, + }); + + expect(writtenVersion).toBe(0); + + const second = await readChannelBridgeBindingsFromKv({ + kv, + serverId: 'local-3005', + }); + expect(second.version).toBe(0); + expect(second.doc.bindings).toHaveLength(1); + expect(second.doc.bindings[0]?.sessionId).toBe('sess-1'); + }); + + it('throws when bindings payload is invalid/corrupt in KV', async () => { + const invalidValueBase64 = Buffer.from(JSON.stringify({ schemaVersion: 999, bindings: [] }), 'utf8').toString('base64'); + const kv: ChannelBridgeKvClient = { + get: async () => ({ + status: 200, + body: { + key: 'k', + value: invalidValueBase64, + version: 12, + }, + }), + mutate: async () => ({ status: 200, body: { success: true, results: [] } }), + }; + + await expect(readChannelBridgeBindingsFromKv({ + kv, + serverId: 'local-3005', + })).rejects.toThrow('Invalid or unsupported channel bridge bindings payload in KV'); + }); + + it('throws when bindings payload includes malformed numeric fields', async () => { + const malformedValueBase64 = Buffer.from(JSON.stringify({ + schemaVersion: 1, + bindings: [ + { + providerId: 'telegram', + conversationId: '-100111', + sessionId: 'sess-1', + lastForwardedSeq: 'oops', + }, + ], + }), 'utf8').toString('base64'); + + const kv: ChannelBridgeKvClient = { + get: async () => ({ + status: 200, + body: { + key: 'k', + value: malformedValueBase64, + version: 5, + }, + }), + mutate: async () => ({ status: 200, body: { success: true, results: [] } }), + }; + + await expect(readChannelBridgeBindingsFromKv({ + kv, + serverId: 'local-3005', + })).rejects.toThrow('Invalid channel bridge binding lastForwardedSeq at index 0'); + }); + + it('throws when bindings payload includes malformed thread ids', async () => { + const malformedValueBase64 = Buffer.from(JSON.stringify({ + schemaVersion: 1, + bindings: [ + { + providerId: 'telegram', + conversationId: '-100111', + sessionId: 'sess-1', + threadId: 123, + }, + ], + }), 'utf8').toString('base64'); + + const kv: ChannelBridgeKvClient = { + get: async () => ({ + status: 200, + body: { + key: 'k', + value: malformedValueBase64, + version: 5, + }, + }), + mutate: async () => ({ status: 200, body: { success: true, results: [] } }), + }; + + await expect(readChannelBridgeBindingsFromKv({ + kv, + serverId: 'local-3005', + })).rejects.toThrow('Invalid channel bridge binding threadId at index 0'); + }); + + it('throws when bindings payload includes malformed binding rows', async () => { + const malformedValueBase64 = Buffer.from(JSON.stringify({ + schemaVersion: 1, + bindings: [ + { + providerId: 'telegram', + conversationId: '-100111', + threadId: null, + sessionId: 'sess-1', + lastForwardedSeq: 9, + createdAtMs: 111, + updatedAtMs: 222, + }, + { + providerId: '', + conversationId: '-100222', + sessionId: 'sess-2', + }, + ], + }), 'utf8').toString('base64'); + + const kv: ChannelBridgeKvClient = { + get: async () => ({ + status: 200, + body: { + key: 'k', + value: malformedValueBase64, + version: 5, + }, + }), + mutate: async () => ({ status: 200, body: { success: true, results: [] } }), + }; + + await expect(readChannelBridgeBindingsFromKv({ + kv, + serverId: 'local-3005', + })).rejects.toThrow('Invalid channel bridge binding identity fields at index 1'); + }); + + it('throws when telegram config payload is invalid/corrupt in KV', async () => { + const invalidValueBase64 = Buffer.from(JSON.stringify({ schemaVersion: 999, telegram: {} }), 'utf8').toString('base64'); + const kv: ChannelBridgeKvClient = { + get: async () => ({ + status: 200, + body: { + key: 'k', + value: invalidValueBase64, + version: 3, + }, + }), + mutate: async () => ({ status: 200, body: { success: true, results: [] } }), + }; + + await expect(readChannelBridgeTelegramConfigFromKv({ + kv, + serverId: 'local-3005', + })).rejects.toThrow('Invalid or unsupported Telegram config schema'); + }); +}); diff --git a/apps/cli/src/channels/channelBridgeServerKv.ts b/apps/cli/src/channels/channelBridgeServerKv.ts new file mode 100644 index 0000000000..fb6f889091 --- /dev/null +++ b/apps/cli/src/channels/channelBridgeServerKv.ts @@ -0,0 +1,614 @@ +/** + * Channel bridge server-KV transport and schema helpers. + * + * Responsibilities: + * - encode/decode scoped bridge documents stored in server KV + * - validate supported schema versions for telegram config and binding docs + * - provide optimistic-write friendly KV client helpers and conflict parsing + * - keep bridge server payloads free of local-only secret fields + */ +import axios from 'axios'; + +import { resolveServerHttpBaseUrl } from '@/sessionControl/serverHttpBaseUrl'; + +import type { ScopedTelegramBridgeUpdate } from './channelBridgeAccountConfig'; + +const CHANNEL_BRIDGE_KV_PREFIX = 'happier:channel-bridge:v1'; + +const TELEGRAM_CONFIG_SCHEMA_VERSION = 1; +const BINDINGS_SCHEMA_VERSION = 1; + +export type ChannelBridgeServerTelegramConfigRecord = Readonly<{ + schemaVersion: 1; + tickMs?: number; + telegram: Readonly<{ + allowedChatIds?: string[]; + requireTopics?: boolean; + webhook?: Readonly<{ + enabled?: boolean; + host?: string; + port?: number; + }>; + }>; + updatedAtMs: number; +}>; + +export type ChannelBridgeServerBindingRecord = Readonly<{ + providerId: string; + conversationId: string; + threadId: string | null; + sessionId: string; + lastForwardedSeq: number; + createdAtMs: number; + updatedAtMs: number; +}>; + +export type ChannelBridgeServerBindingsDocument = Readonly<{ + schemaVersion: 1; + bindings: ChannelBridgeServerBindingRecord[]; +}>; + +export type ChannelBridgeKvGetResponse = Readonly<{ + status: number; + body: unknown; +}>; + +export type ChannelBridgeKvMutateResponse = Readonly<{ + status: number; + body: unknown; +}>; + +export type ChannelBridgeKvMutation = Readonly<{ + key: string; + value: string | null; + version: number; +}>; + +export type ChannelBridgeKvClient = Readonly<{ + get: (key: string) => Promise; + mutate: (mutations: readonly ChannelBridgeKvMutation[]) => Promise; +}>; + +export class ChannelBridgeKvVersionMismatchError extends Error { + readonly currentVersion: number; + readonly currentValueBase64: string | null; + + constructor(params: Readonly<{ key: string; currentVersion: number; currentValueBase64: string | null }>) { + super(`KV version mismatch for key ${params.key}`); + this.name = 'ChannelBridgeKvVersionMismatchError'; + this.currentVersion = params.currentVersion; + this.currentValueBase64 = params.currentValueBase64; + } +} + +export class ChannelBridgeBadPayloadError extends Error { + constructor(message: string) { + super(message); + this.name = 'ChannelBridgeBadPayloadError'; + } +} + +function encodeJsonToBase64(value: unknown): string { + return Buffer.from(JSON.stringify(value), 'utf8').toString('base64'); +} + +function decodeBase64ToJson(valueBase64: string): unknown { + const raw = Buffer.from(valueBase64, 'base64').toString('utf8'); + return JSON.parse(raw); +} + +function asRecord(value: unknown): Record | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + return value as Record; +} + +function parseKvEntry(value: unknown): Readonly<{ valueBase64: string; version: number }> | null { + const record = asRecord(value); + if (!record) return null; + if (typeof record.value !== 'string') return null; + if (typeof record.version !== 'number' || !Number.isFinite(record.version)) return null; + return { + valueBase64: record.value, + version: Math.trunc(record.version), + }; +} + +function parseVersionMismatchError(value: unknown): Readonly<{ currentVersion: number; currentValueBase64: string | null }> | null { + const record = asRecord(value); + if (!record) return null; + const errors = Array.isArray(record.errors) ? record.errors : null; + if (!errors || errors.length === 0) return null; + const first = asRecord(errors[0]); + if (!first) return null; + if (typeof first.version !== 'number' || !Number.isFinite(first.version)) return null; + const currentValueBase64 = typeof first.value === 'string' ? first.value : null; + return { + currentVersion: Math.trunc(first.version), + currentValueBase64, + }; +} + +function isLoopbackHost(hostname: string): boolean { + const normalized = hostname.trim().toLowerCase(); + return normalized === 'localhost' || normalized === '127.0.0.1' || normalized === '::1'; +} + +function assertSecureChannelBridgeKvBaseUrl(baseUrl: string): void { + let parsed: URL; + try { + parsed = new URL(baseUrl); + } catch { + throw new Error(`Invalid channel bridge KV base URL: ${baseUrl}`); + } + + if (parsed.protocol === 'https:') return; + if (parsed.protocol === 'http:' && isLoopbackHost(parsed.hostname)) return; + + throw new Error( + `Insecure channel bridge KV base URL: ${baseUrl}. Use https or explicit loopback http for local development.`, + ); +} + +function parseTelegramConfigRecord(value: unknown): ChannelBridgeServerTelegramConfigRecord | null { + const record = asRecord(value); + if (!record) return null; + if (record.schemaVersion !== TELEGRAM_CONFIG_SCHEMA_VERSION) return null; + const telegram = asRecord(record.telegram); + if (!telegram) return null; + + const out: { + schemaVersion: 1; + tickMs?: number; + telegram: { + allowedChatIds?: string[]; + requireTopics?: boolean; + webhook?: { + enabled?: boolean; + host?: string; + port?: number; + }; + }; + updatedAtMs: number; + } = { + schemaVersion: 1, + telegram: {}, + updatedAtMs: typeof record.updatedAtMs === 'number' && Number.isFinite(record.updatedAtMs) + ? Math.trunc(record.updatedAtMs) + : Date.now(), + }; + + if (typeof record.tickMs === 'number' && Number.isFinite(record.tickMs)) { + out.tickMs = Math.trunc(record.tickMs); + } + + if (Array.isArray(telegram.allowedChatIds)) { + out.telegram.allowedChatIds = telegram.allowedChatIds + .map((value) => (typeof value === 'string' ? value.trim() : '')) + .filter((value) => value.length > 0); + } + if (typeof telegram.requireTopics === 'boolean') { + out.telegram.requireTopics = telegram.requireTopics; + } + + const webhook = asRecord(telegram.webhook); + if (webhook) { + const outWebhook: { enabled?: boolean; host?: string; port?: number } = {}; + if (typeof webhook.enabled === 'boolean') outWebhook.enabled = webhook.enabled; + if (typeof webhook.host === 'string') outWebhook.host = webhook.host; + if (typeof webhook.port === 'number' && Number.isFinite(webhook.port)) outWebhook.port = Math.trunc(webhook.port); + out.telegram.webhook = outWebhook; + } + + return out; +} + +function parseBindingsDocument(value: unknown): ChannelBridgeServerBindingsDocument | null { + const record = asRecord(value); + if (!record) return null; + if (record.schemaVersion !== BINDINGS_SCHEMA_VERSION) return null; + const rawBindings = Array.isArray(record.bindings) ? record.bindings : null; + if (!rawBindings) return null; + + const bindings: ChannelBridgeServerBindingRecord[] = []; + for (let index = 0; index < rawBindings.length; index += 1) { + const item = asRecord(rawBindings[index]); + if (!item) { + throw new ChannelBridgeBadPayloadError(`Invalid channel bridge binding entry at index ${index}`); + } + const providerId = typeof item.providerId === 'string' ? item.providerId.trim() : ''; + const conversationId = typeof item.conversationId === 'string' ? item.conversationId.trim() : ''; + const sessionId = typeof item.sessionId === 'string' ? item.sessionId.trim() : ''; + if (!providerId || !conversationId || !sessionId) { + throw new ChannelBridgeBadPayloadError(`Invalid channel bridge binding identity fields at index ${index}`); + } + + if ( + item.lastForwardedSeq !== undefined + && (typeof item.lastForwardedSeq !== 'number' || !Number.isFinite(item.lastForwardedSeq)) + ) { + throw new ChannelBridgeBadPayloadError(`Invalid channel bridge binding lastForwardedSeq at index ${index}`); + } + if ( + item.createdAtMs !== undefined + && (typeof item.createdAtMs !== 'number' || !Number.isFinite(item.createdAtMs)) + ) { + throw new ChannelBridgeBadPayloadError(`Invalid channel bridge binding createdAtMs at index ${index}`); + } + if ( + item.updatedAtMs !== undefined + && (typeof item.updatedAtMs !== 'number' || !Number.isFinite(item.updatedAtMs)) + ) { + throw new ChannelBridgeBadPayloadError(`Invalid channel bridge binding updatedAtMs at index ${index}`); + } + + if ( + item.threadId !== undefined + && item.threadId !== null + && typeof item.threadId !== 'string' + ) { + throw new ChannelBridgeBadPayloadError(`Invalid channel bridge binding threadId at index ${index}`); + } + + const threadIdRaw = typeof item.threadId === 'string' ? item.threadId.trim() : ''; + const threadId = threadIdRaw.length > 0 ? threadIdRaw : null; + const lastForwardedSeq = typeof item.lastForwardedSeq === 'number' && Number.isFinite(item.lastForwardedSeq) + ? Math.max(0, Math.trunc(item.lastForwardedSeq)) + : 0; + const createdAtMs = typeof item.createdAtMs === 'number' && Number.isFinite(item.createdAtMs) + ? Math.trunc(item.createdAtMs) + : Date.now(); + const updatedAtMs = typeof item.updatedAtMs === 'number' && Number.isFinite(item.updatedAtMs) + ? Math.trunc(item.updatedAtMs) + : Date.now(); + bindings.push({ + providerId, + conversationId, + threadId, + sessionId, + lastForwardedSeq, + createdAtMs, + updatedAtMs, + }); + } + + return { + schemaVersion: 1, + bindings, + }; +} + +function telegramConfigKvKey(serverId: string): string { + return `${CHANNEL_BRIDGE_KV_PREFIX}:server:${serverId}:telegram-config`; +} + +function bindingsKvKey(serverId: string): string { + return `${CHANNEL_BRIDGE_KV_PREFIX}:server:${serverId}:bindings`; +} + +async function readJsonValue(params: Readonly<{ + kv: ChannelBridgeKvClient; + key: string; +}>): Promise> { + const response = await params.kv.get(params.key); + if (response.status === 404) { + return { valueBase64: null, version: -1 }; + } + if (response.status !== 200) { + throw new Error(`KV read failed for ${params.key}: HTTP ${response.status}`); + } + + const parsed = parseKvEntry(response.body); + if (!parsed) { + throw new Error(`KV read returned invalid payload for ${params.key}`); + } + return { + valueBase64: parsed.valueBase64, + version: parsed.version, + }; +} + +async function writeJsonValue(params: Readonly<{ + kv: ChannelBridgeKvClient; + key: string; + valueBase64: string | null; + expectedVersion: number; +}>): Promise { + const response = await params.kv.mutate([ + { + key: params.key, + value: params.valueBase64, + version: params.expectedVersion, + }, + ]); + + if (response.status === 200) { + const body = asRecord(response.body); + const results = body && Array.isArray(body.results) ? body.results : null; + const first = results && results.length > 0 ? asRecord(results[0]) : null; + if (!first || typeof first.version !== 'number' || !Number.isFinite(first.version)) { + throw new Error(`KV write returned invalid success payload for ${params.key}`); + } + return Math.trunc(first.version); + } + + if (response.status === 409) { + const mismatch = parseVersionMismatchError(response.body); + if (!mismatch) { + throw new Error(`KV write version mismatch payload invalid for ${params.key}`); + } + throw new ChannelBridgeKvVersionMismatchError({ + key: params.key, + currentVersion: mismatch.currentVersion, + currentValueBase64: mismatch.currentValueBase64, + }); + } + + throw new Error(`KV write failed for ${params.key}: HTTP ${response.status}`); +} + +export function decodeChannelBridgeBindingsDocFromBase64(valueBase64: string | null): ChannelBridgeServerBindingsDocument { + if (!valueBase64) { + return { schemaVersion: 1, bindings: [] }; + } + const parsed = parseBindingsDocument(decodeBase64ToJson(valueBase64)); + if (!parsed) { + throw new ChannelBridgeBadPayloadError('Invalid or unsupported channel bridge bindings payload in KV'); + } + return parsed; +} + +export function createAxiosChannelBridgeKvClient(params: Readonly<{ token: string }>): ChannelBridgeKvClient { + const token = params.token; + const baseUrl = resolveServerHttpBaseUrl(); + assertSecureChannelBridgeKvBaseUrl(baseUrl); + + return { + get: async (key) => { + const response = await axios.get(`${baseUrl}/v1/kv/${encodeURIComponent(key)}`, { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + timeout: 15_000, + validateStatus: () => true, + }); + return { + status: response.status, + body: response.data, + }; + }, + mutate: async (mutations) => { + const response = await axios.post(`${baseUrl}/v1/kv`, { + mutations, + }, { + headers: { + Authorization: `Bearer ${token}`, + 'Content-Type': 'application/json', + }, + timeout: 15_000, + validateStatus: () => true, + }); + + return { + status: response.status, + body: response.data, + }; + }, + }; +} + +export async function readChannelBridgeTelegramConfigFromKv(params: Readonly<{ + kv: ChannelBridgeKvClient; + serverId: string; + allowUnsupportedSchema?: boolean; +}>): Promise> { + const key = telegramConfigKvKey(params.serverId); + const row = await readJsonValue({ kv: params.kv, key }); + if (!row.valueBase64) { + return { record: null, version: row.version }; + } + + const parsed = parseTelegramConfigRecord(decodeBase64ToJson(row.valueBase64)); + if (!parsed) { + if (params.allowUnsupportedSchema) { + return { record: null, version: row.version }; + } + throw new Error(`Invalid or unsupported Telegram config schema for key ${key}`); + } + + return { + record: parsed, + version: row.version, + }; +} + +function applyTelegramConfigUpdate(current: ChannelBridgeServerTelegramConfigRecord | null, update: ScopedTelegramBridgeUpdate): ChannelBridgeServerTelegramConfigRecord { + const next: { + schemaVersion: 1; + tickMs?: number; + telegram: { + allowedChatIds?: string[]; + requireTopics?: boolean; + webhook?: { + enabled?: boolean; + host?: string; + port?: number; + }; + }; + updatedAtMs: number; + } = { + schemaVersion: 1, + telegram: { + ...(current?.telegram ?? {}), + }, + updatedAtMs: Date.now(), + }; + + if (typeof current?.tickMs === 'number') { + next.tickMs = current.tickMs; + } + if (typeof update.tickMs === 'number' && Number.isFinite(update.tickMs)) { + next.tickMs = Math.trunc(update.tickMs); + } + + if (Array.isArray(update.allowedChatIds)) { + next.telegram.allowedChatIds = [...update.allowedChatIds]; + } + if (typeof update.requireTopics === 'boolean') { + next.telegram.requireTopics = update.requireTopics; + } + + const hasWebhookUpdate = + typeof update.webhookEnabled === 'boolean' + || typeof update.webhookHost === 'string' + || (typeof update.webhookPort === 'number' && Number.isFinite(update.webhookPort)); + + if (hasWebhookUpdate) { + const nextWebhook = { + ...(current?.telegram?.webhook ?? {}), + }; + if (typeof update.webhookEnabled === 'boolean') { + nextWebhook.enabled = update.webhookEnabled; + } + if (typeof update.webhookHost === 'string') { + nextWebhook.host = update.webhookHost; + } + if (typeof update.webhookPort === 'number' && Number.isFinite(update.webhookPort)) { + nextWebhook.port = Math.trunc(update.webhookPort); + } + next.telegram.webhook = nextWebhook; + } + + return next; +} + +function hasNonSecretTelegramConfigUpdate(update: ScopedTelegramBridgeUpdate): boolean { + return ( + (typeof update.tickMs === 'number' && Number.isFinite(update.tickMs)) + || Array.isArray(update.allowedChatIds) + || typeof update.requireTopics === 'boolean' + || typeof update.webhookEnabled === 'boolean' + || typeof update.webhookHost === 'string' + || (typeof update.webhookPort === 'number' && Number.isFinite(update.webhookPort)) + ); +} + +export async function upsertChannelBridgeTelegramConfigInKv(params: Readonly<{ + kv: ChannelBridgeKvClient; + serverId: string; + update: ScopedTelegramBridgeUpdate; +}>): Promise { + if (!hasNonSecretTelegramConfigUpdate(params.update)) { + return; + } + + const key = telegramConfigKvKey(params.serverId); + let current: Readonly<{ + record: ChannelBridgeServerTelegramConfigRecord | null; + version: number; + }> | null = null; + for (let attempt = 0; attempt < 4; attempt += 1) { + if (current === null) { + current = await readChannelBridgeTelegramConfigFromKv({ + kv: params.kv, + serverId: params.serverId, + allowUnsupportedSchema: true, + }); + } + + const nextRecord = applyTelegramConfigUpdate(current.record, params.update); + const nextValueBase64 = encodeJsonToBase64(nextRecord); + + try { + await writeJsonValue({ + kv: params.kv, + key, + valueBase64: nextValueBase64, + expectedVersion: current.version, + }); + return; + } catch (error) { + if (error instanceof ChannelBridgeKvVersionMismatchError) { + try { + const conflictRecord = error.currentValueBase64 === null + ? null + : parseTelegramConfigRecord(decodeBase64ToJson(error.currentValueBase64)); + current = { + record: conflictRecord, + version: error.currentVersion, + }; + } catch { + current = null; + } + continue; + } + throw error; + } + } + throw new Error('Failed to upsert channel bridge telegram config in KV after retries'); +} + +export async function clearChannelBridgeTelegramConfigInKv(params: Readonly<{ + kv: ChannelBridgeKvClient; + serverId: string; +}>): Promise { + const key = telegramConfigKvKey(params.serverId); + const current = await readChannelBridgeTelegramConfigFromKv({ + kv: params.kv, + serverId: params.serverId, + allowUnsupportedSchema: true, + }); + let expectedVersion = current.version; + if (expectedVersion < 0) return; + + for (let attempt = 0; attempt < 4; attempt += 1) { + try { + await writeJsonValue({ + kv: params.kv, + key, + valueBase64: null, + expectedVersion, + }); + return; + } catch (error) { + if (error instanceof ChannelBridgeKvVersionMismatchError) { + if (error.currentVersion < 0) { + return; + } + expectedVersion = error.currentVersion; + continue; + } + throw error; + } + } + + throw new Error('Failed to clear channel bridge telegram config in KV after retries'); +} + +export async function readChannelBridgeBindingsFromKv(params: Readonly<{ + kv: ChannelBridgeKvClient; + serverId: string; +}>): Promise> { + const key = bindingsKvKey(params.serverId); + const row = await readJsonValue({ kv: params.kv, key }); + return { + doc: decodeChannelBridgeBindingsDocFromBase64(row.valueBase64), + version: row.version, + }; +} + +export async function writeChannelBridgeBindingsToKv(params: Readonly<{ + kv: ChannelBridgeKvClient; + serverId: string; + expectedVersion: number; + doc: ChannelBridgeServerBindingsDocument; +}>): Promise { + const key = bindingsKvKey(params.serverId); + const valueBase64 = encodeJsonToBase64(params.doc); + return await writeJsonValue({ + kv: params.kv, + key, + valueBase64, + expectedVersion: params.expectedVersion, + }); +} diff --git a/apps/cli/src/channels/core/channelBridgeWorker.test.ts b/apps/cli/src/channels/core/channelBridgeWorker.test.ts index e6e25ab4fe..f110557f5b 100644 --- a/apps/cli/src/channels/core/channelBridgeWorker.test.ts +++ b/apps/cli/src/channels/core/channelBridgeWorker.test.ts @@ -170,6 +170,64 @@ describe('createInMemoryChannelBindingStore', () => { }); describe('executeChannelBridgeTick', () => { + it('sanitizes non-finite in-memory cursor values in binding writes', async () => { + const store = createInMemoryChannelBindingStore(); + + const upserted = await store.upsertBinding({ + providerId: 'telegram', + conversationId: '-100sanity', + threadId: null, + sessionId: 'sess-sanity', + lastForwardedSeq: Number.NaN, + }); + + expect(upserted.lastForwardedSeq).toBe(0); + + await store.updateLastForwardedSeq({ + providerId: 'telegram', + conversationId: '-100sanity', + threadId: null, + }, Number.POSITIVE_INFINITY); + + const afterInvalidUpdate = await store.getBinding({ + providerId: 'telegram', + conversationId: '-100sanity', + threadId: null, + }); + + expect(afterInvalidUpdate?.lastForwardedSeq).toBe(0); + }); + + it('returns defensive copies from in-memory binding store reads', async () => { + const store = createInMemoryChannelBindingStore(() => 1_000); + await store.upsertBinding({ + providerId: 'telegram', + conversationId: 'room-copy', + threadId: null, + sessionId: 'sess-copy', + lastForwardedSeq: 5, + }); + + const firstRead = await store.getBinding({ + providerId: 'telegram', + conversationId: 'room-copy', + threadId: null, + }); + expect(firstRead).not.toBeNull(); + (firstRead as { sessionId: string }).sessionId = 'mutated-first-read'; + + const listed = await store.listBindings(); + (listed[0] as { sessionId: string }).sessionId = 'mutated-list-read'; + + const secondRead = await store.getBinding({ + providerId: 'telegram', + conversationId: 'room-copy', + threadId: null, + }); + + expect(secondRead?.sessionId).toBe('sess-copy'); + }); + it('supports /attach then forwards inbound user messages into the bound session', async () => { const store = createInMemoryChannelBindingStore(); const harness = createAdapterHarness(); @@ -692,6 +750,40 @@ describe('executeChannelBridgeTick', () => { expect(binding?.lastForwardedSeq).toBe(11); }); + it('skips agent rows with invalid seq values and continues forwarding valid rows', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + + await store.upsertBinding({ + providerId: 'telegram', + conversationId: '-1006', + threadId: null, + sessionId: 'sess-invalid-seq-row', + lastForwardedSeq: 9, + }); + + const { deps, warnings } = createDepsHarness({ + fetchAgentMessagesAfterSeq: async () => [ + { seq: Number.NaN, text: 'bad row' }, + { seq: 12, text: 'valid row' }, + ], + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(harness.sent).toEqual([ + { conversationId: '-1006', threadId: null, text: 'valid row' }, + ]); + const [binding] = await store.listBindings(); + expect(binding?.lastForwardedSeq).toBe(12); + expect(warnings.some((row) => row.message.includes('invalid seq'))).toBe(true); + }); + it('does not attach when latest session sequence cannot be resolved', async () => { const store = createInMemoryChannelBindingStore(); const harness = createAdapterHarness(); @@ -1630,7 +1722,6 @@ describe('executeChannelBridgeTick', () => { expect(warnings.some((row) => row.message.includes('Failed to list bindings for outbound forwarding'))).toBe(true); }); }); - describe('startChannelBridgeWorker', () => { it('runs the first tick on startup', async () => { const store = createInMemoryChannelBindingStore(); diff --git a/apps/cli/src/channels/core/channelBridgeWorker.ts b/apps/cli/src/channels/core/channelBridgeWorker.ts index 3d73831a53..9af51f9161 100644 --- a/apps/cli/src/channels/core/channelBridgeWorker.ts +++ b/apps/cli/src/channels/core/channelBridgeWorker.ts @@ -247,8 +247,11 @@ export function createInMemoryChannelBindingStore(now: () => number = () => Date const byKey = new Map(); return { - listBindings: async () => Array.from(byKey.values()), - getBinding: async (ref) => byKey.get(bindingKey(ref)) ?? null, + listBindings: async () => Array.from(byKey.values()).map((binding) => ({ ...binding })), + getBinding: async (ref) => { + const found = byKey.get(bindingKey(ref)); + return found ? { ...found } : null; + }, upsertBinding: async (binding) => { const key = bindingKey(binding); const existing = byKey.get(key); @@ -262,8 +265,8 @@ export function createInMemoryChannelBindingStore(now: () => number = () => Date createdAtMs: existing?.createdAtMs ?? now(), updatedAtMs: now(), }; - byKey.set(key, next); - return next; + byKey.set(key, { ...next }); + return { ...next }; }, updateLastForwardedSeq: async (ref, seq) => { const key = bindingKey(ref); @@ -295,7 +298,7 @@ function parseSlashCommand(text: string): Readonly<{ name: string; args: string[ function formatSessionsMessage(rows: Array>): string { if (rows.length === 0) { - return 'No active sessions found.'; + return 'No sessions found.'; } const limit = 20; const truncated = rows.length > limit; @@ -402,7 +405,7 @@ async function handleCommand(params: Readonly<{ ref, [ 'Happier bridge commands:', - '/sessions - list active sessions', + '/sessions - list recent sessions', '/attach - bind this DM/topic', '/detach - unbind this DM/topic', '/session - show current binding', @@ -483,8 +486,7 @@ async function handleCommand(params: Readonly<{ await replyToConversation(adapter, ref, 'Attaching by session ID or prefix is not supported in this environment.'); return true; } - - await replyToConversation(adapter, ref, 'Session not found. Use /sessions to list active sessions.'); + await replyToConversation(adapter, ref, 'Session not found. Use /sessions to list recent sessions.'); return true; } diff --git a/apps/cli/src/channels/startChannelBridgeWorker.startupFailure.test.ts b/apps/cli/src/channels/startChannelBridgeWorker.startupFailure.test.ts new file mode 100644 index 0000000000..b17bac599a --- /dev/null +++ b/apps/cli/src/channels/startChannelBridgeWorker.startupFailure.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it, vi } from 'vitest'; + +const credentials = { + token: 'token-1', + encryption: { + type: 'legacy' as const, + secret: new Uint8Array([1, 2, 3]), + }, +}; + +describe('startChannelBridgeFromEnv startup failures', () => { + it('stops relay if worker creation throws', async () => { + vi.resetModules(); + + const relayStop = vi.fn(async () => undefined); + + vi.doMock('./core/channelBridgeWorker', () => ({ + createInMemoryChannelBindingStore: vi.fn(() => ({ + listBindings: async () => [], + getBinding: async () => null, + upsertBinding: async () => ({ + providerId: 'telegram', + conversationId: 'conv-1', + threadId: null, + sessionId: 'sess-1', + lastForwardedSeq: 0, + createdAtMs: Date.now(), + updatedAtMs: Date.now(), + }), + updateLastForwardedSeq: async () => undefined, + removeBinding: async () => false, + })), + startChannelBridgeWorker: vi.fn(() => { + throw new Error('worker start failed'); + }), + })); + + vi.doMock('./telegram/telegramAdapter', () => ({ + createTelegramChannelAdapter: vi.fn(() => ({ + providerId: 'telegram', + pullInboundMessages: async () => [], + sendMessage: async () => undefined, + enqueueWebhookUpdate: vi.fn(), + })), + })); + + vi.doMock('./telegram/telegramWebhookRelay', () => ({ + startTelegramWebhookRelay: vi.fn(async () => ({ + port: 8787, + stop: relayStop, + })), + })); + + const { startChannelBridgeFromEnv } = await import('./startChannelBridgeWorker'); + + await expect(startChannelBridgeFromEnv({ + credentials, + env: { + HAPPIER_TELEGRAM_BOT_TOKEN: 'bot-token', + HAPPIER_TELEGRAM_WEBHOOK_ENABLED: 'true', + HAPPIER_TELEGRAM_WEBHOOK_SECRET: 'secret-1', + } as NodeJS.ProcessEnv, + })).rejects.toThrow('worker start failed'); + + expect(relayStop).toHaveBeenCalledTimes(1); + }); + + it('does not fail shutdown when relay stop throws after worker stop succeeds', async () => { + vi.resetModules(); + + const relayStop = vi.fn(async () => { + throw new Error('relay stop failed'); + }); + const workerStop = vi.fn(async () => undefined); + const warnSpy = vi.fn(); + + vi.doMock('@/ui/logger', () => ({ + logger: { + warn: warnSpy, + info: vi.fn(), + debug: vi.fn(), + }, + })); + + vi.doMock('./core/channelBridgeWorker', () => ({ + createInMemoryChannelBindingStore: vi.fn(() => ({ + listBindings: async () => [], + getBinding: async () => null, + upsertBinding: async () => ({ + providerId: 'telegram', + conversationId: 'conv-1', + threadId: null, + sessionId: 'sess-1', + lastForwardedSeq: 0, + createdAtMs: Date.now(), + updatedAtMs: Date.now(), + }), + updateLastForwardedSeq: async () => undefined, + removeBinding: async () => false, + })), + startChannelBridgeWorker: vi.fn(() => ({ + trigger: vi.fn(), + stop: workerStop, + })), + })); + + vi.doMock('./telegram/telegramAdapter', () => ({ + createTelegramChannelAdapter: vi.fn(() => ({ + providerId: 'telegram', + pullInboundMessages: async () => [], + sendMessage: async () => undefined, + enqueueWebhookUpdate: vi.fn(), + })), + })); + + vi.doMock('./telegram/telegramWebhookRelay', () => ({ + startTelegramWebhookRelay: vi.fn(async () => ({ + port: 8787, + path: '/telegram/webhook/secret-1', + stop: relayStop, + })), + })); + + const { startChannelBridgeFromEnv } = await import('./startChannelBridgeWorker'); + + const handle = await startChannelBridgeFromEnv({ + credentials, + env: { + HAPPIER_TELEGRAM_BOT_TOKEN: 'bot-token', + HAPPIER_TELEGRAM_WEBHOOK_ENABLED: 'true', + HAPPIER_TELEGRAM_WEBHOOK_SECRET: 'secret-1', + } as NodeJS.ProcessEnv, + }); + + expect(handle).not.toBeNull(); + await expect(handle!.stop()).resolves.toBeUndefined(); + expect(workerStop).toHaveBeenCalledTimes(1); + expect(relayStop).toHaveBeenCalledTimes(1); + expect(warnSpy).toHaveBeenCalledWith( + '[channelBridge] Error stopping webhook relay during shutdown', + expect.any(Error), + ); + }); +}); diff --git a/apps/cli/src/channels/startChannelBridgeWorker.test.ts b/apps/cli/src/channels/startChannelBridgeWorker.test.ts new file mode 100644 index 0000000000..5fb7808b7a --- /dev/null +++ b/apps/cli/src/channels/startChannelBridgeWorker.test.ts @@ -0,0 +1,446 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { startChannelBridgeFromEnv } from './startChannelBridgeWorker'; + +const credentials = { + token: 'token-1', + encryption: { + type: 'legacy' as const, + secret: new Uint8Array([1, 2, 3]), + }, +}; + +describe('startChannelBridgeFromEnv', () => { + it('returns null when telegram token is not configured and no custom adapters are provided', async () => { + const handle = await startChannelBridgeFromEnv({ + credentials, + env: {}, + }); + + expect(handle).toBeNull(); + }); + + it('starts with injected adapters/deps even without telegram env configuration', async () => { + const stopSpy = vi.fn(); + const adapter = { + providerId: 'fake', + pullInboundMessages: vi.fn(async () => []), + sendMessage: vi.fn(async () => undefined), + stop: stopSpy, + }; + + const deps = { + listSessions: vi.fn(async () => []), + resolveSessionIdOrPrefix: vi.fn(async () => ({ ok: false as const, code: 'session_not_found' as const })), + sendUserMessageToSession: vi.fn(async () => undefined), + resolveLatestSessionSeq: vi.fn(async () => 0), + fetchAgentMessagesAfterSeq: vi.fn(async () => []), + onWarning: vi.fn(), + }; + + const handle = await startChannelBridgeFromEnv({ + credentials, + env: { HAPPIER_CHANNEL_BRIDGE_TICK_MS: '500' } as NodeJS.ProcessEnv, + adapters: [adapter], + deps, + }); + + expect(handle).not.toBeNull(); + handle?.trigger(); + await handle?.stop(); + expect(stopSpy).toHaveBeenCalledTimes(1); + }); + + it('paginates listSessions across cursored pages in default bridge deps', async () => { + vi.resetModules(); + + const fetchSessionsPage = vi + .fn() + .mockResolvedValueOnce({ + sessions: [{ id: 'sess-1', metadata: null }], + nextCursor: 'cursor-1', + hasNext: true, + }) + .mockResolvedValueOnce({ + sessions: [{ id: 'sess-2', metadata: null }], + nextCursor: null, + hasNext: false, + }); + + let capturedDeps: { + listSessions: () => Promise>; + } | null = null; + + vi.doMock('@/sessionControl/sessionsHttp', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchSessionsPage, + }; + }); + + vi.doMock('./core/channelBridgeWorker', () => ({ + createInMemoryChannelBindingStore: vi.fn(() => ({ + listBindings: async () => [], + getBinding: async () => null, + upsertBinding: async () => ({ + providerId: 'telegram', + conversationId: 'conv-1', + threadId: null, + sessionId: 'sess-1', + lastForwardedSeq: 0, + createdAtMs: Date.now(), + updatedAtMs: Date.now(), + }), + updateLastForwardedSeq: async () => undefined, + removeBinding: async () => false, + })), + startChannelBridgeWorker: vi.fn((params: { deps: typeof capturedDeps }) => { + capturedDeps = params.deps as typeof capturedDeps; + return { + trigger: vi.fn(), + stop: vi.fn(async () => undefined), + }; + }), + })); + + vi.doMock('./telegram/telegramAdapter', () => ({ + createTelegramChannelAdapter: vi.fn(() => ({ + providerId: 'telegram', + pullInboundMessages: async () => [], + sendMessage: async () => undefined, + enqueueWebhookUpdate: vi.fn(), + stop: async () => undefined, + })), + })); + + const { startChannelBridgeFromEnv } = await import('./startChannelBridgeWorker'); + + const handle = await startChannelBridgeFromEnv({ + credentials, + env: { + HAPPIER_TELEGRAM_BOT_TOKEN: 'bot-token', + } as NodeJS.ProcessEnv, + }); + + expect(handle).not.toBeNull(); + expect(capturedDeps).not.toBeNull(); + + const sessions = await capturedDeps!.listSessions(); + expect(fetchSessionsPage).toHaveBeenCalledTimes(2); + expect(fetchSessionsPage).toHaveBeenNthCalledWith(1, { + token: credentials.token, + activeOnly: false, + limit: 100, + cursor: undefined, + }); + expect(fetchSessionsPage).toHaveBeenNthCalledWith(2, { + token: credentials.token, + activeOnly: false, + limit: 100, + cursor: 'cursor-1', + }); + expect(sessions.map((row) => row.sessionId)).toEqual(['sess-1', 'sess-2']); + + await handle?.stop(); + }); + + it('reuses cached session context between send and transcript fetch calls', async () => { + vi.resetModules(); + + const fetchSessionById = vi.fn(async () => ({ + id: 'sess-ctx-1', + encryptionMode: 'plain', + metadata: null, + dataEncryptionKey: null, + })); + const fetchAfterSeq = vi.fn(async () => []); + const sendCommitted = vi.fn(async () => undefined); + + let capturedDeps: { + sendUserMessageToSession: (params: { + sessionId: string; + text: string; + sentFrom: string; + providerId: string; + conversationId: string; + threadId: string | null; + }) => Promise; + fetchAgentMessagesAfterSeq: (params: { sessionId: string; afterSeq: number }) => Promise>; + } | null = null; + + vi.doMock('@/sessionControl/sessionsHttp', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchSessionById, + }; + }); + + vi.doMock('@/api/session/fetchEncryptedTranscriptWindow', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchEncryptedTranscriptPageAfterSeq: fetchAfterSeq, + }; + }); + + vi.doMock('@/sessionControl/sessionSocketSendMessage', () => ({ + sendSessionMessageViaSocketCommitted: sendCommitted, + })); + + vi.doMock('./core/channelBridgeWorker', () => ({ + createInMemoryChannelBindingStore: vi.fn(() => ({ + listBindings: async () => [], + getBinding: async () => null, + upsertBinding: async () => ({ + providerId: 'telegram', + conversationId: 'conv-1', + threadId: null, + sessionId: 'sess-1', + lastForwardedSeq: 0, + createdAtMs: Date.now(), + updatedAtMs: Date.now(), + }), + updateLastForwardedSeq: async () => undefined, + removeBinding: async () => false, + })), + startChannelBridgeWorker: vi.fn((params: { deps: typeof capturedDeps }) => { + capturedDeps = params.deps as typeof capturedDeps; + return { + trigger: vi.fn(), + stop: vi.fn(async () => undefined), + }; + }), + })); + + vi.doMock('./telegram/telegramAdapter', () => ({ + createTelegramChannelAdapter: vi.fn(() => ({ + providerId: 'telegram', + pullInboundMessages: async () => [], + sendMessage: async () => undefined, + enqueueWebhookUpdate: vi.fn(), + stop: async () => undefined, + })), + })); + + const { startChannelBridgeFromEnv } = await import('./startChannelBridgeWorker'); + + const handle = await startChannelBridgeFromEnv({ + credentials, + env: { + HAPPIER_TELEGRAM_BOT_TOKEN: 'bot-token', + } as NodeJS.ProcessEnv, + }); + + expect(handle).not.toBeNull(); + expect(capturedDeps).not.toBeNull(); + + await capturedDeps!.sendUserMessageToSession({ + sessionId: 'sess-ctx-1', + text: 'hello from channel', + sentFrom: 'channel-bridge', + providerId: 'telegram', + conversationId: '-100111', + threadId: null, + }); + await capturedDeps!.fetchAgentMessagesAfterSeq({ + sessionId: 'sess-ctx-1', + afterSeq: 0, + }); + + expect(fetchSessionById).toHaveBeenCalledTimes(1); + expect(sendCommitted).toHaveBeenCalledTimes(1); + expect(fetchAfterSeq).toHaveBeenCalledTimes(1); + + await handle?.stop(); + }); + + it('refreshes session runtime LRU order on cache hits', async () => { + vi.resetModules(); + + const fetchSessionById = vi.fn(async ({ sessionId }: { sessionId: string }) => ({ + id: sessionId, + encryptionMode: 'plain', + metadata: null, + dataEncryptionKey: null, + })); + const sendCommitted = vi.fn(async () => undefined); + + let capturedDeps: { + sendUserMessageToSession: (params: { + sessionId: string; + text: string; + sentFrom: string; + providerId: string; + conversationId: string; + threadId: string | null; + }) => Promise; + } | null = null; + + vi.doMock('@/sessionControl/sessionsHttp', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchSessionById, + }; + }); + + vi.doMock('@/sessionControl/sessionSocketSendMessage', () => ({ + sendSessionMessageViaSocketCommitted: sendCommitted, + })); + + vi.doMock('./core/channelBridgeWorker', () => ({ + createInMemoryChannelBindingStore: vi.fn(() => ({ + listBindings: async () => [], + getBinding: async () => null, + upsertBinding: async () => ({ + providerId: 'telegram', + conversationId: 'conv-1', + threadId: null, + sessionId: 'sess-1', + lastForwardedSeq: 0, + createdAtMs: Date.now(), + updatedAtMs: Date.now(), + }), + updateLastForwardedSeq: async () => undefined, + removeBinding: async () => false, + })), + startChannelBridgeWorker: vi.fn((params: { deps: typeof capturedDeps }) => { + capturedDeps = params.deps as typeof capturedDeps; + return { + trigger: vi.fn(), + stop: vi.fn(async () => undefined), + }; + }), + })); + + vi.doMock('./telegram/telegramAdapter', () => ({ + createTelegramChannelAdapter: vi.fn(() => ({ + providerId: 'telegram', + pullInboundMessages: async () => [], + sendMessage: async () => undefined, + enqueueWebhookUpdate: vi.fn(), + stop: async () => undefined, + })), + })); + + const { startChannelBridgeFromEnv } = await import('./startChannelBridgeWorker'); + + const handle = await startChannelBridgeFromEnv({ + credentials, + env: { + HAPPIER_TELEGRAM_BOT_TOKEN: 'bot-token', + } as NodeJS.ProcessEnv, + }); + + expect(handle).not.toBeNull(); + expect(capturedDeps).not.toBeNull(); + + for (let index = 0; index < 200; index += 1) { + const sessionId = `sess-lru-${index}`; + await capturedDeps!.sendUserMessageToSession({ + sessionId, + text: 'prime cache', + sentFrom: 'channel-bridge', + providerId: 'telegram', + conversationId: '-100111', + threadId: null, + }); + } + + await capturedDeps!.sendUserMessageToSession({ + sessionId: 'sess-lru-0', + text: 'refresh oldest', + sentFrom: 'channel-bridge', + providerId: 'telegram', + conversationId: '-100111', + threadId: null, + }); + + await capturedDeps!.sendUserMessageToSession({ + sessionId: 'sess-lru-200', + text: 'trigger eviction', + sentFrom: 'channel-bridge', + providerId: 'telegram', + conversationId: '-100111', + threadId: null, + }); + + await capturedDeps!.sendUserMessageToSession({ + sessionId: 'sess-lru-0', + text: 'should stay cached', + sentFrom: 'channel-bridge', + providerId: 'telegram', + conversationId: '-100111', + threadId: null, + }); + + expect(fetchSessionById).toHaveBeenCalledTimes(201); + + await handle?.stop(); + }); + + it('warns when webhook mode is enabled without webhook secret', async () => { + vi.resetModules(); + + const warnSpy = vi.fn(); + + vi.doMock('@/ui/logger', () => ({ + logger: { + warn: warnSpy, + info: vi.fn(), + debug: vi.fn(), + }, + })); + + vi.doMock('./core/channelBridgeWorker', () => ({ + createInMemoryChannelBindingStore: vi.fn(() => ({ + listBindings: async () => [], + getBinding: async () => null, + upsertBinding: async () => ({ + providerId: 'telegram', + conversationId: 'conv-1', + threadId: null, + sessionId: 'sess-1', + lastForwardedSeq: 0, + createdAtMs: Date.now(), + updatedAtMs: Date.now(), + }), + updateLastForwardedSeq: async () => undefined, + removeBinding: async () => false, + })), + startChannelBridgeWorker: vi.fn(() => ({ + trigger: vi.fn(), + stop: vi.fn(async () => undefined), + })), + })); + + vi.doMock('./telegram/telegramAdapter', () => ({ + createTelegramChannelAdapter: vi.fn(() => ({ + providerId: 'telegram', + pullInboundMessages: async () => [], + sendMessage: async () => undefined, + enqueueWebhookUpdate: vi.fn(), + stop: async () => undefined, + })), + })); + + const { startChannelBridgeFromEnv } = await import('./startChannelBridgeWorker'); + + const handle = await startChannelBridgeFromEnv({ + credentials, + env: { + HAPPIER_TELEGRAM_BOT_TOKEN: 'bot-token', + HAPPIER_TELEGRAM_WEBHOOK_ENABLED: 'true', + } as NodeJS.ProcessEnv, + }); + + expect(handle).not.toBeNull(); + expect(warnSpy).toHaveBeenCalledWith( + '[channelBridge] Telegram webhook.enabled=true but webhook.secret is missing; falling back to polling mode', + ); + + await handle?.stop(); + }); +}); diff --git a/apps/cli/src/channels/startChannelBridgeWorker.ts b/apps/cli/src/channels/startChannelBridgeWorker.ts new file mode 100644 index 0000000000..866c8194b0 --- /dev/null +++ b/apps/cli/src/channels/startChannelBridgeWorker.ts @@ -0,0 +1,375 @@ +import { randomUUID } from 'node:crypto'; + +import { serializeAxiosErrorForLog } from '@/api/client/serializeAxiosErrorForLog'; +import { fetchEncryptedTranscriptPageAfterSeq, fetchEncryptedTranscriptPageLatest } from '@/api/session/fetchEncryptedTranscriptWindow'; +import type { Credentials } from '@/persistence'; +import { decryptTranscriptRows } from '@/session/replay/decryptTranscriptRows'; +import { resolveSessionIdOrPrefix } from '@/sessionControl/resolveSessionId'; +import { + encryptSessionPayload, + resolveSessionEncryptionContextFromCredentials, + resolveSessionStoredContentEncryptionMode, + tryDecryptSessionMetadata, + type SessionEncryptionContext, +} from '@/sessionControl/sessionEncryptionContext'; +import { sendSessionMessageViaSocketCommitted } from '@/sessionControl/sessionSocketSendMessage'; +import { fetchSessionById, fetchSessionsPage, type RawSessionListRow } from '@/sessionControl/sessionsHttp'; +import { logger } from '@/ui/logger'; + +import { + createInMemoryChannelBindingStore, + startChannelBridgeWorker, + type ChannelBindingStore, + type ChannelBridgeAdapter, + type ChannelBridgeDeps, + type ChannelBridgeWorkerHandle, +} from './core/channelBridgeWorker'; +import { resolveChannelBridgeRuntimeConfig } from './channelBridgeConfig'; +import { createServerBackedChannelBindingStore } from './channelBindingStore.server'; +import { createAxiosChannelBridgeKvClient } from './channelBridgeServerKv'; +import { createTelegramChannelAdapter } from './telegram/telegramAdapter'; +import { startTelegramWebhookRelay, type TelegramWebhookRelayHandle } from './telegram/telegramWebhookRelay'; + +export type ChannelBridgeRuntimeHandle = ChannelBridgeWorkerHandle; + +type DefaultChannelBridgeDepsHandle = Readonly<{ + deps: ChannelBridgeDeps; + dispose: () => void; +}>; + +function asRecord(value: unknown): Record | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + return value as Record; +} + +function extractAssistantText(content: unknown): string | null { + const contentRecord = asRecord(content); + if (!contentRecord) return null; + + const type = contentRecord.type; + if (type === 'text') { + const text = contentRecord.text; + return typeof text === 'string' && text.trim().length > 0 ? text.trim() : null; + } + + if (type === 'acp') { + const data = asRecord(contentRecord.data); + if (!data) return null; + + const dataType = data.type; + if (dataType === 'message' || dataType === 'reasoning') { + const message = data.message; + return typeof message === 'string' && message.trim().length > 0 ? message.trim() : null; + } + return null; + } + + if (type === 'output') { + const data = asRecord(contentRecord.data); + if (!data) return null; + + const message = data.message; + return typeof message === 'string' && message.trim().length > 0 ? message.trim() : null; + } + + return null; +} + +function createDefaultChannelBridgeDeps(credentials: Credentials): DefaultChannelBridgeDepsHandle { + type CachedSessionRuntime = Readonly<{ + ctx: SessionEncryptionContext; + mode: ReturnType; + }>; + + const sessionRuntimeCache = new Map(); + const SESSION_CTX_CACHE_MAX_ENTRIES = 200; + + const rememberSessionRuntime = (sessionId: string, runtime: CachedSessionRuntime): CachedSessionRuntime => { + if (sessionRuntimeCache.has(sessionId)) { + sessionRuntimeCache.delete(sessionId); + } + sessionRuntimeCache.set(sessionId, runtime); + while (sessionRuntimeCache.size > SESSION_CTX_CACHE_MAX_ENTRIES) { + const oldestKey = sessionRuntimeCache.keys().next().value; + if (!oldestKey) break; + sessionRuntimeCache.delete(oldestKey); + } + return runtime; + }; + + async function resolveSessionRuntime(sessionId: string): Promise { + const cached = sessionRuntimeCache.get(sessionId); + if (cached) return rememberSessionRuntime(sessionId, cached); + + const rawSession = await fetchSessionById({ token: credentials.token, sessionId }); + if (!rawSession) return null; + const runtime: CachedSessionRuntime = { + ctx: resolveSessionEncryptionContextFromCredentials(credentials, rawSession), + mode: resolveSessionStoredContentEncryptionMode(rawSession), + }; + return rememberSessionRuntime(sessionId, runtime); + } + + async function resolveSessionContext(sessionId: string): Promise { + const runtime = await resolveSessionRuntime(sessionId); + return runtime?.ctx ?? null; + } + + return { + deps: { + listSessions: async () => { + const pageSize = 100; + const maxPages = 50; + const sessions: RawSessionListRow[] = []; + let cursor: string | undefined; + + for (let pageIndex = 0; pageIndex < maxPages; pageIndex += 1) { + const page = await fetchSessionsPage({ + token: credentials.token, + activeOnly: false, + limit: pageSize, + cursor, + }); + sessions.push(...page.sessions); + if (!page.hasNext || !page.nextCursor) { + cursor = undefined; + break; + } + cursor = page.nextCursor; + } + + if (cursor) { + logger.warn(`[channelBridge] Session list pagination stopped after ${maxPages} pages; truncating /sessions results`); + } + + return sessions.map((row) => { + const meta = tryDecryptSessionMetadata({ credentials, rawSession: row }); + const tagRaw = meta?.tag; + const tag = typeof tagRaw === 'string' ? tagRaw.trim() : ''; + return { + sessionId: row.id, + label: tag.length > 0 ? tag : null, + }; + }); + }, + resolveSessionIdOrPrefix: async (idOrPrefix: string) => { + return await resolveSessionIdOrPrefix({ + credentials, + idOrPrefix, + }); + }, + sendUserMessageToSession: async (params) => { + const runtime = await resolveSessionRuntime(params.sessionId); + if (!runtime) { + throw new Error(`Session not found: ${params.sessionId}`); + } + + const ctx = runtime.ctx; + const mode = runtime.mode; + const record = { + role: 'user', + content: { type: 'text', text: params.text }, + meta: { + sentFrom: params.sentFrom, + source: params.sentFrom, + channel: { + providerId: params.providerId, + conversationId: params.conversationId, + threadId: params.threadId, + }, + }, + }; + + const content = + mode === 'plain' + ? ({ t: 'plain', v: record } as const) + : ({ t: 'encrypted', c: encryptSessionPayload({ ctx, payload: record }) } as const); + + await sendSessionMessageViaSocketCommitted({ + token: credentials.token, + sessionId: params.sessionId, + content, + localId: randomUUID(), + sentFrom: params.sentFrom, + }); + }, + resolveLatestSessionSeq: async (sessionId: string) => { + const rows = await fetchEncryptedTranscriptPageLatest({ + token: credentials.token, + sessionId, + limit: 1, + }); + if (rows.length === 0) return 0; + return Math.max(0, Math.trunc(rows[0]!.seq)); + }, + fetchAgentMessagesAfterSeq: async ({ sessionId, afterSeq }) => { + const ctx = await resolveSessionContext(sessionId); + if (!ctx) return []; + const encrypted = await fetchEncryptedTranscriptPageAfterSeq({ + token: credentials.token, + sessionId, + afterSeq, + limit: 50, + }); + const decrypted = decryptTranscriptRows({ ctx, rows: encrypted }); + return decrypted + .filter((row) => row.role === 'agent') + .map((row) => ({ seq: row.seq, text: extractAssistantText(row.content) })) + .filter((row) => typeof row.text === 'string' && row.text.trim().length > 0) + .map((row) => ({ seq: row.seq, text: row.text! })); + }, + onWarning: (message, error) => { + if (typeof error === 'undefined') { + logger.warn(`[channelBridge] ${message}`); + } else { + logger.warn(`[channelBridge] ${message}`, serializeAxiosErrorForLog(error)); + } + }, + }, + dispose: () => { + sessionRuntimeCache.clear(); + }, + }; +} + +export async function startChannelBridgeFromEnv(params: Readonly<{ + credentials: Credentials; + env?: NodeJS.ProcessEnv; + settings?: unknown; + serverId?: string | null; + accountId?: string | null; + deps?: ChannelBridgeDeps; + store?: ChannelBindingStore; + adapters?: readonly ChannelBridgeAdapter[]; +}>): Promise { + const env = params.env ?? process.env; + const runtimeConfig = resolveChannelBridgeRuntimeConfig({ + env, + settings: params.settings, + serverId: params.serverId, + accountId: params.accountId, + }); + + let relayHandle: TelegramWebhookRelayHandle | null = null; + const adapters: ChannelBridgeAdapter[] = params.adapters ? [...params.adapters] : []; + if (adapters.length === 0) { + const botToken = runtimeConfig.telegram.botToken; + if (!botToken) { + return null; + } + + const allowedChatIdsRaw = runtimeConfig.telegram.allowedChatIds; + const allowedChatIds = allowedChatIdsRaw.length > 0 ? new Set(allowedChatIdsRaw) : null; + const webhookEnabled = runtimeConfig.telegram.webhookEnabled; + const webhookSecret = runtimeConfig.telegram.webhookSecret; + const requireTopics = runtimeConfig.telegram.requireTopics; + + if (webhookEnabled && webhookSecret.length === 0) { + logger.warn('[channelBridge] Telegram webhook.enabled=true but webhook.secret is missing; falling back to polling mode'); + } + + const webhookModeRequested = webhookEnabled && webhookSecret.length > 0; + let telegramAdapter = createTelegramChannelAdapter({ + botToken, + allowedChatIds, + requireTopics, + webhookMode: webhookModeRequested, + }); + + const telegramAdapterIndex = adapters.push(telegramAdapter) - 1; + + if (webhookModeRequested) { + const port = runtimeConfig.telegram.webhookPort; + const host = runtimeConfig.telegram.webhookHost; + try { + relayHandle = await startTelegramWebhookRelay({ + port, + host, + // We intentionally use one shared secret today because bridge config currently + // exposes a single webhook secret field. The relay API keeps both knobs + // separate so we can split path/header secrets in a future config version. + secretPathToken: webhookSecret, + secretHeaderToken: webhookSecret, + onUpdate: telegramAdapter.enqueueWebhookUpdate, + }); + logger.debug( + `[channelBridge] Telegram webhook relay listening on http://${host}:${relayHandle.port} (path redacted)`, + ); + } catch (error) { + logger.warn( + '[channelBridge] Failed to start Telegram webhook relay; bridge will continue without webhook relay', + serializeAxiosErrorForLog(error), + ); + telegramAdapter = createTelegramChannelAdapter({ + botToken, + allowedChatIds, + requireTopics, + webhookMode: false, + }); + adapters[telegramAdapterIndex] = telegramAdapter; + logger.warn('[channelBridge] Falling back to Telegram polling mode because webhook relay failed to start'); + } + } + } + + if (adapters.length === 0) { + return null; + } + + let defaultDepsHandle: DefaultChannelBridgeDepsHandle | null = null; + const deps = (() => { + if (params.deps) return params.deps; + defaultDepsHandle = createDefaultChannelBridgeDeps(params.credentials); + return defaultDepsHandle.deps; + })(); + let store = params.store ?? null; + if (!store) { + const serverId = typeof params.serverId === 'string' ? params.serverId.trim() : ''; + if (serverId) { + const kv = createAxiosChannelBridgeKvClient({ token: params.credentials.token }); + store = createServerBackedChannelBindingStore({ + kv, + serverId, + }); + } + } + if (!store) { + store = createInMemoryChannelBindingStore(); + } + const tickMs = runtimeConfig.tickMs; + + let worker: ChannelBridgeWorkerHandle; + try { + worker = startChannelBridgeWorker({ + store, + adapters, + deps, + tickMs, + }); + } catch (error) { + if (relayHandle) { + await relayHandle.stop().catch(() => undefined); + } + defaultDepsHandle?.dispose(); + throw error; + } + + return { + trigger: worker.trigger, + stop: async () => { + try { + await worker.stop(); + } finally { + try { + if (relayHandle) { + await relayHandle.stop().catch((error: unknown) => { + logger.warn('[channelBridge] Error stopping webhook relay during shutdown', error); + }); + } + } finally { + defaultDepsHandle?.dispose(); + } + } + }, + }; +} diff --git a/apps/cli/src/channels/telegram/telegramAdapter.test.ts b/apps/cli/src/channels/telegram/telegramAdapter.test.ts new file mode 100644 index 0000000000..a8817709ad --- /dev/null +++ b/apps/cli/src/channels/telegram/telegramAdapter.test.ts @@ -0,0 +1,295 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createTelegramChannelAdapter } from './telegramAdapter'; + +function createDeferredPromise() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((res, rej) => { + resolve = res; + reject = rej; + }); + return { promise, resolve, reject }; +} + +describe('createTelegramChannelAdapter', () => { + it('parses inbound topic updates and ignores self-authored bot messages', async () => { + const api = { + getMe: vi.fn(async () => ({ id: 777, username: 'happier_bot' })), + getUpdates: vi.fn(async () => ([ + { + update_id: 101, + message: { + message_id: 5001, + text: 'hello from topic', + message_thread_id: 9001, + chat: { id: -100555, type: 'supergroup' }, + from: { id: 42, is_bot: false, first_name: 'Ada' }, + }, + }, + { + update_id: 102, + message: { + message_id: 5002, + text: 'echo from self', + chat: { id: -100555, type: 'supergroup' }, + from: { id: 777, is_bot: true, username: 'happier_bot' }, + }, + }, + ])), + sendMessage: vi.fn(async () => undefined), + }; + + const adapter = createTelegramChannelAdapter({ + botToken: 'test-token', + api, + }); + + const inbound = await adapter.pullInboundMessages(); + expect(inbound).toEqual([ + { + providerId: 'telegram', + conversationId: '-100555', + threadId: '9001', + text: 'hello from topic', + messageId: '5001', + }, + ]); + }); + + it('sends outbound messages with thread targeting when threadId is present', async () => { + const api = { + getMe: vi.fn(async () => ({ id: 9, username: 'happier_bot' })), + getUpdates: vi.fn(async () => []), + sendMessage: vi.fn(async () => undefined), + }; + + const adapter = createTelegramChannelAdapter({ + botToken: 'test-token', + api, + }); + + await adapter.sendMessage({ + conversationId: '-100777', + threadId: '451', + text: 'assistant says hi', + }); + + expect(api.sendMessage).toHaveBeenCalledWith({ + chatId: '-100777', + threadId: '451', + text: 'assistant says hi', + }); + }); + + it('accepts webhook updates through enqueueWebhookUpdate without polling', async () => { + const api = { + getMe: vi.fn(async () => ({ id: 9, username: 'happier_bot' })), + getUpdates: vi.fn(async () => []), + sendMessage: vi.fn(async () => undefined), + }; + + const adapter = createTelegramChannelAdapter({ + botToken: 'test-token', + api, + webhookMode: true, + }); + + adapter.enqueueWebhookUpdate({ + update_id: 777, + message: { + message_id: 88, + text: '/sessions', + chat: { id: 1234, type: 'private' }, + from: { id: 456, is_bot: false, first_name: 'Grace' }, + }, + }); + + const inbound = await adapter.pullInboundMessages(); + expect(inbound).toEqual([ + { + providerId: 'telegram', + conversationId: '1234', + threadId: null, + text: '/sessions', + messageId: '88', + }, + ]); + expect(api.getUpdates).not.toHaveBeenCalled(); + }); + + it('keeps webhook updates queued when parsing fails and retries later', async () => { + const api = { + getMe: vi + .fn() + .mockRejectedValueOnce(new Error('temporary getMe failure')) + .mockResolvedValue({ id: 9, username: 'happier_bot' }), + getUpdates: vi.fn(async () => []), + sendMessage: vi.fn(async () => undefined), + }; + + const adapter = createTelegramChannelAdapter({ + botToken: 'test-token', + api, + webhookMode: true, + }); + + adapter.enqueueWebhookUpdate({ + update_id: 801, + message: { + message_id: 9001, + text: 'retry me', + chat: { id: 1234, type: 'private' }, + from: { id: 456, is_bot: false, first_name: 'Grace' }, + }, + }); + + await expect(adapter.pullInboundMessages()).rejects.toThrow('temporary getMe failure'); + + const inbound = await adapter.pullInboundMessages(); + expect(inbound).toEqual([ + { + providerId: 'telegram', + conversationId: '1234', + threadId: null, + text: 'retry me', + messageId: '9001', + }, + ]); + }); + + it('bounds webhook queue size to prevent unbounded growth', async () => { + const api = { + getMe: vi.fn(async () => ({ id: 9, username: 'happier_bot' })), + getUpdates: vi.fn(async () => []), + sendMessage: vi.fn(async () => undefined), + }; + + const adapter = createTelegramChannelAdapter({ + botToken: 'test-token', + api, + webhookMode: true, + }); + + for (let i = 0; i < 2_100; i += 1) { + adapter.enqueueWebhookUpdate({ + update_id: i, + message: { + message_id: i, + text: `message ${i}`, + chat: { id: 4321, type: 'private' }, + from: { id: 222, is_bot: false, first_name: 'Ada' }, + }, + }); + } + + const inbound = await adapter.pullInboundMessages(); + expect(inbound).toHaveLength(2_000); + expect(inbound[0]?.messageId).toBe('100'); + expect(inbound[1_999]?.messageId).toBe('2099'); + }); + + it('does not drop updates enqueued while webhook parsing is in flight', async () => { + const gate = createDeferredPromise(); + const api = { + getMe: vi.fn(async () => { + await gate.promise; + return { id: 9, username: 'happier_bot' }; + }), + getUpdates: vi.fn(async () => []), + sendMessage: vi.fn(async () => undefined), + }; + + const adapter = createTelegramChannelAdapter({ + botToken: 'test-token', + api, + webhookMode: true, + }); + + adapter.enqueueWebhookUpdate({ + update_id: 901, + message: { + message_id: 901, + text: 'first', + chat: { id: 1234, type: 'private' }, + from: { id: 456, is_bot: false, first_name: 'Grace' }, + }, + }); + + const firstPull = adapter.pullInboundMessages(); + + adapter.enqueueWebhookUpdate({ + update_id: 902, + message: { + message_id: 902, + text: 'second', + chat: { id: 1234, type: 'private' }, + from: { id: 456, is_bot: false, first_name: 'Grace' }, + }, + }); + + gate.resolve(); + + const firstInbound = await firstPull; + expect(firstInbound).toHaveLength(1); + expect(firstInbound[0]?.messageId).toBe('901'); + + const secondInbound = await adapter.pullInboundMessages(); + expect(secondInbound).toHaveLength(1); + expect(secondInbound[0]?.messageId).toBe('902'); + }); + + it('advances polling offset only after successful parsing', async () => { + const getMe = vi + .fn() + .mockRejectedValueOnce(new Error('temporary getMe failure')) + .mockResolvedValue({ id: 9, username: 'happier_bot' }); + const getUpdates = vi + .fn() + .mockResolvedValueOnce([ + { + update_id: 11, + message: { + message_id: 111, + text: 'hello', + chat: { id: -100555, type: 'private' }, + from: { id: 42, is_bot: false, first_name: 'Ada' }, + }, + }, + ]) + .mockResolvedValueOnce([ + { + update_id: 11, + message: { + message_id: 111, + text: 'hello', + chat: { id: -100555, type: 'private' }, + from: { id: 42, is_bot: false, first_name: 'Ada' }, + }, + }, + ]) + .mockResolvedValueOnce([]); + + const api = { + getMe, + getUpdates, + sendMessage: vi.fn(async () => undefined), + }; + + const adapter = createTelegramChannelAdapter({ + botToken: 'test-token', + api, + webhookMode: false, + }); + + await expect(adapter.pullInboundMessages()).rejects.toThrow('temporary getMe failure'); + expect(getUpdates).toHaveBeenNthCalledWith(1, { offset: null, limit: 100 }); + + const parsed = await adapter.pullInboundMessages(); + expect(parsed).toHaveLength(1); + expect(getUpdates).toHaveBeenNthCalledWith(2, { offset: null, limit: 100 }); + + await adapter.pullInboundMessages(); + expect(getUpdates).toHaveBeenNthCalledWith(3, { offset: 12, limit: 100 }); + }); +}); diff --git a/apps/cli/src/channels/telegram/telegramAdapter.ts b/apps/cli/src/channels/telegram/telegramAdapter.ts new file mode 100644 index 0000000000..09a258fe0e --- /dev/null +++ b/apps/cli/src/channels/telegram/telegramAdapter.ts @@ -0,0 +1,255 @@ +import axios from 'axios'; + +import type { ChannelBridgeAdapter, ChannelBridgeInboundMessage } from '@/channels/core/channelBridgeWorker'; + +type TelegramSelfUser = Readonly<{ id: number; username: string | null }>; + +type TelegramApiClient = Readonly<{ + getMe: () => Promise; + getUpdates: (params: Readonly<{ offset: number | null; limit: number }>) => Promise; + sendMessage: (params: Readonly<{ chatId: string; threadId: string | null; text: string }>) => Promise; +}>; + +const TELEGRAM_GET_UPDATES_LONG_POLL_TIMEOUT_SECONDS = 25; +const TELEGRAM_GET_UPDATES_HTTP_TIMEOUT_MS = 30_000; + +function telegramApiUrl(botToken: string, method: string): string { + return `https://api.telegram.org/bot${botToken}/${method}`; +} + +function createDefaultTelegramApiClient(botToken: string): TelegramApiClient { + return { + getMe: async () => { + const response = await axios.post(telegramApiUrl(botToken, 'getMe'), {}, { + timeout: 10_000, + validateStatus: () => true, + }); + if (response.status !== 200 || !response.data || response.data.ok !== true || !response.data.result) { + throw new Error(`Telegram getMe failed (${response.status})`); + } + const user = response.data.result; + return { + id: Number(user.id), + username: typeof user.username === 'string' ? user.username : null, + }; + }, + getUpdates: async ({ offset, limit }) => { + const response = await axios.post(telegramApiUrl(botToken, 'getUpdates'), { + ...(typeof offset === 'number' ? { offset } : {}), + limit, + timeout: TELEGRAM_GET_UPDATES_LONG_POLL_TIMEOUT_SECONDS, + allowed_updates: ['message'], + }, { + timeout: TELEGRAM_GET_UPDATES_HTTP_TIMEOUT_MS, + validateStatus: () => true, + }); + if (response.status !== 200 || !response.data || response.data.ok !== true || !Array.isArray(response.data.result)) { + throw new Error(`Telegram getUpdates failed (${response.status})`); + } + return response.data.result; + }, + sendMessage: async ({ chatId, threadId, text }) => { + const parsedThreadId = Number.parseInt(typeof threadId === 'string' ? threadId.trim() : '', 10); + const messageThreadId = Number.isSafeInteger(parsedThreadId) && parsedThreadId > 0 + ? parsedThreadId + : null; + const response = await axios.post(telegramApiUrl(botToken, 'sendMessage'), { + chat_id: chatId, + text, + ...(messageThreadId !== null ? { message_thread_id: messageThreadId } : {}), + }, { + timeout: 10_000, + validateStatus: () => true, + }); + if (response.status !== 200 || !response.data || response.data.ok !== true) { + throw new Error(`Telegram sendMessage failed (${response.status})`); + } + }, + }; +} + +function asRecord(value: unknown): Record | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + return value as Record; +} + +function parseInboundFromUpdate(params: Readonly<{ + update: unknown; + selfBotId: number | null; + allowedChatIds: ReadonlySet | null; + requireTopics: boolean; +}>): ChannelBridgeInboundMessage | null { + const update = asRecord(params.update); + if (!update) return null; + const rawMessage = asRecord(update.message); + if (!rawMessage) return null; + + const text = typeof rawMessage.text === 'string' ? rawMessage.text.trim() : ''; + if (!text) return null; + + const chat = asRecord(rawMessage.chat); + if (!chat) return null; + const rawChatId = chat.id; + const conversationId = + typeof rawChatId === 'number' && Number.isFinite(rawChatId) + ? String(Math.trunc(rawChatId)) + : typeof rawChatId === 'string' + ? rawChatId.trim() + : ''; + if (!conversationId) return null; + + if (params.allowedChatIds && !params.allowedChatIds.has(conversationId)) { + return null; + } + + const threadId = + typeof rawMessage.message_thread_id === 'number' && Number.isFinite(rawMessage.message_thread_id) + ? String(Math.trunc(rawMessage.message_thread_id)) + : null; + + if (params.requireTopics) { + const chatType = typeof chat.type === 'string' ? chat.type : ''; + if (chatType === 'supergroup' && threadId === null) { + return null; + } + } + + const sender = asRecord(rawMessage.from); + const senderId = + sender && typeof sender.id === 'number' && Number.isFinite(sender.id) ? Math.trunc(sender.id) : null; + const senderIsBot = sender?.is_bot === true; + if (senderIsBot && senderId !== null && params.selfBotId !== null && senderId === params.selfBotId) { + return null; + } + + const messageId = + typeof rawMessage.message_id === 'number' && Number.isFinite(rawMessage.message_id) + ? String(Math.trunc(rawMessage.message_id)) + : ''; + if (!messageId) return null; + + return { + providerId: 'telegram', + conversationId, + threadId, + text, + messageId, + }; +} + +function parseHighestUpdateOffset(updates: readonly unknown[]): number | null { + let max: number | null = null; + for (const item of updates) { + const record = asRecord(item); + if (!record) continue; + const rawUpdateId = record.update_id; + if (typeof rawUpdateId !== 'number' || !Number.isFinite(rawUpdateId)) continue; + const current = Math.trunc(rawUpdateId); + if (max === null || current > max) max = current; + } + return max; +} + +export function createTelegramChannelAdapter(params: Readonly<{ + botToken: string; + api?: TelegramApiClient; + webhookMode?: boolean; + updateLimit?: number; + allowedChatIds?: ReadonlySet | null; + requireTopics?: boolean; +}>): ChannelBridgeAdapter & Readonly<{ enqueueWebhookUpdate: (update: unknown) => void }> { + const api = params.api ?? createDefaultTelegramApiClient(params.botToken); + const webhookMode = params.webhookMode === true; + const updateLimit = + typeof params.updateLimit === 'number' && Number.isFinite(params.updateLimit) + ? Math.max(1, Math.min(100, Math.trunc(params.updateLimit))) + : 100; + const allowedChatIds = params.allowedChatIds ?? null; + const requireTopics = params.requireTopics === true; + const MAX_WEBHOOK_QUEUE_SIZE = 2_000; + + type QueuedWebhookUpdate = { + id: number; + update: unknown; + }; + + let selfBotId: number | null = null; + /** + * Telegram polling cursor (`getUpdates` offset). + * + * This cursor is intentionally process-local today and is not persisted across + * daemon restarts. Polling mode therefore provides at-least-once delivery: + * after restart, Telegram may replay unacknowledged updates from the retention + * window, and downstream dedupe is expected to absorb duplicates. + */ + let updateOffset: number | null = null; + const queuedWebhookUpdates: QueuedWebhookUpdate[] = []; + let nextQueuedWebhookId = 1; + + async function ensureSelfIdentity(): Promise { + if (selfBotId !== null) return; + const self = await api.getMe(); + selfBotId = Number.isFinite(self.id) ? Math.trunc(self.id) : null; + } + + async function parseUpdates(updates: readonly unknown[]): Promise { + await ensureSelfIdentity(); + const out: ChannelBridgeInboundMessage[] = []; + for (const update of updates) { + const parsed = parseInboundFromUpdate({ + update, + selfBotId, + allowedChatIds, + requireTopics, + }); + if (parsed) out.push(parsed); + } + return out; + } + + return { + providerId: 'telegram', + enqueueWebhookUpdate: (update: unknown) => { + if (queuedWebhookUpdates.length >= MAX_WEBHOOK_QUEUE_SIZE) { + queuedWebhookUpdates.shift(); + } + queuedWebhookUpdates.push({ + id: nextQueuedWebhookId, + update, + }); + nextQueuedWebhookId += 1; + }, + pullInboundMessages: async () => { + if (webhookMode) { + const snapshot = queuedWebhookUpdates.slice(); + const parsed = await parseUpdates(snapshot.map((row) => row.update)); + const consumedIds = new Set(snapshot.map((row) => row.id)); + for (let index = queuedWebhookUpdates.length - 1; index >= 0; index -= 1) { + if (consumedIds.has(queuedWebhookUpdates[index]!.id)) { + queuedWebhookUpdates.splice(index, 1); + } + } + return parsed; + } + + const updates = await api.getUpdates({ + offset: updateOffset, + limit: updateLimit, + }); + + const parsed = await parseUpdates(updates); + const maxUpdateId = parseHighestUpdateOffset(updates); + if (maxUpdateId !== null) { + updateOffset = maxUpdateId + 1; + } + return parsed; + }, + sendMessage: async (message) => { + await api.sendMessage({ + chatId: message.conversationId, + threadId: message.threadId, + text: message.text, + }); + }, + }; +} diff --git a/apps/cli/src/channels/telegram/telegramWebhookRelay.test.ts b/apps/cli/src/channels/telegram/telegramWebhookRelay.test.ts new file mode 100644 index 0000000000..8f0049d849 --- /dev/null +++ b/apps/cli/src/channels/telegram/telegramWebhookRelay.test.ts @@ -0,0 +1,122 @@ +import axios from 'axios'; +import { describe, expect, it } from 'vitest'; + +import { startTelegramWebhookRelay } from './telegramWebhookRelay'; + +describe('startTelegramWebhookRelay', () => { + it('rejects webhook secret path tokens outside Telegram-safe charset', async () => { + await expect(startTelegramWebhookRelay({ + port: 0, + host: '127.0.0.1', + secretPathToken: 'bad$token', + onUpdate: () => { + throw new Error('should not be called'); + }, + })).rejects.toThrow('Webhook secret token must match [A-Za-z0-9_-]'); + }); + + it('accepts webhook updates on the configured secret path', async () => { + const received: unknown[] = []; + + const relay = await startTelegramWebhookRelay({ + port: 0, + host: '127.0.0.1', + secretPathToken: 'secret-123', + onUpdate: (update) => { + received.push(update); + }, + }); + + try { + const response = await axios.post(`http://127.0.0.1:${relay.port}${relay.path}`, { + update_id: 42, + message: { text: 'hello' }, + }, { + headers: { + 'X-Telegram-Bot-Api-Secret-Token': 'secret-123', + }, + }); + + expect(response.status).toBe(200); + expect(received).toEqual([ + { + update_id: 42, + message: { text: 'hello' }, + }, + ]); + } finally { + await relay.stop(); + } + }); + + it('rejects requests when header secret token is missing or invalid', async () => { + const received: unknown[] = []; + + const relay = await startTelegramWebhookRelay({ + port: 0, + host: '127.0.0.1', + secretPathToken: 'secret-abc', + secretHeaderToken: 'header-token-abc', + onUpdate: (update) => { + received.push(update); + }, + }); + + try { + const missingHeaderResponse = await axios.post(`http://127.0.0.1:${relay.port}${relay.path}`, { + update_id: 101, + message: { text: 'hello' }, + }, { + validateStatus: () => true, + }); + + const invalidHeaderResponse = await axios.post(`http://127.0.0.1:${relay.port}${relay.path}`, { + update_id: 102, + message: { text: 'hello again' }, + }, { + headers: { + 'X-Telegram-Bot-Api-Secret-Token': 'wrong-token', + }, + validateStatus: () => true, + }); + + expect(missingHeaderResponse.status).toBe(401); + expect(invalidHeaderResponse.status).toBe(401); + expect(received).toEqual([]); + } finally { + await relay.stop(); + } + }); + + it('does not acknowledge webhook updates when onUpdate fails', async () => { + const relay = await startTelegramWebhookRelay({ + port: 0, + host: '127.0.0.1', + secretPathToken: 'secret-fail', + onUpdate: async () => { + throw new Error('failed to process update'); + }, + }); + + try { + const response = await axios.post( + `http://127.0.0.1:${relay.port}${relay.path}`, + { + update_id: 202, + message: { text: 'hello' }, + }, + { + headers: { + 'X-Telegram-Bot-Api-Secret-Token': 'secret-fail', + }, + validateStatus: () => true, + }, + ); + + expect(response.status).toBeGreaterThanOrEqual(500); + expect(response.status).toBeLessThan(600); + } finally { + await relay.stop(); + } + }); +}); diff --git a/apps/cli/src/channels/telegram/telegramWebhookRelay.ts b/apps/cli/src/channels/telegram/telegramWebhookRelay.ts new file mode 100644 index 0000000000..37f1cc4588 --- /dev/null +++ b/apps/cli/src/channels/telegram/telegramWebhookRelay.ts @@ -0,0 +1,73 @@ +import { timingSafeEqual } from 'node:crypto'; + +import fastify from 'fastify'; + +function secureCompareToken(providedToken: string, expectedToken: string): boolean { + const providedBytes = Buffer.from(providedToken, 'utf8'); + const expectedBytes = Buffer.from(expectedToken, 'utf8'); + if (providedBytes.length !== expectedBytes.length) return false; + return timingSafeEqual(providedBytes, expectedBytes); +} + +export type TelegramWebhookRelayHandle = Readonly<{ + port: number; + path: string; + stop: () => Promise; +}>; + +export async function startTelegramWebhookRelay(params: Readonly<{ + port: number; + host?: string; + secretPathToken: string; + secretHeaderToken?: string; + onUpdate: (update: unknown) => void | Promise; +}>): Promise { + const secretPathToken = String(params.secretPathToken ?? '').trim(); + if (!secretPathToken) { + throw new Error('Webhook secret token is required'); + } + if (!/^[A-Za-z0-9_-]+$/.test(secretPathToken)) { + throw new Error('Webhook secret token must match [A-Za-z0-9_-]'); + } + + const secretHeaderToken = String(params.secretHeaderToken ?? secretPathToken).trim(); + if (!secretHeaderToken) { + throw new Error('Webhook header secret token is required'); + } + + const host = String(params.host ?? '127.0.0.1').trim() || '127.0.0.1'; + const requestedPort = Number.isFinite(params.port) ? Math.trunc(params.port) : 0; + if (requestedPort < 0 || requestedPort > 65_535) { + throw new Error('Webhook port must be between 0 and 65535'); + } + const path = `/telegram/webhook/${secretPathToken}`; + + const app = fastify({ logger: false }); + app.post(path, async (request, reply) => { + const providedHeader = request.headers['x-telegram-bot-api-secret-token']; + const providedToken = + typeof providedHeader === 'string' + ? providedHeader + : Array.isArray(providedHeader) && providedHeader.length > 0 + ? providedHeader[0] ?? '' + : ''; + if (!secureCompareToken(providedToken.trim(), secretHeaderToken)) { + return reply.status(401).send({ ok: false, error: 'Unauthorized' }); + } + + await params.onUpdate(request.body); + return reply.send({ ok: true }); + }); + + await app.listen({ port: requestedPort, host }); + const address = app.server.address(); + const boundPort = typeof address === 'object' && address ? address.port : requestedPort; + + return { + port: boundPort, + path, + stop: async () => { + await app.close(); + }, + }; +} diff --git a/apps/cli/src/cli/commandRegistry.ts b/apps/cli/src/cli/commandRegistry.ts index 7039d3f4a0..90977eeff3 100644 --- a/apps/cli/src/cli/commandRegistry.ts +++ b/apps/cli/src/cli/commandRegistry.ts @@ -4,6 +4,7 @@ import { AGENTS, type AgentCatalogEntry } from '@/backends/catalog'; import { handleAttachCliCommand } from './commands/attach'; import { handleAuthCliCommand } from './commands/auth'; +import { handleBridgeCliCommand } from './commands/bridge'; import { handleBugReportCliCommand } from './commands/bugReport'; import { handleConnectCliCommand } from './commands/connect'; import { handleDaemonCliCommand } from './commands/daemon'; @@ -47,6 +48,7 @@ export const commandRegistry: Readonly> = { attach: handleAttachCliCommand, 'acp-catalog': handleConfiguredAcpCatalogCliCommand, auth: handleAuthCliCommand, + bridge: handleBridgeCliCommand, 'bug-report': handleBugReportCliCommand, connect: handleConnectCliCommand, daemon: handleDaemonCliCommand, diff --git a/apps/cli/src/cli/commandSurfaceManifest.ts b/apps/cli/src/cli/commandSurfaceManifest.ts index 1bfa131079..ba12988cc3 100644 --- a/apps/cli/src/cli/commandSurfaceManifest.ts +++ b/apps/cli/src/cli/commandSurfaceManifest.ts @@ -43,6 +43,12 @@ const COMMAND_SURFACE_MANIFEST: readonly CliCommandSurfaceEntry[] = [ rootHelpDescription: 'Connect AI vendor API keys', allowTmux: false, }, + { + command: 'bridge', + rootHelpLabel: 'happier bridge', + rootHelpDescription: 'Manage channel bridges', + allowTmux: false, + }, { command: 'notify', rootHelpLabel: 'happier notify', diff --git a/apps/cli/src/cli/commands/bridge.test.ts b/apps/cli/src/cli/commands/bridge.test.ts new file mode 100644 index 0000000000..7d7466c0db --- /dev/null +++ b/apps/cli/src/cli/commands/bridge.test.ts @@ -0,0 +1,162 @@ +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { configuration, reloadConfiguration } from '@/configuration'; + +const readCredentialsMock = vi.fn(); +const readSettingsMock = vi.fn(); +const updateSettingsMock = vi.fn(); +const decodeJwtPayloadMock = vi.fn(); +const checkDaemonMock = vi.fn(); + +const createKvClientMock = vi.fn(); +const upsertKvConfigMock = vi.fn(); +const clearKvConfigMock = vi.fn(); + +vi.mock('@/persistence', () => ({ + readCredentials: readCredentialsMock, + readSettings: readSettingsMock, + updateSettings: updateSettingsMock, +})); + +vi.mock('@/cloud/decodeJwtPayload', () => ({ + decodeJwtPayload: decodeJwtPayloadMock, +})); + +vi.mock('@/daemon/controlClient', () => ({ + checkIfDaemonRunningAndCleanupStaleState: checkDaemonMock, +})); + +vi.mock('@/channels/channelBridgeServerKv', () => ({ + createAxiosChannelBridgeKvClient: createKvClientMock, + upsertChannelBridgeTelegramConfigInKv: upsertKvConfigMock, + clearChannelBridgeTelegramConfigInKv: clearKvConfigMock, + readChannelBridgeTelegramConfigFromKv: vi.fn(), +})); + +describe('happier bridge command', () => { + let homeDir: string; + const prevHomeDir = process.env.HAPPIER_HOME_DIR; + const prevServerUrl = process.env.HAPPIER_SERVER_URL; + const prevWebappUrl = process.env.HAPPIER_WEBAPP_URL; + + beforeEach(async () => { + vi.resetModules(); + vi.clearAllMocks(); + + readCredentialsMock.mockResolvedValue({ token: 'token.jwt' }); + decodeJwtPayloadMock.mockReturnValue({ sub: 'acct_123' }); + readSettingsMock.mockResolvedValue({}); + checkDaemonMock.mockResolvedValue(false); + + updateSettingsMock.mockImplementation(async (updater: (current: unknown) => Promise | unknown) => { + await updater({}); + }); + + createKvClientMock.mockReturnValue({ get: vi.fn(), mutate: vi.fn() }); + upsertKvConfigMock.mockResolvedValue(undefined); + clearKvConfigMock.mockResolvedValue(undefined); + + homeDir = await mkdtemp(join(tmpdir(), 'happier-bridge-cmd-')); + process.env.HAPPIER_HOME_DIR = homeDir; + process.env.HAPPIER_SERVER_URL = 'http://127.0.0.1:3005'; + process.env.HAPPIER_WEBAPP_URL = 'http://127.0.0.1:3006'; + reloadConfiguration(); + }); + + afterEach(async () => { + process.exitCode = undefined; + if (prevHomeDir === undefined) delete process.env.HAPPIER_HOME_DIR; + else process.env.HAPPIER_HOME_DIR = prevHomeDir; + if (prevServerUrl === undefined) delete process.env.HAPPIER_SERVER_URL; + else process.env.HAPPIER_SERVER_URL = prevServerUrl; + if (prevWebappUrl === undefined) delete process.env.HAPPIER_WEBAPP_URL; + else process.env.HAPPIER_WEBAPP_URL = prevWebappUrl; + reloadConfiguration(); + await rm(homeDir, { recursive: true, force: true }); + }); + + it('writes telegram non-secret set updates to server KV and local settings', async () => { + const { handleBridgeCliCommand } = await import('./bridge'); + + await handleBridgeCliCommand({ + args: ['bridge', 'telegram', 'set', '--bot-token', 'bot-token-1', '--allow-all', '--require-topics', 'true'], + rawArgv: [], + terminalRuntime: null, + }); + + expect(createKvClientMock).toHaveBeenCalledWith({ token: 'token.jwt' }); + expect(upsertKvConfigMock).toHaveBeenCalledWith({ + kv: expect.any(Object), + serverId: configuration.activeServerId, + update: { + allowedChatIds: [], + requireTopics: true, + }, + }); + expect(updateSettingsMock).toHaveBeenCalledTimes(1); + expect(updateSettingsMock.mock.invocationCallOrder[0]).toBeLessThan(upsertKvConfigMock.mock.invocationCallOrder[0]); + expect(process.exitCode).toBeUndefined(); + }); + + it('does not write secrets-only updates to server KV', async () => { + const { handleBridgeCliCommand } = await import('./bridge'); + + await handleBridgeCliCommand({ + args: ['bridge', 'telegram', 'set', '--bot-token', 'bot-token-1'], + rawArgv: [], + terminalRuntime: null, + }); + + expect(createKvClientMock).not.toHaveBeenCalled(); + expect(upsertKvConfigMock).not.toHaveBeenCalled(); + expect(updateSettingsMock).toHaveBeenCalledTimes(1); + expect(process.exitCode).toBeUndefined(); + }); + + it('clears telegram config in server KV and local settings', async () => { + const { handleBridgeCliCommand } = await import('./bridge'); + + await handleBridgeCliCommand({ + args: ['bridge', 'telegram', 'clear'], + rawArgv: [], + terminalRuntime: null, + }); + + expect(createKvClientMock).toHaveBeenCalledWith({ token: 'token.jwt' }); + expect(clearKvConfigMock).toHaveBeenCalledWith({ + kv: expect.any(Object), + serverId: configuration.activeServerId, + }); + expect(updateSettingsMock).toHaveBeenCalledTimes(1); + expect(updateSettingsMock.mock.invocationCallOrder[0]).toBeLessThan(clearKvConfigMock.mock.invocationCallOrder[0]); + expect(process.exitCode).toBeUndefined(); + }); + + it('rejects webhook secrets that do not match Telegram-safe token charset', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + try { + const { handleBridgeCliCommand } = await import('./bridge'); + + await handleBridgeCliCommand({ + args: ['bridge', 'telegram', 'set', '--webhook-secret', 'bad$secret'], + rawArgv: [], + terminalRuntime: null, + }); + + expect(updateSettingsMock).not.toHaveBeenCalled(); + expect(createKvClientMock).not.toHaveBeenCalled(); + expect(upsertKvConfigMock).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + expect(errorSpy).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining('Invalid --webhook-secret value'), + ); + } finally { + errorSpy.mockRestore(); + } + }); +}); diff --git a/apps/cli/src/cli/commands/bridge.ts b/apps/cli/src/cli/commands/bridge.ts new file mode 100644 index 0000000000..a1a3bf4f2c --- /dev/null +++ b/apps/cli/src/cli/commands/bridge.ts @@ -0,0 +1,358 @@ +import chalk from 'chalk'; + +import type { CommandContext } from '@/cli/commandRegistry'; +import { configuration } from '@/configuration'; +import { decodeJwtPayload } from '@/cloud/decodeJwtPayload'; +import { checkIfDaemonRunningAndCleanupStaleState } from '@/daemon/controlClient'; +import { + hasSharedTelegramBridgeUpdate, + readScopedTelegramBridgeConfig, + removeScopedTelegramBridgeConfig, + splitScopedTelegramBridgeUpdate, + upsertScopedTelegramBridgeConfig, +} from '@/channels/channelBridgeAccountConfig'; +import { resolveChannelBridgeRuntimeConfig } from '@/channels/channelBridgeConfig'; +import { + clearChannelBridgeTelegramConfigInKv, + createAxiosChannelBridgeKvClient, + readChannelBridgeTelegramConfigFromKv, + upsertChannelBridgeTelegramConfigInKv, +} from '@/channels/channelBridgeServerKv'; +import { overlayServerKvTelegramConfigInSettings } from '@/channels/channelBridgeServerConfigOverlay'; +import { readCredentials, readSettings, updateSettings } from '@/persistence'; +import { argvValue } from '@/cli/commands/server/commandUtilities'; + +function parseBooleanInput(raw: string, flagName: string): boolean { + const value = raw.trim().toLowerCase(); + if (['1', 'true', 'yes', 'on'].includes(value)) return true; + if (['0', 'false', 'no', 'off'].includes(value)) return false; + throw new Error(`Invalid ${flagName} value: ${raw}`); +} + +function parseIntegerInput(raw: string, flagName: string, min: number, max: number): number { + const trimmed = raw.trim(); + if (!/^[-]?\d+$/.test(trimmed)) { + throw new Error(`Invalid ${flagName} value: ${raw}`); + } + const parsed = Number.parseInt(trimmed, 10); + if (!Number.isFinite(parsed) || parsed < min || parsed > max) { + throw new Error(`Invalid ${flagName} value: ${raw}`); + } + return Math.trunc(parsed); +} + +function parseCsvList(raw: string): string[] { + return raw + .split(',') + .map((value) => value.trim()) + .filter((value) => value.length > 0); +} + +function validateTelegramWebhookSecretToken(raw: string, flagName: string): void { + if (!/^[A-Za-z0-9_-]+$/.test(raw)) { + throw new Error(`Invalid ${flagName} value: must match [A-Za-z0-9_-] (Telegram webhook token restriction)`); + } +} + +function maskSecret(value: string): string { + if (!value.trim()) return ''; + return `<${value.length} chars>`; +} + +async function resolveActiveAuthContext(): Promise> { + const credentials = await readCredentials(); + if (!credentials) { + throw new Error('Not authenticated. Run: happier auth login'); + } + const payload = decodeJwtPayload(credentials.token); + const accountId = payload && typeof payload.sub === 'string' ? payload.sub.trim() : ''; + if (!accountId) { + throw new Error('Unable to resolve account id from credentials token'); + } + return { + accountId, + token: credentials.token, + }; +} + +function showBridgeHelp(): void { + console.log(` +${chalk.bold('happier bridge')} - Channel bridge configuration (account-scoped) + +${chalk.bold('Usage:')} + happier bridge list + happier bridge telegram set --bot-token [--allowed-chat-ids |--allow-all] [--require-topics ] [--tick-ms ] [--webhook-enabled ] [--webhook-secret ] [--webhook-host ] [--webhook-port ] + happier bridge telegram clear + +${chalk.bold('Notes:')} + - Scope is the active server + authenticated account. + - Secrets are local-only (settings/env), not synced to server KV. + - Non-secret bridge config is synced to server KV + scoped settings.json. + - Restart daemon to apply: happier daemon stop && happier daemon start +`); +} + +async function cmdList(): Promise { + const serverId = String(configuration.activeServerId ?? '').trim(); + if (!serverId) { + throw new Error('Unable to resolve active server id'); + } + const auth = await resolveActiveAuthContext(); + const accountId = auth.accountId; + const settings = await readSettings(); + + let serverKvRecord: Awaited>['record'] | null = null; + let serverKvError: string | null = null; + try { + const kv = createAxiosChannelBridgeKvClient({ token: auth.token }); + const fetched = await readChannelBridgeTelegramConfigFromKv({ + kv, + serverId, + allowUnsupportedSchema: true, + }); + serverKvRecord = fetched.record; + } catch (error) { + serverKvError = error instanceof Error ? error.message : String(error); + } + + const scopedTelegram = readScopedTelegramBridgeConfig({ + settings, + serverId, + accountId, + }); + + const runtimeSettings = overlayServerKvTelegramConfigInSettings({ + settings, + serverId, + accountId, + record: serverKvRecord, + }); + + const effective = resolveChannelBridgeRuntimeConfig({ + env: process.env, + settings: runtimeSettings, + serverId, + accountId, + }); + + const daemonRunning = await checkIfDaemonRunningAndCleanupStaleState(); + + console.log(chalk.bold('Bridge scope')); + console.log(` Server: ${serverId}`); + console.log(` Account: ${accountId}`); + console.log(` Daemon: ${daemonRunning ? 'running' : 'stopped'}`); + + console.log(chalk.bold('\nTelegram (scoped settings.json)')); + if (!scopedTelegram) { + console.log(' configured: no'); + } else { + const scopedToken = typeof scopedTelegram.botToken === 'string' ? scopedTelegram.botToken : ''; + const scopedAllowed = Array.isArray(scopedTelegram.allowedChatIds) ? scopedTelegram.allowedChatIds : []; + const scopedRequireTopics = scopedTelegram.requireTopics === true; + console.log(' configured: yes'); + console.log(` botToken: ${maskSecret(scopedToken)}`); + console.log(` allowedChatIds: ${scopedAllowed.length > 0 ? scopedAllowed.join(', ') : '(allow all)'}`); + console.log(` requireTopics: ${scopedRequireTopics ? 'true' : 'false'}`); + } + + console.log(chalk.bold('\nTelegram (server KV)')); + if (serverKvError) { + console.log(` unavailable: ${serverKvError}`); + } else if (!serverKvRecord) { + console.log(' configured: no'); + } else { + const remoteAllowed = Array.isArray(serverKvRecord.telegram.allowedChatIds) ? serverKvRecord.telegram.allowedChatIds : []; + const remoteRequireTopics = serverKvRecord.telegram.requireTopics === true; + const remoteWebhookEnabled = serverKvRecord.telegram.webhook?.enabled === true; + const remoteWebhookHost = + typeof serverKvRecord.telegram.webhook?.host === 'string' ? serverKvRecord.telegram.webhook.host : '(default)'; + const remoteWebhookPort = + typeof serverKvRecord.telegram.webhook?.port === 'number' ? String(serverKvRecord.telegram.webhook.port) : '(default)'; + + console.log(' configured: yes'); + console.log(' botToken: (not stored in server KV)'); + console.log(` allowedChatIds: ${remoteAllowed.length > 0 ? remoteAllowed.join(', ') : '(allow all)'}`); + console.log(` requireTopics: ${remoteRequireTopics ? 'true' : 'false'}`); + console.log(` webhook.enabled: ${remoteWebhookEnabled ? 'true' : 'false'}`); + console.log(` webhook.host: ${remoteWebhookHost}`); + console.log(` webhook.port: ${remoteWebhookPort}`); + } + + console.log(chalk.bold('\nTelegram (effective runtime: env > server KV > settings.json)')); + console.log(` botToken: ${maskSecret(effective.telegram.botToken)}`); + console.log( + ` allowedChatIds: ${effective.telegram.allowedChatIds.length > 0 ? effective.telegram.allowedChatIds.join(', ') : '(allow all)'}`, + ); + console.log(` requireTopics: ${effective.telegram.requireTopics ? 'true' : 'false'}`); + console.log(` webhook.enabled: ${effective.telegram.webhookEnabled ? 'true' : 'false'}`); + console.log(` webhook.host: ${effective.telegram.webhookHost}`); + console.log(` webhook.port: ${effective.telegram.webhookPort}`); +} + +async function cmdTelegramSet(args: string[]): Promise { + const serverId = String(configuration.activeServerId ?? '').trim(); + if (!serverId) { + throw new Error('Unable to resolve active server id'); + } + const auth = await resolveActiveAuthContext(); + const accountId = auth.accountId; + + const botToken = argvValue(args, '--bot-token').trim(); + const allowedChatIdsRaw = argvValue(args, '--allowed-chat-ids').trim(); + const allowAll = args.includes('--allow-all'); + const requireTopicsRaw = argvValue(args, '--require-topics').trim(); + const tickMsRaw = argvValue(args, '--tick-ms').trim(); + const webhookEnabledRaw = argvValue(args, '--webhook-enabled').trim(); + const webhookSecret = argvValue(args, '--webhook-secret').trim(); + const webhookHost = argvValue(args, '--webhook-host').trim(); + const webhookPortRaw = argvValue(args, '--webhook-port').trim(); + + if (allowAll && allowedChatIdsRaw) { + throw new Error('Cannot combine --allow-all with --allowed-chat-ids'); + } + + const update: { + tickMs?: number; + botToken?: string; + allowedChatIds?: string[]; + requireTopics?: boolean; + webhookEnabled?: boolean; + webhookSecret?: string; + webhookHost?: string; + webhookPort?: number; + } = {}; + + if (botToken) update.botToken = botToken; + if (allowAll) { + update.allowedChatIds = []; + } else if (allowedChatIdsRaw) { + update.allowedChatIds = parseCsvList(allowedChatIdsRaw); + } + if (requireTopicsRaw) { + update.requireTopics = parseBooleanInput(requireTopicsRaw, '--require-topics'); + } + if (tickMsRaw) { + update.tickMs = parseIntegerInput(tickMsRaw, '--tick-ms', 250, 60_000); + } + if (webhookEnabledRaw) { + update.webhookEnabled = parseBooleanInput(webhookEnabledRaw, '--webhook-enabled'); + } + if (webhookSecret) { + validateTelegramWebhookSecretToken(webhookSecret, '--webhook-secret'); + update.webhookSecret = webhookSecret; + } + if (webhookHost) { + update.webhookHost = webhookHost; + } + if (webhookPortRaw) { + update.webhookPort = parseIntegerInput(webhookPortRaw, '--webhook-port', 1, 65_535); + } + + if (Object.keys(update).length === 0) { + throw new Error( + 'No updates provided. Use flags like --bot-token, --allowed-chat-ids, --allow-all, --require-topics, --tick-ms, --webhook-enabled, --webhook-secret, --webhook-host, --webhook-port', + ); + } + + const split = splitScopedTelegramBridgeUpdate({ update }); + + await updateSettings(async (current) => + upsertScopedTelegramBridgeConfig({ + settings: current, + serverId, + accountId, + update: split.localUpdate, + }), + ); + + if (hasSharedTelegramBridgeUpdate({ update: split.sharedUpdate })) { + const kv = createAxiosChannelBridgeKvClient({ token: auth.token }); + await upsertChannelBridgeTelegramConfigInKv({ + kv, + serverId, + update: split.sharedUpdate, + }); + } + + console.log(chalk.green('✓ Saved Telegram bridge config for active account scope')); + console.log(` Server: ${serverId}`); + console.log(` Account: ${accountId}`); + console.log( + hasSharedTelegramBridgeUpdate({ update: split.sharedUpdate }) + ? ' Persisted: non-secret fields -> server KV, full config -> scoped settings.json' + : ' Persisted: secrets-only update -> scoped settings.json (server KV unchanged)', + ); + console.log(' Restart daemon to apply changes:'); + console.log(chalk.cyan(' happier daemon stop && happier daemon start')); +} + +async function cmdTelegramClear(): Promise { + const serverId = String(configuration.activeServerId ?? '').trim(); + if (!serverId) { + throw new Error('Unable to resolve active server id'); + } + const auth = await resolveActiveAuthContext(); + const accountId = auth.accountId; + + await updateSettings(async (current) => + removeScopedTelegramBridgeConfig({ + settings: current, + serverId, + accountId, + }), + ); + + const kv = createAxiosChannelBridgeKvClient({ token: auth.token }); + await clearChannelBridgeTelegramConfigInKv({ + kv, + serverId, + }); + + console.log(chalk.green('✓ Cleared Telegram bridge config for active account scope')); + console.log(` Server: ${serverId}`); + console.log(` Account: ${accountId}`); + console.log(' Cleared: server KV + scoped settings.json'); + console.log(' Restart daemon to apply changes:'); + console.log(chalk.cyan(' happier daemon stop && happier daemon start')); +} + +async function cmdTelegram(args: string[]): Promise { + const sub = String(args[0] ?? '').trim(); + if (!sub || sub === 'help' || sub === '--help' || sub === '-h') { + showBridgeHelp(); + return; + } + if (sub === 'set') { + await cmdTelegramSet(args.slice(1)); + return; + } + if (sub === 'clear') { + await cmdTelegramClear(); + return; + } + throw new Error(`Unknown bridge telegram subcommand: ${sub}`); +} + +export async function handleBridgeCliCommand(context: CommandContext): Promise { + const args = context.args.slice(1); + const sub = String(args[0] ?? '').trim(); + + try { + if (!sub || sub === 'help' || sub === '--help' || sub === '-h') { + showBridgeHelp(); + return; + } + if (sub === 'list') { + await cmdList(); + return; + } + if (sub === 'telegram') { + await cmdTelegram(args.slice(1)); + return; + } + throw new Error(`Unknown bridge subcommand: ${sub}`); + } catch (error) { + console.error(chalk.red('Error:'), error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + } +} diff --git a/apps/cli/src/daemon/startDaemon.automation.integration.test.ts b/apps/cli/src/daemon/startDaemon.automation.integration.test.ts index 3a5876f4ea..1e2e965522 100644 --- a/apps/cli/src/daemon/startDaemon.automation.integration.test.ts +++ b/apps/cli/src/daemon/startDaemon.automation.integration.test.ts @@ -13,6 +13,7 @@ const harness = vi.hoisted(() => { const automationWorkerRefreshAssignments = vi.fn(async () => {}); const automationWorkerPause = vi.fn(); const automationWorkerResume = vi.fn(); + const startChannelBridgeFromEnv = vi.fn(async () => null); const startAutomationWorker = vi.fn(() => { if (autoShutdownAfterAutomationStart && requestShutdownRef) { setTimeout(() => requestShutdownRef?.('happier-cli'), 0); @@ -90,10 +91,15 @@ const harness = vi.hoisted(() => { setAutoShutdownAfterAutomationStart: (value: boolean) => { autoShutdownAfterAutomationStart = value; }, + startChannelBridgeFromEnv, requestShutdown: (source: ShutdownSource) => requestShutdownRef?.(source), }; }); +vi.mock('@/channels/startChannelBridgeWorker', () => ({ + startChannelBridgeFromEnv: harness.startChannelBridgeFromEnv, +})); + vi.mock('@/api/api', () => ({ ApiClient: { create: vi.fn(async () => ({ @@ -166,6 +172,7 @@ vi.mock('@/persistence', () => ({ writeDaemonState: vi.fn(), acquireDaemonLock: vi.fn(async () => harness.lockHandle), releaseDaemonLock: vi.fn(async () => {}), + readSettings: vi.fn(async () => ({})), readCredentials: vi.fn(async () => null), })); @@ -499,6 +506,7 @@ describe('startDaemon automation wiring (integration)', () => { }); const { logger } = await import('@/ui/logger'); + const { startChannelBridgeFromEnv } = await import('@/channels/startChannelBridgeWorker'); const { startDaemon } = await import('./startDaemon'); const run = startDaemon(); @@ -510,6 +518,7 @@ describe('startDaemon automation wiring (integration)', () => { const debugMock = (logger as any).debug as any; const serialized = JSON.stringify([...warnMock.mock.calls, ...debugMock.mock.calls]); expect(serialized).not.toContain(leakedBearer); + expect(startChannelBridgeFromEnv).toHaveBeenCalledTimes(1); } finally { exitSpy.mockRestore(); } diff --git a/apps/cli/src/daemon/startDaemon.ts b/apps/cli/src/daemon/startDaemon.ts index b0591ed408..5de9032e2f 100644 --- a/apps/cli/src/daemon/startDaemon.ts +++ b/apps/cli/src/daemon/startDaemon.ts @@ -23,12 +23,14 @@ import { getEnvironmentInfo } from '@/ui/doctor'; import { buildHappyCliSubprocessLaunchSpec, spawnHappyCLI } from '@/utils/spawnHappyCLI'; import { getVendorResumeSupport, requireCatalogEntry, resolveAgentCliSubcommand, resolveCatalogAgentId } from '@/backends/catalog'; import { CATALOG_AGENT_IDS } from '@/backends/types'; +import { decodeJwtPayload } from '@/cloud/decodeJwtPayload'; import { writeDaemonState, DaemonLocallyPersistedState, acquireDaemonLock, releaseDaemonLock, readCredentials, + readSettings, } from '@/persistence'; import { createSessionAttachFile } from './sessionAttachFile'; import { getDaemonShutdownExitCode, getDaemonShutdownWatchdogTimeoutMs } from './shutdownPolicy'; @@ -87,6 +89,9 @@ import { computeDaemonSpawnRequestKey, createSpawnRequestCoalescer } from './spa import { startAutomationWorker, type AutomationWorkerHandle } from './automation/automationWorker'; import { startMemoryWorker, type MemoryWorkerHandle } from './memory/memoryWorker'; import { createDaemonConnectivityCoordinator } from './connection/createDaemonConnectivityCoordinator'; +import { startChannelBridgeFromEnv, type ChannelBridgeRuntimeHandle } from '@/channels/startChannelBridgeWorker'; +import { createAxiosChannelBridgeKvClient, readChannelBridgeTelegramConfigFromKv } from '@/channels/channelBridgeServerKv'; +import { overlayServerKvTelegramConfigInSettings } from '@/channels/channelBridgeServerConfigOverlay'; import { resolveConnectedServiceAuthForSpawn } from './connectedServices/resolveConnectedServiceAuthForSpawn'; import { shouldResolveConnectedServiceAuthForSpawn } from './connectedServices/shouldResolveConnectedServiceAuthForSpawn'; import { ConnectedServiceRefreshCoordinator } from './connectedServices/refresh/ConnectedServiceRefreshCoordinator'; @@ -282,6 +287,7 @@ export async function startDaemon(): Promise { let apiMachineForSessions: ApiMachineClient | null = null; let automationWorker: AutomationWorkerHandle | null = null; let memoryWorker: MemoryWorkerHandle | null = null; + let channelBridgeWorker: ChannelBridgeRuntimeHandle | null = null; let apiMachine: ApiMachineClient | null = null; let machineConnectionStateCleanup: (() => void) | null = null; let shutdownInitiated = false; @@ -1521,6 +1527,58 @@ export async function startDaemon(): Promise { // Do machine bootstrap in the background so shutdown requests are not blocked by /v1/machines latency. void (async () => { + const bridgeSettings = await readSettings().catch((error) => { + logger.warn( + '[DAEMON RUN] Failed to read settings for channel bridge startup; using env-only defaults', + error instanceof Error ? error.message : String(error), + ); + return null; + }); + + const tokenPayload = decodeJwtPayload(credentials.token); + const channelBridgeAccountId = + tokenPayload && typeof tokenPayload.sub === 'string' + ? tokenPayload.sub.trim() + : null; + const channelBridgeServerId = (configuration.activeServerId ?? '').trim() || null; + + let channelBridgeRuntimeSettings: unknown = bridgeSettings; + if (channelBridgeServerId && channelBridgeAccountId) { + try { + const kv = createAxiosChannelBridgeKvClient({ token: credentials.token }); + const fetched = await readChannelBridgeTelegramConfigFromKv({ + kv, + serverId: channelBridgeServerId, + allowUnsupportedSchema: true, + }); + channelBridgeRuntimeSettings = overlayServerKvTelegramConfigInSettings({ + settings: bridgeSettings, + serverId: channelBridgeServerId, + accountId: channelBridgeAccountId, + record: fetched.record, + }); + } catch (error) { + logger.warn( + '[DAEMON RUN] Failed to read channel bridge config from server KV; using local/env configuration', + serializeAxiosErrorForLog(error), + ); + } + } + + if (!shutdownInitiated) { + channelBridgeWorker = await startChannelBridgeFromEnv({ + credentials, + ...(channelBridgeRuntimeSettings ? { settings: channelBridgeRuntimeSettings } : {}), + ...(channelBridgeServerId ? { serverId: channelBridgeServerId } : {}), + ...(channelBridgeAccountId ? { accountId: channelBridgeAccountId } : {}), + }).catch((error) => { + logger.warn( + '[DAEMON RUN] Failed to start channel bridge worker (best-effort)', + serializeAxiosErrorForLog(error), + ); + return null; + }); + } let attempts = 0; while (!shutdownInitiated) { try { @@ -1817,6 +1875,39 @@ export async function startDaemon(): Promise { if (memoryWorker) { memoryWorker.stop(); } + if (channelBridgeWorker) { + const channelBridgeStopTimeoutMs = resolvePositiveIntEnv( + process.env.HAPPIER_DAEMON_CHANNEL_BRIDGE_STOP_TIMEOUT_MS, + 5_000, + { min: 250, max: 60_000 }, + ); + let channelBridgeStopTimeoutHandle: NodeJS.Timeout | null = null; + try { + const stopResult = await Promise.race<'stopped' | 'timeout'>([ + channelBridgeWorker.stop().then(() => 'stopped' as const), + new Promise<'timeout'>((resolve) => { + channelBridgeStopTimeoutHandle = setTimeout(() => resolve('timeout'), channelBridgeStopTimeoutMs); + channelBridgeStopTimeoutHandle.unref?.(); + }), + ]); + + if (channelBridgeStopTimeoutHandle) { + clearTimeout(channelBridgeStopTimeoutHandle); + channelBridgeStopTimeoutHandle = null; + } + + if (stopResult === 'timeout') { + logger.warn( + `[DAEMON RUN] Channel bridge worker stop timed out after ${channelBridgeStopTimeoutMs}ms; continuing shutdown`, + ); + } + } catch (error) { + logger.warn( + '[DAEMON RUN] Failed to stop channel bridge worker during shutdown (best-effort)', + serializeAxiosErrorForLog(error), + ); + } + } // Best-effort cleanup for provider-managed background processes (e.g. shared OpenCode server). // Important: do not tear down shared provider background processes while session runners are still diff --git a/apps/cli/src/persistence.ts b/apps/cli/src/persistence.ts index 13f8842c8d..43a3885862 100644 --- a/apps/cli/src/persistence.ts +++ b/apps/cli/src/persistence.ts @@ -108,6 +108,11 @@ export interface Settings { * Parsed/normalized by `settings/memorySettings.ts`. */ memory?: unknown + + /** + * Channel bridge configuration (account/server scoped + optional global defaults). + */ + channelBridge?: unknown } const defaultSettings: Settings = { diff --git a/apps/cli/src/ui/doctor.test.ts b/apps/cli/src/ui/doctor.test.ts index 9a4ba471b2..1fa9a0cb06 100644 --- a/apps/cli/src/ui/doctor.test.ts +++ b/apps/cli/src/ui/doctor.test.ts @@ -1,5 +1,13 @@ import { describe, it, expect } from 'vitest'; -import { maskValue, redactDaemonStateForDisplay, shouldShowGlobalProcessInventory } from './doctor'; +import { + collectMissingRequiredWebhookFields, + resolveTelegramWebhookValidationInputs, + isMissingRequiredTelegramWebhookSecret, + maskValue, + redactSettingsForDisplay, + redactDaemonStateForDisplay, + shouldShowGlobalProcessInventory, +} from './doctor'; describe('doctor redaction', () => { it('does not treat ${VAR:-default} templates as safe', () => { @@ -55,6 +63,47 @@ describe('doctor redaction', () => { controlToken: '', }); }); + + it('redacts channel bridge secret fields from settings output', () => { + const input = { + channelBridge: { + byServerId: { + 'local-3005': { + byAccountId: { + acct1: { + providers: { + telegram: { + botToken: 'bot-token-123', + webhook: { + enabled: true, + host: '127.0.0.1', + port: 8787, + secret: 'legacy-webhook-secret', + }, + secrets: { + botToken: 'bot-token-123', + webhookSecret: 'webhook-secret-123', + extraSecret: 'extra-secret', + }, + }, + }, + }, + }, + }, + }, + }, + }; + + const redacted = redactSettingsForDisplay(input as never) as any; + const telegram = redacted.channelBridge.byServerId['local-3005'].byAccountId.acct1.providers.telegram; + expect(telegram.botToken).toBe(''); + expect(telegram.webhook.secret).toBe(''); + expect(telegram.secrets.botToken).toBe(''); + expect(telegram.secrets.webhookSecret).toBe(''); + expect(telegram.secrets.extraSecret).toBe(''); + expect(telegram.webhook.host).toBe('127.0.0.1'); + expect(telegram.webhook.port).toBe(8787); + }); }); describe('doctor process inventory visibility', () => { @@ -66,3 +115,77 @@ describe('doctor process inventory visibility', () => { expect(shouldShowGlobalProcessInventory('all')).toBe(true); }); }); + +describe('telegram webhook secret requirements', () => { + it('requires webhook secret only when webhook mode is enabled', () => { + expect(isMissingRequiredTelegramWebhookSecret({ webhookEnabled: true, webhookSecret: '' })).toBe(true); + expect(isMissingRequiredTelegramWebhookSecret({ webhookEnabled: true, webhookSecret: ' ' })).toBe(true); + expect(isMissingRequiredTelegramWebhookSecret({ webhookEnabled: true, webhookSecret: 'secret-123' })).toBe(false); + expect(isMissingRequiredTelegramWebhookSecret({ webhookEnabled: false, webhookSecret: '' })).toBe(false); + }); +}); + +describe('generic webhook field requirements', () => { + it('returns no issues when webhook mode is disabled', () => { + expect( + collectMissingRequiredWebhookFields({ + webhookEnabled: false, + webhookSecret: '', + webhookHost: '', + webhookPort: null, + }), + ).toEqual([]); + }); + + it('flags missing required webhook fields when enabled', () => { + expect( + collectMissingRequiredWebhookFields({ + webhookEnabled: true, + webhookSecret: ' ', + webhookHost: '', + webhookPort: null, + }), + ).toEqual([ + 'webhook.secret: (required when webhook.enabled=true)', + 'webhook.host: (required when webhook.enabled=true)', + 'webhook.port: (required when webhook.enabled=true)', + ]); + }); + + it('accepts valid webhook secret, host, and port', () => { + expect( + collectMissingRequiredWebhookFields({ + webhookEnabled: true, + webhookSecret: 'secret-1', + webhookHost: '127.0.0.1', + webhookPort: 8787, + }), + ).toEqual([]); + }); +}); + +describe('telegram webhook validation inputs', () => { + it('uses resolved runtime defaults for host and port validation', () => { + expect( + resolveTelegramWebhookValidationInputs({ + runtimeWebhookHost: '127.0.0.1', + runtimeWebhookPort: 8787, + }), + ).toEqual({ + webhookHost: '127.0.0.1', + webhookPort: 8787, + }); + }); + + it('flags invalid runtime values so critical checks can fail loudly', () => { + expect( + resolveTelegramWebhookValidationInputs({ + runtimeWebhookHost: ' ', + runtimeWebhookPort: Number.NaN, + }), + ).toEqual({ + webhookHost: '', + webhookPort: null, + }); + }); +}); diff --git a/apps/cli/src/ui/doctor.ts b/apps/cli/src/ui/doctor.ts index 0c2e527a60..579dfcc0ef 100644 --- a/apps/cli/src/ui/doctor.ts +++ b/apps/cli/src/ui/doctor.ts @@ -8,9 +8,13 @@ import chalk from 'chalk' import { configuration } from '@/configuration' import { readSettings, readCredentials } from '@/persistence' +import { decodeJwtPayload } from '@/cloud/decodeJwtPayload'; import { checkIfDaemonRunningAndCleanupStaleState } from '@/daemon/controlClient' import { findRunawayHappyProcesses, findAllHappyProcesses } from '@/daemon/doctor' import { readDaemonState, type DaemonLocallyPersistedState } from '@/persistence' +import { resolveChannelBridgeRuntimeConfig } from '@/channels/channelBridgeConfig'; +import { createAxiosChannelBridgeKvClient, readChannelBridgeTelegramConfigFromKv } from '@/channels/channelBridgeServerKv'; +import { overlayServerKvTelegramConfigInSettings } from '@/channels/channelBridgeServerConfigOverlay'; import { existsSync, readdirSync, statSync } from 'node:fs' import { readFile } from 'node:fs/promises' import { join } from 'node:path' @@ -43,9 +47,82 @@ export function maskValue(value: string | undefined): string | undefined { return `<${value.length} chars>`; } +export function isMissingRequiredTelegramWebhookSecret(params: Readonly<{ + webhookEnabled: boolean; + webhookSecret: string; +}>): boolean { + return params.webhookEnabled && params.webhookSecret.trim().length === 0; +} + +export function collectMissingRequiredWebhookFields(params: Readonly<{ + webhookEnabled: boolean; + webhookSecret: string; + webhookHost: string; + webhookPort: number | null; +}>): string[] { + if (!params.webhookEnabled) return []; + const issues: string[] = []; + if (params.webhookSecret.trim().length === 0) { + issues.push('webhook.secret: (required when webhook.enabled=true)'); + } + if (params.webhookHost.trim().length === 0) { + issues.push('webhook.host: (required when webhook.enabled=true)'); + } + if ( + !Number.isFinite(params.webhookPort) + || params.webhookPort === null + || !Number.isInteger(params.webhookPort) + || params.webhookPort <= 0 + || params.webhookPort > 65_535 + ) { + issues.push('webhook.port: (required when webhook.enabled=true)'); + } + return issues; +} + +function asRecord(value: unknown): Record | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + return value as Record; +} + +function parseStrictWebhookPort(value: unknown): number | null { + if (typeof value === 'number' && Number.isFinite(value)) { + return Math.trunc(value); + } + if (typeof value === 'string') { + const trimmed = value.trim(); + if (!/^[-]?\d+$/.test(trimmed)) return null; + const parsed = Number.parseInt(trimmed, 10); + return Number.isFinite(parsed) ? parsed : null; + } + return null; +} + +export function resolveTelegramWebhookValidationInputs(params: Readonly<{ + runtimeWebhookHost: string; + runtimeWebhookPort: number; +}>): Readonly<{ + webhookHost: string; + webhookPort: number | null; +}> { + const webhookHost = String(params.runtimeWebhookHost ?? '').trim(); + const webhookPort = + Number.isFinite(params.runtimeWebhookPort) + && Number.isInteger(params.runtimeWebhookPort) + && params.runtimeWebhookPort > 0 + && params.runtimeWebhookPort <= 65_535 + ? params.runtimeWebhookPort + : null; + + return { + webhookHost, + webhookPort, + }; +} + type SettingsForDisplay = Awaited>; -function redactSettingsForDisplay(settings: SettingsForDisplay): SettingsForDisplay { +export function redactSettingsForDisplay(settings: SettingsForDisplay): SettingsForDisplay { const redacted = JSON.parse(JSON.stringify(settings ?? {})) as SettingsForDisplay; const redactedRecord = redacted as unknown as Record; @@ -54,6 +131,45 @@ function redactSettingsForDisplay(settings: SettingsForDisplay): SettingsForDisp delete redactedRecord.localEnvironmentVariables; } + const channelBridge = asRecord(redactedRecord.channelBridge); + const byServerId = asRecord(channelBridge?.byServerId); + if (byServerId) { + for (const serverScope of Object.values(byServerId)) { + const serverRecord = asRecord(serverScope); + const byAccountId = asRecord(serverRecord?.byAccountId); + if (!byAccountId) continue; + + for (const accountScope of Object.values(byAccountId)) { + const accountRecord = asRecord(accountScope); + const providers = asRecord(accountRecord?.providers); + if (!providers) continue; + + for (const providerScope of Object.values(providers)) { + const providerRecord = asRecord(providerScope); + if (!providerRecord) continue; + + const secrets = asRecord(providerRecord.secrets); + if (secrets) { + for (const [key, value] of Object.entries(secrets)) { + if (typeof value === 'string' && value.trim().length > 0) { + secrets[key] = ''; + } + } + } + + if (typeof providerRecord.botToken === 'string' && providerRecord.botToken.trim().length > 0) { + providerRecord.botToken = ''; + } + + const webhook = asRecord(providerRecord.webhook); + if (webhook && typeof webhook.secret === 'string' && webhook.secret.trim().length > 0) { + webhook.secret = ''; + } + } + } + } + } + return redacted; } @@ -128,6 +244,8 @@ export async function runDoctorCommand(filter?: 'all' | 'daemon'): Promise if (!filter) { filter = 'all'; } + + let hasCriticalFailures = false; console.log(chalk.bold.cyan('\n🩺 Happier CLI Doctor\n')); @@ -157,9 +275,15 @@ export async function runDoctorCommand(filter?: 'all' | 'daemon'): Promise console.log(`Wrapper Script: ${chalk.blue(formatDoctorSpawnPathLabel(runtimeDiagnostics.wrapperPath))}`); console.log(`CLI Entrypoint: ${chalk.blue(formatDoctorSpawnPathLabel(runtimeDiagnostics.cliEntrypointPath))}`); if (runtimeDiagnostics.wrapperExists !== null) { + if (!runtimeDiagnostics.wrapperExists) { + hasCriticalFailures = true; + } console.log(`Wrapper Exists: ${runtimeDiagnostics.wrapperExists ? chalk.green('✓ Yes') : chalk.red('❌ No')}`); } if (runtimeDiagnostics.cliEntrypointExists !== null) { + if (!runtimeDiagnostics.cliEntrypointExists) { + hasCriticalFailures = true; + } console.log(`CLI Exists: ${runtimeDiagnostics.cliEntrypointExists ? chalk.green('✓ Yes') : chalk.red('❌ No')}`); } console.log(''); @@ -209,19 +333,22 @@ export async function runDoctorCommand(filter?: 'all' | 'daemon'): Promise } // Settings + let settingsSnapshot: unknown = null; try { - const settings = await readSettings(); + settingsSnapshot = await readSettings(); console.log(chalk.bold('\n📄 Settings (settings.json):')); - console.log(chalk.gray(JSON.stringify(redactSettingsForDisplay(settings), null, 2))); + console.log(chalk.gray(JSON.stringify(redactSettingsForDisplay(settingsSnapshot), null, 2))); } catch (error) { console.log(chalk.bold('\n📄 Settings:')); console.log(chalk.red('❌ Failed to read settings')); + hasCriticalFailures = true; } // Authentication status + let credentials: Awaited> | null = null; console.log(chalk.bold('\n🔐 Authentication')); try { - const credentials = await readCredentials(); + credentials = await readCredentials(); if (credentials) { console.log(chalk.green('✓ Authenticated (credentials found)')); if (snapshot?.accountId) { @@ -232,6 +359,159 @@ export async function runDoctorCommand(filter?: 'all' | 'daemon'): Promise } } catch (error) { console.log(chalk.red('❌ Error reading credentials')); + hasCriticalFailures = true; + } + + try { + const authCredentials = credentials ?? await readCredentials().catch(() => null); + const payload = authCredentials?.token ? decodeJwtPayload(authCredentials.token) : null; + const accountId = payload && typeof payload.sub === 'string' ? payload.sub.trim() : ''; + const settings = settingsSnapshot ?? await readSettings(); + const serverId = String(configuration.activeServerId ?? '').trim(); + let runtimeSettings: unknown = settings; + let runtimeConfigSource = 'local settings'; + let serverKvReadFailure: string | null = null; + + if (serverId.length > 0 && authCredentials?.token) { + try { + const kv = createAxiosChannelBridgeKvClient({ token: authCredentials.token }); + const serverRecord = await readChannelBridgeTelegramConfigFromKv({ + kv, + serverId, + allowUnsupportedSchema: true, + }); + runtimeSettings = overlayServerKvTelegramConfigInSettings({ + settings, + serverId, + accountId, + record: serverRecord.record, + }); + if (serverRecord.record != null) { + runtimeConfigSource = 'server KV overlay + local settings'; + } + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + serverKvReadFailure = message; + } + } + + const runtimeSettingsRecord = asRecord(runtimeSettings); + const channelBridgeRoot = asRecord(runtimeSettingsRecord?.channelBridge); + const byServerId = asRecord(channelBridgeRoot?.byServerId); + const serverScope = serverId ? asRecord(byServerId?.[serverId]) : null; + const byAccountId = asRecord(serverScope?.byAccountId); + const accountScope = accountId ? asRecord(byAccountId?.[accountId]) : null; + const providers = asRecord(accountScope?.providers); + const telegram = asRecord(providers?.telegram); + const providerEntries = providers + ? Object.entries(providers) + : []; + + const runtimeBridge = resolveChannelBridgeRuntimeConfig({ + env: process.env, + settings: runtimeSettings, + serverId, + accountId, + }); + + console.log(chalk.bold('\n🔌 Channel Bridges')); + console.log(`Server scope: ${serverId || '(unknown)'}`); + console.log(`Account scope: ${accountId || '(unknown)'}`); + console.log(`Runtime source: ${runtimeConfigSource}`); + if (serverKvReadFailure) { + console.log(chalk.yellow(`⚠️ Unable to read channel bridge server KV: ${serverKvReadFailure}`)); + } + const telegramConfigured = + telegram !== null + || runtimeBridge.telegram.botToken.trim().length > 0 + || runtimeBridge.telegram.webhookEnabled + || runtimeBridge.telegram.webhookSecret.trim().length > 0 + || runtimeBridge.telegram.allowedChatIds.length > 0 + || runtimeBridge.telegram.requireTopics; + + if (telegramConfigured) { + const token = runtimeBridge.telegram.botToken; + const webhookSecret = runtimeBridge.telegram.webhookSecret; + const allowedChatIds = runtimeBridge.telegram.allowedChatIds; + const requireTopics = runtimeBridge.telegram.requireTopics; + const webhookEnabled = runtimeBridge.telegram.webhookEnabled; + const webhookHost = runtimeBridge.telegram.webhookHost; + const webhookPort = String(runtimeBridge.telegram.webhookPort); + const { webhookHost: webhookHostForValidation, webhookPort: webhookPortForValidation } = + resolveTelegramWebhookValidationInputs({ + runtimeWebhookHost: runtimeBridge.telegram.webhookHost, + runtimeWebhookPort: runtimeBridge.telegram.webhookPort, + }); + const tokenMissing = token.trim().length === 0; + const webhookIssues = collectMissingRequiredWebhookFields({ + webhookEnabled, + webhookSecret, + webhookHost: webhookHostForValidation, + webhookPort: webhookPortForValidation, + }); + const hasTelegramCriticalIssue = tokenMissing || webhookIssues.length > 0; + if (hasTelegramCriticalIssue) { + console.log(chalk.red('❌ Telegram bridge configured with critical issues')); + hasCriticalFailures = true; + } else { + console.log(chalk.green('✓ Telegram bridge configured in scoped settings')); + } + if (tokenMissing) { + console.log(chalk.red(' botToken: (required)')); + } else { + console.log(` botToken: ${maskValue(token) ?? ''}`); + } + if (webhookIssues.length > 0) { + for (const issue of webhookIssues) { + console.log(chalk.red(` ${issue}`)); + } + } else { + console.log(` webhook.secret: ${maskValue(webhookSecret) ?? ''}`); + } + console.log(` webhook.enabled: ${webhookEnabled ? 'true' : 'false'}`); + console.log(` webhook.host: ${webhookHost}`); + console.log(` webhook.port: ${webhookPort}`); + console.log(` allowedChatIds: ${allowedChatIds.length > 0 ? allowedChatIds.join(', ') : '(allow all)'}`); + console.log(` requireTopics: ${requireTopics ? 'true' : 'false'}`); + console.log(' secret policy: local-only (not synced to server KV)'); + } else { + console.log(chalk.gray('Telegram bridge not configured for active server/account scope')); + } + + for (const [providerId, providerConfig] of providerEntries) { + if (providerId === 'telegram') continue; + if (!providerConfig || typeof providerConfig !== 'object' || Array.isArray(providerConfig)) continue; + + const providerRecord = providerConfig as Record; + const webhook = asRecord(providerRecord.webhook); + const secrets = asRecord(providerRecord.secrets); + const webhookEnabled = webhook?.enabled === true; + const webhookSecret = typeof secrets?.webhookSecret === 'string' + ? secrets.webhookSecret + : typeof webhook?.secret === 'string' + ? webhook.secret + : ''; + const webhookHostRaw = typeof webhook?.host === 'string' ? webhook.host : ''; + const webhookPortRaw = parseStrictWebhookPort(webhook?.port); + const issues = collectMissingRequiredWebhookFields({ + webhookEnabled, + webhookSecret, + webhookHost: webhookHostRaw, + webhookPort: webhookPortRaw, + }); + if (issues.length > 0) { + console.log(chalk.red(`❌ ${providerId} bridge configured with critical issues`)); + for (const issue of issues) { + console.log(chalk.red(` ${issue}`)); + } + hasCriticalFailures = true; + } + } + console.log(chalk.gray('Apply changes with daemon restart: happier daemon stop && happier daemon start')); + } catch (error) { + hasCriticalFailures = true; + const message = error instanceof Error ? error.message : String(error); + console.log(chalk.red(`❌ Failed to evaluate channel bridge diagnostics: ${message}`)); } } @@ -252,7 +532,7 @@ export async function runDoctorCommand(filter?: 'all' | 'daemon'): Promise } else if (state && !isRunning) { console.log(chalk.yellow('⚠️ Daemon state exists but process not running (stale)')); } else { - console.log(chalk.red('❌ Daemon is not running')); + console.log(chalk.yellow('⚠️ Daemon is not running')); } // Show daemon state file @@ -301,7 +581,7 @@ export async function runDoctorCommand(filter?: 'all' | 'daemon'): Promise }); }); } else { - console.log(chalk.red('❌ No happier processes found')); + console.log(chalk.yellow('⚠️ No happier processes found (process inventory may be unavailable in this runtime)')); } if (allProcesses.length > 1) { // More than just current process @@ -311,6 +591,7 @@ export async function runDoctorCommand(filter?: 'all' | 'daemon'): Promise } } catch (error) { console.log(chalk.red('❌ Error checking daemon status')); + hasCriticalFailures = true; } // Log files - only show for 'all' filter @@ -362,5 +643,9 @@ export async function runDoctorCommand(filter?: 'all' | 'daemon'): Promise console.log(`Documentation: ${chalk.blue('https://app.happier.dev')}`); } - console.log(chalk.green('\n✅ Doctor diagnosis complete!\n')); + if (hasCriticalFailures) { + console.log(chalk.red('\n❌ Doctor diagnosis complete!\n')); + } else { + console.log(chalk.green('\n✅ Doctor diagnosis complete!\n')); + } } diff --git a/docs/README.md b/docs/README.md index 1f2ffa8f15..8a227bebd6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -14,7 +14,8 @@ This folder documents how Happier works internally, with a focus on protocol, ba - opencode-feature-matrix.md: Low-level OpenCode implementation matrix and unified-architecture migration notes. - pi-feature-matrix.md: Low-level PI implementation matrix and unified-architecture migration notes. - acp-provider-feature-matrix.md: Low-level ACP-provider matrix and catalog migration notes. -- channel-bridge.md: Provider-agnostic bridge architecture and adapter contract. +- channel-bridge.md: Provider-agnostic bridge architecture, ingress mode matrix, and relay deployment model. +- telegram-channel-bridge.md: Telegram adapter setup, commands, and channel-specific bridge usage. - issue-triage.md: How the GitHub issue triage workflows are wired to maintainer tooling. ## Conventions diff --git a/docs/assets/doctor-critical-summary.png b/docs/assets/doctor-critical-summary.png new file mode 100644 index 0000000000000000000000000000000000000000..b7161e170d88f2c3ad6a4c9a00bee0d65b8c5c03 GIT binary patch literal 77342 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%0D!H#N4X*}GD%`J@MSr;IB|Cp}VS0L6 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

=e9mKb4ffm8bXTu<+1LxiP8tiz@ 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

?VU^QLyzla37 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 literal 0 HcmV?d00001 diff --git a/docs/channel-bridge-uat.md b/docs/channel-bridge-uat.md new file mode 100644 index 0000000000..b5dcc613a0 --- /dev/null +++ b/docs/channel-bridge-uat.md @@ -0,0 +1,92 @@ +# Channel Bridge UAT (Single Account, Multi-Machine) + +This checklist validates that one Happier account can use multiple machines + Telegram bridge without cross-account bleed. + +## 1) Start from clean local state (test env) + +- Stop daemon: `happier daemon stop` +- Confirm active server: `happier server list` +- Start daemon: `happier daemon start` + +## 2) Register primary machine + account + +- Open Web UI for active server. +- Create account (or login with existing account secret key). +- Verify account id in UI/CLI matches expected scope. + +## 3) Configure Telegram bridge for this account + +- Configure bridge from CLI: + +```bash +happier bridge telegram set \ + --bot-token \ + --allow-all \ + --require-topics true +``` + +- Verify persisted state: + +```bash +happier bridge list +``` + +Expected: +- `Telegram (server KV)` shows `configured: yes` +- `Telegram (effective runtime...)` shows configured token/topic policy + +## 4) Restart daemon and verify bridge worker startup + +```bash +happier daemon stop && happier daemon start +happier doctor +``` + +Expected: +- Channel bridge section shows configured state. +- No crash loop in daemon logs. +- Final diagnosis line matches overall health: + - `✅ Doctor diagnosis complete!` when no critical failures + - `❌ Doctor diagnosis complete!` when any critical failure exists (for example, Telegram bridge configured but bot token missing, or webhook enabled with missing secret/host/port) + +## 5) Session bind + bi-direction test from Telegram + +- In Happier, create a new session and copy session id. +- In Telegram chat/topic with bot: + - `/sessions` + - `/attach ` + - send: `bridge-e2e-ok` +- In Happier session, confirm message appears. +- Send reply in Happier session, confirm it appears back in same Telegram thread/topic. + +## 6) Add second machine to same account + +- Open a second browser/device. +- Use **Login with mobile app** → **Restore with Secret Key Instead**. +- Authenticate into same account. + +Expected: +- Same account id. +- Existing sessions are visible/resumable (subject to agent backend credentials). + +## 7) Isolation check (optional second account) + +- Create another account on same server. +- Configure a different Telegram bot/chat allowlist. + +Expected: +- No cross-account session visibility. +- No cross-account channel binding behavior. + +## 8) Failure-mode checks + +- Set `allowedChatIds` to a different chat and verify current chat is blocked. +- Revert to `--allow-all` and verify chat works again. +- If using webhook mode, verify secret mismatch returns a non-200 response (typically 404 with path-token mismatch). + +## Notes + +- `allowedChatIds: []` means allow all chats/topics the bot can read. +- Runtime precedence is: `HAPPIER_* env` > `server KV` > `settings.json` > defaults. +- Secrets (bot/API tokens) stay local-only (`settings.json`/env), never server KV. +- For full deployment, server KV is DB-backed and shared across machines in the same account scope. diff --git a/docs/telegram-channel-bridge.md b/docs/telegram-channel-bridge.md new file mode 100644 index 0000000000..00616e37e9 --- /dev/null +++ b/docs/telegram-channel-bridge.md @@ -0,0 +1,243 @@ +# Telegram Session Bridge + +This repository now includes a **built-in channel bridge core** plus a **Telegram adapter**. + +- Core worker: `apps/cli/src/channels/core/channelBridgeWorker.ts` +- Telegram adapter: `apps/cli/src/channels/telegram/telegramAdapter.ts` +- Runtime wiring: `apps/cli/src/channels/startChannelBridgeWorker.ts` + +The design is intentionally modular so additional adapters (Discord, Slack, WhatsApp, etc.) can plug into the same core contract. + +For core bridge architecture, ingress mode matrix, and relay deployment model, see `docs/channel-bridge.md`. + +## Current Behavior + +- Bi-directional flow between Telegram and Happier sessions. +- Conversation-to-session mapping via Telegram commands. +- Mapping key is `(provider, chat_id, topic_thread_id|null)`: + - **DM** = `thread_id = null` + - **Topic** = `thread_id = ` +- Outbound agent replies are forwarded back into the mapped DM/topic. + +## BotFather + Telegram Setup + +1. Create bot with `@BotFather` (`/newbot`) and copy bot token. +2. In BotFather, disable privacy mode (`/setprivacy` → **Disable**) so group/topic messages are visible. +3. Add bot to your group/supergroup. +4. Promote bot to **admin** in the group so it can read/send in topics. +5. (Recommended) Use a **supergroup with topics** and bind one topic per Happier session. + +## Configuration (`settings.json`) + Environment Overrides + +You can configure the bridge in `~/.happier/settings.json` (or your `HAPPIER_HOME_DIR/settings.json`), and still use env vars for overrides. + +Recommended model: configure bridges per `serverId` + `accountId`, so each account can own its own adapters and credentials. + +Example: + +```json +{ + "channelBridge": { + "byServerId": { + "127.0.0.1-3005": { + "byAccountId": { + "cmmb9sp...": { + "tickMs": 2500, + "providers": { + "telegram": { + "allowedChatIds": ["-1001234567890", "-10055555555"], + "requireTopics": true, + "webhook": { + "enabled": false, + "host": "127.0.0.1", + "port": 8787 + }, + "secrets": { + "botToken": "", + "webhookSecret": "" + } + } + } + } + } + } + } + } +} +``` + +Backward compatibility is preserved: root-level `channelBridge.tickMs` and `channelBridge.providers` still work as global defaults. + +Precedence is: + +1. `HAPPIER_*` environment variables +2. Server KV bridge config (account-authenticated, DB-backed in full deployment, **non-secret only**) +3. `settings.json` bridge config (local scoped fallback, includes secrets) +4. built-in defaults + +`allowedChatIds` behavior: + +- `[]` (empty list) = allow all chats/topics the bot can read. +- non-empty list = allow only those chat IDs. + +## Secret Handling Policy (for all adapters) + +- **Never sync secrets to server KV**. +- Store secrets only in: + - local scoped `settings.json` (`providers..secrets.*`), or + - process env vars (`HAPPIER_*`). +- Server KV is for non-secret shareable bridge config/state only. + +For new adapters (Discord/Slack/WhatsApp/etc), use the same model: + +```json +{ + "providers": { + "adapterName": { + "...nonSecretFields": true, + "secrets": { + "token": "", + "apiKey": "" + } + } + } +} +``` + +## Environment Variables (`.env.local`) + +Set these to override `settings.json` or run env-only: + +```bash +HAPPIER_TELEGRAM_BOT_TOKEN= + +# Optional hardening: +HAPPIER_TELEGRAM_ALLOWED_CHAT_IDS=-1001234567890,-10055555555 +HAPPIER_TELEGRAM_REQUIRE_TOPICS=1 + +# Bridge tick cadence (ms) +HAPPIER_CHANNEL_BRIDGE_TICK_MS=2500 +``` + +## CLI Management (account-scoped) + +Use the bridge CLI to manage Telegram config for the active `serverId + accountId` scope: + +- `telegram set` + - writes non-secret fields to server KV + - writes full config (including secrets) to local scoped `settings.json` +- `telegram clear` clears server KV + local scoped `settings.json` +- `bridge list` prints scoped local config, server KV config, and effective runtime resolution + +```bash +happier bridge list + +happier bridge telegram set \ + --bot-token \ + --allow-all \ + --require-topics true \ + --tick-ms 2500 + +# optional webhook relay configuration +happier bridge telegram set \ + --webhook-enabled true \ + --webhook-secret \ + --webhook-host 127.0.0.1 \ + --webhook-port 8787 + +# optional: restrict to specific chats +happier bridge telegram set --allowed-chat-ids -1001234567890,-10022222222 + +happier bridge telegram clear +``` + +Then apply changes by restarting daemon: + +```bash +happier daemon stop && happier daemon start +``` + +## Doctor Status Aggregation + +`happier doctor` now aggregates critical failures and sets the final diagnosis line accordingly: + +- final line is `✅ Doctor diagnosis complete!` only when no critical failures are found +- final line is `❌ Doctor diagnosis complete!` if any critical failure is detected + +Telegram bridge example: + +- if Telegram bridge is configured but bot token is missing, doctor prints a red bridge error and the final line is `❌` +- if `webhook.enabled=true` and `webhook.secret` is empty, doctor treats it as critical and final line is `❌` +- if `webhook.enabled=true` and `webhook.host` or `webhook.port` is missing/invalid, doctor treats it as critical and final line is `❌` + +Generic adapter behavior: + +- doctor applies the same webhook-required checks to any channel adapter that configures `webhook.enabled=true` + +## Webhook Setup (daemon relay mode) + +By default the adapter polls `getUpdates`. + +If you prefer webhooks, enable the built-in relay in the daemon: + +```bash +HAPPIER_TELEGRAM_WEBHOOK_ENABLED=1 +HAPPIER_TELEGRAM_WEBHOOK_SECRET= +HAPPIER_TELEGRAM_WEBHOOK_HOST=127.0.0.1 +HAPPIER_TELEGRAM_WEBHOOK_PORT=8787 +``` + +Important: + +- `HAPPIER_SERVER_URL` is the Telegram callback target only when server-relay mode exists and is enabled (planned, not currently implemented). +- In the currently implemented daemon-relay mode, Telegram must call a public URL that forwards to daemon host/port/path. +- If you do not have inbound public endpoint/tunnel to daemon, use polling mode (`getUpdates`). + +Expose/proxy this daemon endpoint publicly: + +```text +POST /telegram/webhook/ +``` + +Example: + +```text +https://your-public-host/telegram/webhook/ + -> http://127.0.0.1:8787/telegram/webhook/ +``` + +Set Telegram webhook: + +```bash +curl "https://api.telegram.org/bot/setWebhook?url=https://your-public-host/telegram/webhook/&secret_token=" +``` + +Use the same `` value for both webhook URL path token and `secret_token` so relay can validate the `X-Telegram-Bot-Api-Secret-Token` header. + +If you switch back to polling mode, clear webhook first: + +```bash +curl "https://api.telegram.org/bot/deleteWebhook" +``` + +For server-relay and standalone-relay planning details, see `docs/channel-bridge.md`. + +## Telegram Commands (inside DM/topic) + +- `/help` — command list +- `/sessions` — list active Happier sessions +- `/attach ` — bind current DM/topic to a session +- `/session` — show current binding +- `/detach` — remove binding + +After `/attach`, normal messages in that DM/topic are forwarded into the mapped session, and agent replies flow back into that same DM/topic. + +## Extending Beyond Telegram + +To add another provider, implement the same adapter shape used by Telegram: + +- inbound pull method +- outbound send method +- provider id + conversation/thread identifiers + +No session pipeline logic needs to be duplicated; the core bridge worker handles command routing, binding state, and session forwarding. diff --git a/yarn.lock b/yarn.lock index 51ed2611dd..6409334c35 100644 --- a/yarn.lock +++ b/yarn.lock @@ -98,13 +98,6 @@ "@csstools/css-tokenizer" "^3.0.3" lru-cache "^10.4.3" -"@babel/code-frame@7.10.4", "@babel/code-frame@~7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz" - integrity sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg== - dependencies: - "@babel/highlight" "^7.10.4" - "@babel/code-frame@^7.12.13", "@babel/code-frame@^7.20.0", "@babel/code-frame@^7.24.7", "@babel/code-frame@^7.28.6": version "7.28.6" resolved "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz" @@ -114,12 +107,26 @@ js-tokens "^4.0.0" picocolors "^1.1.1" +"@babel/code-frame@~7.10.4": + version "7.10.4" + resolved "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz" + integrity sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg== + dependencies: + "@babel/highlight" "^7.10.4" + +"@babel/code-frame@7.10.4": + version "7.10.4" + resolved "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz" + integrity sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg== + dependencies: + "@babel/highlight" "^7.10.4" + "@babel/compat-data@^7.28.6": version "7.28.6" resolved "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.6.tgz" integrity sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg== -"@babel/core@^7.11.6", "@babel/core@^7.12.3", "@babel/core@^7.20.0", "@babel/core@^7.25.2": +"@babel/core@*", "@babel/core@^7.0.0", "@babel/core@^7.0.0 || ^8.0.0-0", "@babel/core@^7.0.0-0", "@babel/core@^7.11.6", "@babel/core@^7.12.0", "@babel/core@^7.12.3", "@babel/core@^7.20.0", "@babel/core@^7.25.2", "@babel/core@^7.4.0 || ^8.0.0-0 <8.0.0", "@babel/core@^7.8.0": version "7.28.6" resolved "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz" integrity sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw== @@ -482,7 +489,7 @@ dependencies: "@babel/helper-plugin-utils" "^7.28.6" -"@babel/plugin-transform-arrow-functions@7.27.1", "@babel/plugin-transform-arrow-functions@^7.24.7": +"@babel/plugin-transform-arrow-functions@^7.24.7", "@babel/plugin-transform-arrow-functions@7.27.1": version "7.27.1" resolved "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.27.1.tgz" integrity sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA== @@ -514,6 +521,14 @@ dependencies: "@babel/helper-plugin-utils" "^7.28.6" +"@babel/plugin-transform-class-properties@^7.25.4": + version "7.28.6" + resolved "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.28.6.tgz" + integrity sha512-dY2wS3I2G7D697VHndN91TJr8/AAfXQNt5ynCTI/MpxMsSzHp+52uNivYT5wCPax3whc47DR8Ba7cmlQMg24bw== + dependencies: + "@babel/helper-create-class-features-plugin" "^7.28.6" + "@babel/helper-plugin-utils" "^7.28.6" + "@babel/plugin-transform-class-properties@7.27.1": version "7.27.1" resolved "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.27.1.tgz" @@ -522,21 +537,25 @@ "@babel/helper-create-class-features-plugin" "^7.27.1" "@babel/helper-plugin-utils" "^7.27.1" -"@babel/plugin-transform-class-properties@^7.25.4": +"@babel/plugin-transform-class-static-block@^7.27.1": version "7.28.6" - resolved "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.28.6.tgz" - integrity sha512-dY2wS3I2G7D697VHndN91TJr8/AAfXQNt5ynCTI/MpxMsSzHp+52uNivYT5wCPax3whc47DR8Ba7cmlQMg24bw== + resolved "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.28.6.tgz" + integrity sha512-rfQ++ghVwTWTqQ7w8qyDxL1XGihjBss4CmTgGRCTAC9RIbhVpyp4fOeZtta0Lbf+dTNIVJer6ych2ibHwkZqsQ== dependencies: "@babel/helper-create-class-features-plugin" "^7.28.6" "@babel/helper-plugin-utils" "^7.28.6" -"@babel/plugin-transform-class-static-block@^7.27.1": +"@babel/plugin-transform-classes@^7.25.4": version "7.28.6" - resolved "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.28.6.tgz" - integrity sha512-rfQ++ghVwTWTqQ7w8qyDxL1XGihjBss4CmTgGRCTAC9RIbhVpyp4fOeZtta0Lbf+dTNIVJer6ych2ibHwkZqsQ== + resolved "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.6.tgz" + integrity sha512-EF5KONAqC5zAqT783iMGuM2ZtmEBy+mJMOKl2BCvPZ2lVrwvXnB6o+OBWCS+CoeCCpVRF2sA2RBKUxvT8tQT5Q== dependencies: - "@babel/helper-create-class-features-plugin" "^7.28.6" + "@babel/helper-annotate-as-pure" "^7.27.3" + "@babel/helper-compilation-targets" "^7.28.6" + "@babel/helper-globals" "^7.28.0" "@babel/helper-plugin-utils" "^7.28.6" + "@babel/helper-replace-supers" "^7.28.6" + "@babel/traverse" "^7.28.6" "@babel/plugin-transform-classes@7.28.4": version "7.28.4" @@ -550,18 +569,6 @@ "@babel/helper-replace-supers" "^7.27.1" "@babel/traverse" "^7.28.4" -"@babel/plugin-transform-classes@^7.25.4": - version "7.28.6" - resolved "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.6.tgz" - integrity sha512-EF5KONAqC5zAqT783iMGuM2ZtmEBy+mJMOKl2BCvPZ2lVrwvXnB6o+OBWCS+CoeCCpVRF2sA2RBKUxvT8tQT5Q== - dependencies: - "@babel/helper-annotate-as-pure" "^7.27.3" - "@babel/helper-compilation-targets" "^7.28.6" - "@babel/helper-globals" "^7.28.0" - "@babel/helper-plugin-utils" "^7.28.6" - "@babel/helper-replace-supers" "^7.28.6" - "@babel/traverse" "^7.28.6" - "@babel/plugin-transform-computed-properties@^7.24.7": version "7.28.6" resolved "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.28.6.tgz" @@ -640,13 +647,6 @@ "@babel/helper-create-regexp-features-plugin" "^7.27.1" "@babel/helper-plugin-utils" "^7.27.1" -"@babel/plugin-transform-nullish-coalescing-operator@7.27.1": - version "7.27.1" - resolved "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.27.1.tgz" - integrity sha512-aGZh6xMo6q9vq1JGcw58lZ1Z0+i0xB2x0XaauNIUXd6O1xXc3RwoWEBlsTQrY4KQ9Jf0s5rgD6SiNkaUdJegTA== - dependencies: - "@babel/helper-plugin-utils" "^7.27.1" - "@babel/plugin-transform-nullish-coalescing-operator@^7.24.7": version "7.28.6" resolved "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.28.6.tgz" @@ -654,6 +654,13 @@ dependencies: "@babel/helper-plugin-utils" "^7.28.6" +"@babel/plugin-transform-nullish-coalescing-operator@7.27.1": + version "7.27.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.27.1.tgz" + integrity sha512-aGZh6xMo6q9vq1JGcw58lZ1Z0+i0xB2x0XaauNIUXd6O1xXc3RwoWEBlsTQrY4KQ9Jf0s5rgD6SiNkaUdJegTA== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/plugin-transform-numeric-separator@^7.24.7": version "7.28.6" resolved "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.28.6.tgz" @@ -679,14 +686,6 @@ dependencies: "@babel/helper-plugin-utils" "^7.28.6" -"@babel/plugin-transform-optional-chaining@7.27.1": - version "7.27.1" - resolved "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.27.1.tgz" - integrity sha512-BQmKPPIuc8EkZgNKsv0X4bPmOoayeu4F1YCwx2/CfmDSXDbp7GnzlUH+/ul5VGfRg1AoFPsrIThlEBj2xb4CAg== - dependencies: - "@babel/helper-plugin-utils" "^7.27.1" - "@babel/helper-skip-transparent-expression-wrappers" "^7.27.1" - "@babel/plugin-transform-optional-chaining@^7.24.8": version "7.28.6" resolved "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.28.6.tgz" @@ -695,6 +694,14 @@ "@babel/helper-plugin-utils" "^7.28.6" "@babel/helper-skip-transparent-expression-wrappers" "^7.27.1" +"@babel/plugin-transform-optional-chaining@7.27.1": + version "7.27.1" + resolved "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.27.1.tgz" + integrity sha512-BQmKPPIuc8EkZgNKsv0X4bPmOoayeu4F1YCwx2/CfmDSXDbp7GnzlUH+/ul5VGfRg1AoFPsrIThlEBj2xb4CAg== + dependencies: + "@babel/helper-plugin-utils" "^7.27.1" + "@babel/helper-skip-transparent-expression-wrappers" "^7.27.1" + "@babel/plugin-transform-parameters@^7.24.7", "@babel/plugin-transform-parameters@^7.27.7": version "7.27.7" resolved "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.27.7.tgz" @@ -785,7 +792,7 @@ babel-plugin-polyfill-regenerator "^0.6.5" semver "^6.3.1" -"@babel/plugin-transform-shorthand-properties@7.27.1", "@babel/plugin-transform-shorthand-properties@^7.24.7": +"@babel/plugin-transform-shorthand-properties@^7.24.7", "@babel/plugin-transform-shorthand-properties@7.27.1": version "7.27.1" resolved "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.27.1.tgz" integrity sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ== @@ -825,7 +832,7 @@ "@babel/helper-skip-transparent-expression-wrappers" "^7.27.1" "@babel/plugin-syntax-typescript" "^7.28.6" -"@babel/plugin-transform-unicode-regex@7.27.1", "@babel/plugin-transform-unicode-regex@^7.24.7": +"@babel/plugin-transform-unicode-regex@^7.24.7", "@babel/plugin-transform-unicode-regex@7.27.1": version "7.27.1" resolved "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.27.1.tgz" integrity sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw== @@ -845,29 +852,29 @@ "@babel/plugin-transform-react-jsx-development" "^7.27.1" "@babel/plugin-transform-react-pure-annotations" "^7.27.1" -"@babel/preset-typescript@7.27.1": - version "7.27.1" - resolved "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.27.1.tgz" - integrity sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ== +"@babel/preset-typescript@^7.23.0", "@babel/preset-typescript@^7.26.0": + version "7.28.5" + resolved "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.28.5.tgz" + integrity sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g== dependencies: "@babel/helper-plugin-utils" "^7.27.1" "@babel/helper-validator-option" "^7.27.1" "@babel/plugin-syntax-jsx" "^7.27.1" "@babel/plugin-transform-modules-commonjs" "^7.27.1" - "@babel/plugin-transform-typescript" "^7.27.1" + "@babel/plugin-transform-typescript" "^7.28.5" -"@babel/preset-typescript@^7.23.0", "@babel/preset-typescript@^7.26.0": - version "7.28.5" - resolved "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.28.5.tgz" - integrity sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g== +"@babel/preset-typescript@7.27.1": + version "7.27.1" + resolved "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.27.1.tgz" + integrity sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ== dependencies: "@babel/helper-plugin-utils" "^7.27.1" "@babel/helper-validator-option" "^7.27.1" "@babel/plugin-syntax-jsx" "^7.27.1" "@babel/plugin-transform-modules-commonjs" "^7.27.1" - "@babel/plugin-transform-typescript" "^7.28.5" + "@babel/plugin-transform-typescript" "^7.27.1" -"@babel/runtime@^7.18.6", "@babel/runtime@^7.20.0", "@babel/runtime@^7.20.13", "@babel/runtime@^7.25.0": +"@babel/runtime@*", "@babel/runtime@^7.18.6", "@babel/runtime@^7.20.0", "@babel/runtime@^7.20.13", "@babel/runtime@^7.25.0": version "7.28.6" resolved "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz" integrity sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA== @@ -965,7 +972,7 @@ "@noble/curves" "1.5.0" "@noble/hashes" "1.4.0" -"@codemirror/autocomplete@6.20.0", "@codemirror/autocomplete@^6.0.0", "@codemirror/autocomplete@^6.3.2", "@codemirror/autocomplete@^6.7.1": +"@codemirror/autocomplete@^6.0.0", "@codemirror/autocomplete@^6.3.2", "@codemirror/autocomplete@^6.7.1", "@codemirror/autocomplete@6.20.0": version "6.20.0" resolved "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.0.tgz" integrity sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg== @@ -1001,7 +1008,7 @@ "@codemirror/language" "^6.0.0" "@lezer/cpp" "^1.0.0" -"@codemirror/lang-css@6.3.1", "@codemirror/lang-css@^6.0.0": +"@codemirror/lang-css@^6.0.0", "@codemirror/lang-css@6.3.1": version "6.3.1" resolved "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz" integrity sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg== @@ -1023,7 +1030,7 @@ "@lezer/common" "^1.0.0" "@lezer/go" "^1.0.0" -"@codemirror/lang-html@6.4.11", "@codemirror/lang-html@^6.0.0": +"@codemirror/lang-html@^6.0.0", "@codemirror/lang-html@6.4.11": version "6.4.11" resolved "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz" integrity sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw== @@ -1046,7 +1053,7 @@ "@codemirror/language" "^6.0.0" "@lezer/java" "^1.0.0" -"@codemirror/lang-javascript@6.2.4", "@codemirror/lang-javascript@^6.0.0": +"@codemirror/lang-javascript@^6.0.0", "@codemirror/lang-javascript@6.2.4": version "6.2.4" resolved "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.4.tgz" integrity sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA== @@ -1147,7 +1154,7 @@ "@lezer/lr" "^1.0.0" "@lezer/yaml" "^1.0.0" -"@codemirror/language@6.12.1", "@codemirror/language@^6.0.0", "@codemirror/language@^6.3.0", "@codemirror/language@^6.4.0", "@codemirror/language@^6.6.0", "@codemirror/language@^6.8.0": +"@codemirror/language@^6.0.0", "@codemirror/language@^6.3.0", "@codemirror/language@^6.4.0", "@codemirror/language@^6.6.0", "@codemirror/language@^6.8.0", "@codemirror/language@6.12.1": version "6.12.1" resolved "https://registry.npmjs.org/@codemirror/language/-/language-6.12.1.tgz" integrity sha512-Fa6xkSiuGKc8XC8Cn96T+TQHYj4ZZ7RdFmXA3i9xe/3hLHfwPZdM+dqfX0Cp0zQklBKhVD8Yzc8LS45rkqcwpQ== @@ -1182,35 +1189,32 @@ dependencies: "@codemirror/state" "^0.19.0" -"@codemirror/state@6.5.4", "@codemirror/state@^6.0.0", "@codemirror/state@^6.4.0", "@codemirror/state@^6.5.0": - version "6.5.4" - resolved "https://registry.npmjs.org/@codemirror/state/-/state-6.5.4.tgz" - integrity sha512-8y7xqG/hpB53l25CIoit9/ngxdfoG+fx+V3SHBrinnhOtLvKHRyAJJuHzkWrR4YXXLX8eXBsejgAAxHUOdW1yw== +"@codemirror/state@^0.19.0": + version "0.19.9" + resolved "https://registry.npmjs.org/@codemirror/state/-/state-0.19.9.tgz" + integrity sha512-psOzDolKTZkx4CgUqhBQ8T8gBc0xN5z4gzed109aF6x7D7umpDRoimacI/O6d9UGuyl4eYuDCZmDFr2Rq7aGOw== dependencies: - "@marijn/find-cluster-break" "^1.0.0" + "@codemirror/text" "^0.19.0" -"@codemirror/state@^0.19.0", "@codemirror/state@^0.19.2", "@codemirror/state@^0.19.3": +"@codemirror/state@^0.19.2", "@codemirror/state@^0.19.3": version "0.19.9" resolved "https://registry.npmjs.org/@codemirror/state/-/state-0.19.9.tgz" integrity sha512-psOzDolKTZkx4CgUqhBQ8T8gBc0xN5z4gzed109aF6x7D7umpDRoimacI/O6d9UGuyl4eYuDCZmDFr2Rq7aGOw== dependencies: "@codemirror/text" "^0.19.0" +"@codemirror/state@^6.0.0", "@codemirror/state@^6.4.0", "@codemirror/state@^6.5.0", "@codemirror/state@6.5.4": + version "6.5.4" + resolved "https://registry.npmjs.org/@codemirror/state/-/state-6.5.4.tgz" + integrity sha512-8y7xqG/hpB53l25CIoit9/ngxdfoG+fx+V3SHBrinnhOtLvKHRyAJJuHzkWrR4YXXLX8eXBsejgAAxHUOdW1yw== + dependencies: + "@marijn/find-cluster-break" "^1.0.0" + "@codemirror/text@^0.19.0": version "0.19.6" resolved "https://registry.npmjs.org/@codemirror/text/-/text-0.19.6.tgz" integrity sha512-T9jnREMIygx+TPC1bOuepz18maGq/92q2a+n4qTqObKwvNMg+8cMTslb8yxeEDEq7S3kpgGWxgO1UWbQRij0dA== -"@codemirror/view@6.39.15", "@codemirror/view@^6.0.0", "@codemirror/view@^6.17.0", "@codemirror/view@^6.23.0", "@codemirror/view@^6.27.0", "@codemirror/view@^6.35.0": - version "6.39.15" - resolved "https://registry.npmjs.org/@codemirror/view/-/view-6.39.15.tgz" - integrity sha512-aCWjgweIIXLBHh7bY6cACvXuyrZ0xGafjQ2VInjp4RM4gMfscK5uESiNdrH0pE+e1lZr2B4ONGsjchl2KsKZzg== - dependencies: - "@codemirror/state" "^6.5.0" - crelt "^1.0.6" - style-mod "^4.1.0" - w3c-keyname "^2.2.4" - "@codemirror/view@^0.19.0": version "0.19.48" resolved "https://registry.npmjs.org/@codemirror/view/-/view-0.19.48.tgz" @@ -1222,6 +1226,16 @@ style-mod "^4.0.0" w3c-keyname "^2.2.4" +"@codemirror/view@^6.0.0", "@codemirror/view@^6.17.0", "@codemirror/view@^6.23.0", "@codemirror/view@^6.27.0", "@codemirror/view@^6.35.0", "@codemirror/view@6.39.15": + version "6.39.15" + resolved "https://registry.npmjs.org/@codemirror/view/-/view-6.39.15.tgz" + integrity sha512-aCWjgweIIXLBHh7bY6cACvXuyrZ0xGafjQ2VInjp4RM4gMfscK5uESiNdrH0pE+e1lZr2B4ONGsjchl2KsKZzg== + dependencies: + "@codemirror/state" "^6.5.0" + crelt "^1.0.6" + style-mod "^4.1.0" + w3c-keyname "^2.2.4" + "@config-plugins/react-native-webrtc@^13.0.0": version "13.0.0" resolved "https://registry.npmjs.org/@config-plugins/react-native-webrtc/-/react-native-webrtc-13.0.0.tgz" @@ -1252,12 +1266,12 @@ "@csstools/color-helpers" "^5.1.0" "@csstools/css-calc" "^2.1.4" -"@csstools/css-parser-algorithms@^3.0.4": +"@csstools/css-parser-algorithms@^3.0.4", "@csstools/css-parser-algorithms@^3.0.5": version "3.0.5" resolved "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz" integrity sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ== -"@csstools/css-tokenizer@^3.0.3": +"@csstools/css-tokenizer@^3.0.3", "@csstools/css-tokenizer@^3.0.4": version "3.0.4" resolved "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz" integrity sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw== @@ -1279,7 +1293,7 @@ resolved "https://registry.npmjs.org/@electric-sql/pglite-socket/-/pglite-socket-0.0.20.tgz" integrity sha512-J5nLGsicnD9wJHnno9r+DGxfcZWh+YJMCe0q/aCgtG6XOm9Z7fKeite8IZSNXgZeGltSigM9U/vAWZQWdgcSFg== -"@electric-sql/pglite@^0.3.15": +"@electric-sql/pglite@^0.3.15", "@electric-sql/pglite@0.3.15": version "0.3.15" resolved "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.3.15.tgz" integrity sha512-Cj++n1Mekf9ETfdc16TlDi+cDDQF0W7EcbyRHYOAeZdsAe8M/FJg18itDTSwyHfar2WIezawM9o0EKaRGVKygQ== @@ -1306,427 +1320,15 @@ dependencies: "@elevenlabs/client" "0.12.2" -"@elevenlabs/types@0.4.0": - version "0.4.0" - resolved "https://registry.npmjs.org/@elevenlabs/types/-/types-0.4.0.tgz" - integrity sha512-6CQb4r9DjepILhLybDxW0h8rvbsR8jPAp74HvFGYuvYdXcQRTkYTD9BQ4PV6VcuC1iqx0UljzJj8js3ADQ8EXw== - -"@emnapi/core@^1.7.1": - version "1.8.1" - resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.8.1.tgz#fd9efe721a616288345ffee17a1f26ac5dd01349" - integrity sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg== - dependencies: - "@emnapi/wasi-threads" "1.1.0" - tslib "^2.4.0" - -"@emnapi/runtime@^1.7.0", "@emnapi/runtime@^1.7.1": - version "1.8.1" - resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.8.1.tgz#550fa7e3c0d49c5fb175a116e8cd70614f9a22a5" - integrity sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg== - dependencies: - tslib "^2.4.0" - -"@emnapi/wasi-threads@1.1.0", "@emnapi/wasi-threads@^1.1.0": - version "1.1.0" - resolved "https://registry.yarnpkg.com/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz#60b2102fddc9ccb78607e4a3cf8403ea69be41bf" - integrity sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ== - dependencies: - tslib "^2.4.0" - -"@epic-web/invariant@^1.0.0": - version "1.0.0" - resolved "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz" - integrity sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA== - -"@esbuild/aix-ppc64@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz#80fcbe36130e58b7670511e888b8e88a259ed76c" - integrity sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA== - -"@esbuild/aix-ppc64@0.26.0": - version "0.26.0" - resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.26.0.tgz#e1d4bed341913e20caf2c2cb3a77b9662a8e40f1" - integrity sha512-hj0sKNCQOOo2fgyII3clmJXP28VhgDfU5iy3GNHlWO76KG6N7x4D9ezH5lJtQTG+1J6MFDAJXC1qsI+W+LvZoA== - -"@esbuild/aix-ppc64@0.27.2": - version "0.27.2" - resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz#521cbd968dcf362094034947f76fa1b18d2d403c" - integrity sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw== - -"@esbuild/android-arm64@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz#8aa4965f8d0a7982dc21734bf6601323a66da752" - integrity sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg== - -"@esbuild/android-arm64@0.26.0": - version "0.26.0" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.26.0.tgz#0a871d1332467be244101f757213a4dfc32ee93c" - integrity sha512-DDnoJ5eoa13L8zPh87PUlRd/IyFaIKOlRbxiwcSbeumcJ7UZKdtuMCHa1Q27LWQggug6W4m28i4/O2qiQQ5NZQ== - -"@esbuild/android-arm64@0.27.2": - version "0.27.2" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz#61ea550962d8aa12a9b33194394e007657a6df57" - integrity sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA== - -"@esbuild/android-arm@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.25.12.tgz#300712101f7f50f1d2627a162e6e09b109b6767a" - integrity sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg== - -"@esbuild/android-arm@0.26.0": - version "0.26.0" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.26.0.tgz#6615de6d8fe792f8abd752cf0118305a171e15cd" - integrity sha512-C0hkDsYNHZkBtPxxDx177JN90/1MiCpvBNjz1f5yWJo1+5+c5zr8apjastpEG+wtPjo9FFtGG7owSsAxyKiHxA== - -"@esbuild/android-arm@0.27.2": - version "0.27.2" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.27.2.tgz#554887821e009dd6d853f972fde6c5143f1de142" - integrity sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA== - -"@esbuild/android-x64@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.25.12.tgz#87dfb27161202bdc958ef48bb61b09c758faee16" - integrity sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg== - -"@esbuild/android-x64@0.26.0": - version "0.26.0" - resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.26.0.tgz#c9e69f9389893392aed36824677c2594ae2445e2" - integrity sha512-bKDkGXGZnj0T70cRpgmv549x38Vr2O3UWLbjT2qmIkdIWcmlg8yebcFWoT9Dku7b5OV3UqPEuNKRzlNhjwUJ9A== - -"@esbuild/android-x64@0.27.2": - version "0.27.2" - resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.27.2.tgz#a7ce9d0721825fc578f9292a76d9e53334480ba2" - integrity sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A== - -"@esbuild/darwin-arm64@0.25.12": - version "0.25.12" - resolved "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz" - integrity sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg== - -"@esbuild/darwin-arm64@0.26.0": - version "0.26.0" - resolved "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.26.0.tgz" - integrity sha512-6Z3naJgOuAIB0RLlJkYc81An3rTlQ/IeRdrU3dOea8h/PvZSgitZV+thNuIccw0MuK1GmIAnAmd5TrMZad8FTQ== - -"@esbuild/darwin-arm64@0.27.2": - version "0.27.2" - resolved "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz" - integrity sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg== - -"@esbuild/darwin-x64@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz#146400a8562133f45c4d2eadcf37ddd09718079e" - integrity sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA== - -"@esbuild/darwin-x64@0.26.0": - version "0.26.0" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.26.0.tgz#ea6d99e8c706318ce0171e85eb61ebb6e1a3e8f3" - integrity sha512-OPnYj0zpYW0tHusMefyaMvNYQX5pNQuSsHFTHUBNp3vVXupwqpxofcjVsUx11CQhGVkGeXjC3WLjh91hgBG2xw== - -"@esbuild/darwin-x64@0.27.2": - version "0.27.2" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz#e741fa6b1abb0cd0364126ba34ca17fd5e7bf509" - integrity sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA== - -"@esbuild/freebsd-arm64@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz#1c5f9ba7206e158fd2b24c59fa2d2c8bb47ca0fe" - integrity sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg== - -"@esbuild/freebsd-arm64@0.26.0": - version "0.26.0" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.26.0.tgz#9acd33e588d2e1edddf6ee0e5a5272336b6ffcc1" - integrity sha512-jix2fa6GQeZhO1sCKNaNMjfj5hbOvoL2F5t+w6gEPxALumkpOV/wq7oUBMHBn2hY2dOm+mEV/K+xfZy3mrsxNQ== - -"@esbuild/freebsd-arm64@0.27.2": - version "0.27.2" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz#2b64e7116865ca172d4ce034114c21f3c93e397c" - integrity sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g== - -"@esbuild/freebsd-x64@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz#ea631f4a36beaac4b9279fa0fcc6ca29eaeeb2b3" - integrity sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ== - -"@esbuild/freebsd-x64@0.26.0": - version "0.26.0" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.26.0.tgz#c187f1f56731ab17861b9d3b00a9ddd7fbf6e46d" - integrity sha512-tccJaH5xHJD/239LjbVvJwf6T4kSzbk6wPFerF0uwWlkw/u7HL+wnAzAH5GB2irGhYemDgiNTp8wJzhAHQ64oA== - -"@esbuild/freebsd-x64@0.27.2": - version "0.27.2" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz#e5252551e66f499e4934efb611812f3820e990bb" - integrity sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA== - -"@esbuild/linux-arm64@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz#e1066bce58394f1b1141deec8557a5f0a22f5977" - integrity sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ== - -"@esbuild/linux-arm64@0.26.0": - version "0.26.0" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.26.0.tgz#e516c7062d0b7f19cd139a5b597cd827d1e843ee" - integrity sha512-IMJYN7FSkLttYyTbsbme0Ra14cBO5z47kpamo16IwggzzATFY2lcZAwkbcNkWiAduKrTgFJP7fW5cBI7FzcuNQ== - -"@esbuild/linux-arm64@0.27.2": - version "0.27.2" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz#dc4acf235531cd6984f5d6c3b13dbfb7ddb303cb" - integrity sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw== - -"@esbuild/linux-arm@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz#452cd66b20932d08bdc53a8b61c0e30baf4348b9" - integrity sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw== - -"@esbuild/linux-arm@0.26.0": - version "0.26.0" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.26.0.tgz#f26b22a0c80ac1ea73afd33c4e5034c69ae9ab14" - integrity sha512-JY8NyU31SyRmRpuc5W8PQarAx4TvuYbyxbPIpHAZdr/0g4iBr8KwQBS4kiiamGl2f42BBecHusYCsyxi7Kn8UQ== - -"@esbuild/linux-arm@0.27.2": - version "0.27.2" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz#56a900e39240d7d5d1d273bc053daa295c92e322" - integrity sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw== - -"@esbuild/linux-ia32@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz#b24f8acc45bcf54192c7f2f3be1b53e6551eafe0" - integrity sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA== - -"@esbuild/linux-ia32@0.26.0": - version "0.26.0" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.26.0.tgz#22b310b307f68b57a28d25c662758a865555b6dc" - integrity sha512-XITaGqGVLgk8WOHw8We9Z1L0lbLFip8LyQzKYFKO4zFo1PFaaSKsbNjvkb7O8kEXytmSGRkYpE8LLVpPJpsSlw== - -"@esbuild/linux-ia32@0.27.2": - version "0.27.2" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz#d4a36d473360f6870efcd19d52bbfff59a2ed1cc" - integrity sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w== - -"@esbuild/linux-loong64@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz#f9cfffa7fc8322571fbc4c8b3268caf15bd81ad0" - integrity sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng== - -"@esbuild/linux-loong64@0.26.0": - version "0.26.0" - resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.26.0.tgz#39027e0050ec9b275432814f47d3325562c62565" - integrity sha512-MkggfbDIczStUJwq9wU7gQ7kO33d8j9lWuOCDifN9t47+PeI+9m2QVh51EI/zZQ1spZtFMC1nzBJ+qNGCjJnsg== - -"@esbuild/linux-loong64@0.27.2": - version "0.27.2" - resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz#fcf0ab8c3eaaf45891d0195d4961cb18b579716a" - integrity sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg== - -"@esbuild/linux-mips64el@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz#575a14bd74644ffab891adc7d7e60d275296f2cd" - integrity sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw== - -"@esbuild/linux-mips64el@0.26.0": - version "0.26.0" - resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.26.0.tgz#d30185dc4298bd2470b21e6358f4c0e42cd8ecfc" - integrity sha512-fUYup12HZWAeccNLhQ5HwNBPr4zXCPgUWzEq2Rfw7UwqwfQrFZ0SR/JljaURR8xIh9t+o1lNUFTECUTmaP7yKA== - -"@esbuild/linux-mips64el@0.27.2": - version "0.27.2" - resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz#598b67d34048bb7ee1901cb12e2a0a434c381c10" - integrity sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw== - -"@esbuild/linux-ppc64@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz#75b99c70a95fbd5f7739d7692befe60601591869" - integrity sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA== - -"@esbuild/linux-ppc64@0.26.0": - version "0.26.0" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.26.0.tgz#8df55e65fd98a4244aaea649ba9745998f82a2e8" - integrity sha512-MzRKhM0Ip+//VYwC8tialCiwUQ4G65WfALtJEFyU0GKJzfTYoPBw5XNWf0SLbCUYQbxTKamlVwPmcw4DgZzFxg== - -"@esbuild/linux-ppc64@0.27.2": - version "0.27.2" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz#3846c5df6b2016dab9bc95dde26c40f11e43b4c0" - integrity sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ== - -"@esbuild/linux-riscv64@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz#2e3259440321a44e79ddf7535c325057da875cd6" - integrity sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w== - -"@esbuild/linux-riscv64@0.26.0": - version "0.26.0" - resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.26.0.tgz#406e26b21646d19bf2e58a187a53fec719465512" - integrity sha512-QhCc32CwI1I4Jrg1enCv292sm3YJprW8WHHlyxJhae/dVs+KRWkbvz2Nynl5HmZDW/m9ZxrXayHzjzVNvQMGQA== - -"@esbuild/linux-riscv64@0.27.2": - version "0.27.2" - resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz#173d4475b37c8d2c3e1707e068c174bb3f53d07d" - integrity sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA== - -"@esbuild/linux-s390x@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz#17676cabbfe5928da5b2a0d6df5d58cd08db2663" - integrity sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg== - -"@esbuild/linux-s390x@0.26.0": - version "0.26.0" - resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.26.0.tgz#a3c55edb6f19b2ff737f3d5d238bbcaf58815652" - integrity sha512-1D6vi6lfI18aNT1aTf2HV+RIlm6fxtlAp8eOJ4mmnbYmZ4boz8zYDar86sIYNh0wmiLJEbW/EocaKAX6Yso2fw== - -"@esbuild/linux-s390x@0.27.2": - version "0.27.2" - resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz#f7a4790105edcab8a5a31df26fbfac1aa3dacfab" - integrity sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w== - -"@esbuild/linux-x64@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz#0583775685ca82066d04c3507f09524d3cd7a306" - integrity sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw== - -"@esbuild/linux-x64@0.26.0": - version "0.26.0" - resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.26.0.tgz#d60f139897b12e5af37113d6a433a7ef27c6d87f" - integrity sha512-rnDcepj7LjrKFvZkx+WrBv6wECeYACcFjdNPvVPojCPJD8nHpb3pv3AuR9CXgdnjH1O23btICj0rsp0L9wAnHA== - -"@esbuild/linux-x64@0.27.2": - version "0.27.2" - resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz#2ecc1284b1904aeb41e54c9ddc7fcd349b18f650" - integrity sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA== - -"@esbuild/netbsd-arm64@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz#f04c4049cb2e252fe96b16fed90f70746b13f4a4" - integrity sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg== - -"@esbuild/netbsd-arm64@0.26.0": - version "0.26.0" - resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.26.0.tgz#a89594f82f8172b127d7a59988dbde1a56a4d03e" - integrity sha512-FSWmgGp0mDNjEXXFcsf12BmVrb+sZBBBlyh3LwB/B9ac3Kkc8x5D2WimYW9N7SUkolui8JzVnVlWh7ZmjCpnxw== - -"@esbuild/netbsd-arm64@0.27.2": - version "0.27.2" - resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz#e2863c2cd1501845995cb11adf26f7fe4be527b0" - integrity sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw== - -"@esbuild/netbsd-x64@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz#77da0d0a0d826d7c921eea3d40292548b258a076" - integrity sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ== - -"@esbuild/netbsd-x64@0.26.0": - version "0.26.0" - resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.26.0.tgz#22c396c61067d4057d3c8b1e0b7481422bcf70f5" - integrity sha512-0QfciUDFryD39QoSPUDshj4uNEjQhp73+3pbSAaxjV2qGOEDsM67P7KbJq7LzHoVl46oqhIhJ1S+skKGR7lMXA== - -"@esbuild/netbsd-x64@0.27.2": - version "0.27.2" - resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz#93f7609e2885d1c0b5a1417885fba8d1fcc41272" - integrity sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA== - -"@esbuild/openbsd-arm64@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz#6296f5867aedef28a81b22ab2009c786a952dccd" - integrity sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A== - -"@esbuild/openbsd-arm64@0.26.0": - version "0.26.0" - resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.26.0.tgz#302f9602161cf1556dfc3574f6ff895f92566221" - integrity sha512-vmAK+nHhIZWImwJ3RNw9hX3fU4UGN/OqbSE0imqljNbUQC3GvVJ1jpwYoTfD6mmXmQaxdJY6Hn4jQbLGJKg5Yw== - -"@esbuild/openbsd-arm64@0.27.2": - version "0.27.2" - resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz#a1985604a203cdc325fd47542e106fafd698f02e" - integrity sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA== - -"@esbuild/openbsd-x64@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz#f8d23303360e27b16cf065b23bbff43c14142679" - integrity sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw== - -"@esbuild/openbsd-x64@0.26.0": - version "0.26.0" - resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.26.0.tgz#2d702092fe56dcc9f77932733898900b077e195a" - integrity sha512-GPXF7RMkJ7o9bTyUsnyNtrFMqgM3X+uM/LWw4CeHIjqc32fm0Ir6jKDnWHpj8xHFstgWDUYseSABK9KCkHGnpg== - -"@esbuild/openbsd-x64@0.27.2": - version "0.27.2" - resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz#8209e46c42f1ffbe6e4ef77a32e1f47d404ad42a" - integrity sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg== - -"@esbuild/openharmony-arm64@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz#49e0b768744a3924be0d7fd97dd6ce9b2923d88d" - integrity sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg== - -"@esbuild/openharmony-arm64@0.26.0": - version "0.26.0" - resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.26.0.tgz#b23c269bfe5583f2ffecf2778200194756652262" - integrity sha512-nUHZ5jEYqbBthbiBksbmHTlbb5eElyVfs/s1iHQ8rLBq1eWsd5maOnDpCocw1OM8kFK747d1Xms8dXJHtduxSw== - -"@esbuild/openharmony-arm64@0.27.2": - version "0.27.2" - resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz#8fade4441893d9cc44cbd7dcf3776f508ab6fb2f" - integrity sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag== - -"@esbuild/sunos-x64@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz#a6ed7d6778d67e528c81fb165b23f4911b9b13d6" - integrity sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w== - -"@esbuild/sunos-x64@0.26.0": - version "0.26.0" - resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.26.0.tgz#8579737e0e147af566070fdcf691b79cf508666c" - integrity sha512-TMg3KCTCYYaVO+R6P5mSORhcNDDlemUVnUbb8QkboUtOhb5JWKAzd5uMIMECJQOxHZ/R+N8HHtDF5ylzLfMiLw== - -"@esbuild/sunos-x64@0.27.2": - version "0.27.2" - resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz#980d4b9703a16f0f07016632424fc6d9a789dfc2" - integrity sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg== - -"@esbuild/win32-arm64@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz#9ac14c378e1b653af17d08e7d3ce34caef587323" - integrity sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg== - -"@esbuild/win32-arm64@0.26.0": - version "0.26.0" - resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.26.0.tgz#6761aab78c8b7d397a82d73c545de0410e61a5f7" - integrity sha512-apqYgoAUd6ZCb9Phcs8zN32q6l0ZQzQBdVXOofa6WvHDlSOhwCWgSfVQabGViThS40Y1NA4SCvQickgZMFZRlA== - -"@esbuild/win32-arm64@0.27.2": - version "0.27.2" - resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz#1c09a3633c949ead3d808ba37276883e71f6111a" - integrity sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg== - -"@esbuild/win32-ia32@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz#918942dcbbb35cc14fca39afb91b5e6a3d127267" - integrity sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ== - -"@esbuild/win32-ia32@0.26.0": - version "0.26.0" - resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.26.0.tgz#22bef8d60489e4edd3646154ef23d0bdab54b12a" - integrity sha512-FGJAcImbJNZzLWu7U6WB0iKHl4RuY4TsXEwxJPl9UZLS47agIZuILZEX3Pagfw7I4J3ddflomt9f0apfaJSbaw== - -"@esbuild/win32-ia32@0.27.2": - version "0.27.2" - resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz#1b1e3a63ad4bef82200fef4e369e0fff7009eee5" - integrity sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ== - -"@esbuild/win32-x64@0.25.12": - version "0.25.12" - resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz#9bdad8176be7811ad148d1f8772359041f46c6c5" - integrity sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA== - -"@esbuild/win32-x64@0.26.0": - version "0.26.0" - resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.26.0.tgz#5fdb98bc8d6f767132363ab9527184c073773853" - integrity sha512-WAckBKaVnmFqbEhbymrPK7M086DQMpL1XoRbpmN0iW8k5JSXjDRQBhcZNa0VweItknLq9eAeCL34jK7/CDcw7A== +"@elevenlabs/types@0.4.0": + version "0.4.0" + resolved "https://registry.npmjs.org/@elevenlabs/types/-/types-0.4.0.tgz" + integrity sha512-6CQb4r9DjepILhLybDxW0h8rvbsR8jPAp74HvFGYuvYdXcQRTkYTD9BQ4PV6VcuC1iqx0UljzJj8js3ADQ8EXw== -"@esbuild/win32-x64@0.27.2": - version "0.27.2" - resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz#9e585ab6086bef994c6e8a5b3a0481219ada862b" - integrity sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ== +"@epic-web/invariant@^1.0.0": + version "1.0.0" + resolved "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz" + integrity sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA== "@eslint-community/eslint-utils@^4.8.0": version "4.9.1" @@ -1879,7 +1481,7 @@ dependencies: node-forge "^1.3.3" -"@expo/config-plugins@~54.0.1", "@expo/config-plugins@~54.0.4": +"@expo/config-plugins@*", "@expo/config-plugins@~54.0.1", "@expo/config-plugins@~54.0.4": version "54.0.4" resolved "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-54.0.4.tgz" integrity sha512-g2yXGICdoOw5i3LkQSDxl2Q5AlQCrG7oniu0pCPPO+UxGb7He4AFqSvPSy8HpRUj55io17hT62FTjYRD+d6j3Q== @@ -1990,7 +1592,7 @@ "@babel/code-frame" "~7.10.4" json5 "^2.2.3" -"@expo/metro-config@54.0.14", "@expo/metro-config@~54.0.14": +"@expo/metro-config@~54.0.14", "@expo/metro-config@54.0.14": version "54.0.14" resolved "https://registry.npmjs.org/@expo/metro-config/-/metro-config-54.0.14.tgz" integrity sha512-hxpLyDfOR4L23tJ9W1IbJJsG7k4lv2sotohBm/kTYyiG+pe1SYCAWsRmgk+H42o/wWf/HQjE5k45S5TomGLxNA== @@ -2017,7 +1619,7 @@ postcss "~8.4.32" resolve-from "^5.0.0" -"@expo/metro-runtime@^6.1.2": +"@expo/metro-runtime@*", "@expo/metro-runtime@^6.1.2": version "6.1.2" resolved "https://registry.npmjs.org/@expo/metro-runtime/-/metro-runtime-6.1.2.tgz" integrity sha512-nvM+Qv45QH7pmYvP8JB1G8JpScrWND3KrMA6ZKe62cwwNiX/BjHU28Ear0v/4bQWXlOY0mv6B8CDIm8JxXde9g== @@ -2047,61 +1649,6 @@ metro-transform-plugins "0.83.3" metro-transform-worker "0.83.3" -"@expo/ngrok-bin-darwin-arm64@2.3.41": - version "2.3.41" - resolved "https://registry.npmjs.org/@expo/ngrok-bin-darwin-arm64/-/ngrok-bin-darwin-arm64-2.3.41.tgz" - integrity sha512-TPf95xp6SkvbRONZjltTOFcCJbmzAH7lrQ36Dv+djrOckWGPVq4HCur48YAeiGDqspmFEmqZ7ykD5c/bDfRFOA== - -"@expo/ngrok-bin-darwin-x64@2.3.41": - version "2.3.41" - resolved "https://registry.yarnpkg.com/@expo/ngrok-bin-darwin-x64/-/ngrok-bin-darwin-x64-2.3.41.tgz#ff006f92c01887070d2302c67455c53d43ccb9f1" - integrity sha512-29QZHfX4Ec0p0pQF5UrqiP2/Qe7t2rI96o+5b8045VCEl9AEAKHceGuyo+jfUDR4FSQBGFLSDb06xy8ghL3ZYA== - -"@expo/ngrok-bin-freebsd-ia32@2.3.41": - version "2.3.41" - resolved "https://registry.yarnpkg.com/@expo/ngrok-bin-freebsd-ia32/-/ngrok-bin-freebsd-ia32-2.3.41.tgz#c488ce27823c199e2876eb80c5049856a0541b1d" - integrity sha512-YYXgwNZ+p0aIrwgb+1/RxJbsWhGEzBDBhZulKg1VB7tKDAd2C8uGnbK1rOCuZy013iOUsJDXaj9U5QKc13iIXw== - -"@expo/ngrok-bin-freebsd-x64@2.3.41": - version "2.3.41" - resolved "https://registry.yarnpkg.com/@expo/ngrok-bin-freebsd-x64/-/ngrok-bin-freebsd-x64-2.3.41.tgz#9e17cf0b4cd76dd1050db40463dfc9fc9b43938c" - integrity sha512-1Ei6K8BB+3etmmBT0tXYC4dyVkJMigT4ELbRTF5jKfw1pblqeXM9Qpf3p8851PTlH142S3bockCeO39rSkOnkg== - -"@expo/ngrok-bin-linux-arm64@2.3.41": - version "2.3.41" - resolved "https://registry.yarnpkg.com/@expo/ngrok-bin-linux-arm64/-/ngrok-bin-linux-arm64-2.3.41.tgz#dc7c761b7f12d8baae997565c360939b34da78f0" - integrity sha512-eC8GA/xPcmQJy4h+g2FlkuQB3lf5DjITy8Y6GyydmPYMByjUYAGEXe0brOcP893aalAzRqbNOAjSuAw1lcCLSQ== - -"@expo/ngrok-bin-linux-arm@2.3.41": - version "2.3.41" - resolved "https://registry.yarnpkg.com/@expo/ngrok-bin-linux-arm/-/ngrok-bin-linux-arm-2.3.41.tgz#c914a3e86d166e0595d13b3e501535bf441834ca" - integrity sha512-B6+rW/+tEi7ZrKWQGkRzlwmKo7c1WJhNODFBSgkF/Sj9PmmNhBz67mer91S2+6nNt5pfcwLLd61CjtWfR1LUHQ== - -"@expo/ngrok-bin-linux-ia32@2.3.41": - version "2.3.41" - resolved "https://registry.yarnpkg.com/@expo/ngrok-bin-linux-ia32/-/ngrok-bin-linux-ia32-2.3.41.tgz#5079fc5aa2f886d6e33a25e913ec240be57eac73" - integrity sha512-w5Cy31wSz4jYnygEHS7eRizR1yt8s9TX6kHlkjzayIiRTFRb2E1qD2l0/4T2w0LJpBjM5ZFPaaKqsNWgCUIEow== - -"@expo/ngrok-bin-linux-x64@2.3.41": - version "2.3.41" - resolved "https://registry.yarnpkg.com/@expo/ngrok-bin-linux-x64/-/ngrok-bin-linux-x64-2.3.41.tgz#34c117c14b3d82f6d51cd25df025d9679d866577" - integrity sha512-LcU3MbYHv7Sn2eFz8Yzo2rXduufOvX1/hILSirwCkH+9G8PYzpwp2TeGqVWuO+EmvtBe6NEYwgdQjJjN6I4L1A== - -"@expo/ngrok-bin-sunos-x64@2.3.41": - version "2.3.41" - resolved "https://registry.yarnpkg.com/@expo/ngrok-bin-sunos-x64/-/ngrok-bin-sunos-x64-2.3.41.tgz#8ab01f96344d5c2aae2944009fbea67aed74c022" - integrity sha512-bcOj45BLhiV2PayNmLmEVZlFMhEiiGpOr36BXC0XSL+cHUZHd6uNaS28AaZdz95lrRzGpeb0hAF8cuJjo6nq4g== - -"@expo/ngrok-bin-win32-ia32@2.3.41": - version "2.3.41" - resolved "https://registry.yarnpkg.com/@expo/ngrok-bin-win32-ia32/-/ngrok-bin-win32-ia32-2.3.41.tgz#b5fbcd49f2ae38fdb4105f8248159dc75c9ba9e3" - integrity sha512-0+vPbKvUA+a9ERgiAknmZCiWA3AnM5c6beI+51LqmjKEM4iAAlDmfXNJ89aAbvZMUtBNwEPHzJHnaM4s2SeBhA== - -"@expo/ngrok-bin-win32-x64@2.3.41": - version "2.3.41" - resolved "https://registry.yarnpkg.com/@expo/ngrok-bin-win32-x64/-/ngrok-bin-win32-x64-2.3.41.tgz#91c56b695c9c8d81424a335795e204d7bd011fcf" - integrity sha512-mncsPRaG462LiYrM8mQT8OYe3/i44m3N/NzUeieYpGi8+pCOo8TIC23kR9P93CVkbM9mmXsy3X6hq91a8FWBdA== - "@expo/ngrok-bin@2.3.42": version "2.3.42" resolved "https://registry.npmjs.org/@expo/ngrok-bin/-/ngrok-bin-2.3.42.tgz" @@ -2292,7 +1839,7 @@ fastify-plugin "^5.0.0" toad-cache "^3.7.0" -"@fastify/swagger@9.7.0": +"@fastify/swagger@>=9.5.1", "@fastify/swagger@9.7.0": version "9.7.0" resolved "https://registry.npmjs.org/@fastify/swagger/-/swagger-9.7.0.tgz" integrity sha512-Vp1SC1GC2Hrkd3faFILv86BzUNyFz5N4/xdExqtCgkGASOzn/x+eMe4qXIGq7cdT6wif/P/oa6r1Ruqx19paZA== @@ -2303,19 +1850,18 @@ rfdc "^1.3.1" yaml "^2.4.2" -"@floating-ui/core@^1.7.3", "@floating-ui/core@^1.7.4": +"@floating-ui/core@^1.7.3": version "1.7.4" resolved "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.4.tgz" integrity sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg== dependencies: "@floating-ui/utils" "^0.2.10" -"@floating-ui/dom@1.7.4": +"@floating-ui/core@^1.7.4": version "1.7.4" - resolved "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz" - integrity sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA== + resolved "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.4.tgz" + integrity sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg== dependencies: - "@floating-ui/core" "^1.7.3" "@floating-ui/utils" "^0.2.10" "@floating-ui/dom@^1.7.5": @@ -2326,6 +1872,14 @@ "@floating-ui/core" "^1.7.4" "@floating-ui/utils" "^0.2.10" +"@floating-ui/dom@1.7.4": + version "1.7.4" + resolved "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz" + integrity sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA== + dependencies: + "@floating-ui/core" "^1.7.3" + "@floating-ui/utils" "^0.2.10" + "@floating-ui/react-dom@^2.0.0": version "2.1.7" resolved "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.7.tgz" @@ -2362,13 +1916,301 @@ postcss-selector-parser "^7.1.1" tailwind-merge "^3.4.0" -"@homebridge/node-pty-prebuilt-multiarch@^0.13.1": - version "0.13.1" - resolved "https://registry.npmjs.org/@homebridge/node-pty-prebuilt-multiarch/-/node-pty-prebuilt-multiarch-0.13.1.tgz" - integrity sha512-ccQ60nMcbEGrQh0U9E6x0ajW9qJNeazpcM/9CH6J8leyNtJgb+gu24WTBAfBUVeO486ZhscnaxLEITI2HXwhow== +"@happier-dev/agents@0.0.0", "@happier-dev/agents@file:/Users/cdenneen/code/workspace/work/happier-dev/packages/agents": + version "0.0.0" + resolved "file:packages/agents" + dependencies: + "@happier-dev/protocol" "0.0.0" + zod "4.3.6" + +"@happier-dev/app@file:/Users/cdenneen/code/workspace/work/happier-dev/apps/ui": + version "0.1.0" + resolved "file:apps/ui" + dependencies: + "@codemirror/autocomplete" "6.20.0" + "@codemirror/commands" "6.10.2" + "@codemirror/history" "0.19.2" + "@codemirror/lang-cpp" "6.0.3" + "@codemirror/lang-css" "6.3.1" + "@codemirror/lang-go" "6.0.1" + "@codemirror/lang-html" "6.4.11" + "@codemirror/lang-java" "6.0.2" + "@codemirror/lang-javascript" "6.2.4" + "@codemirror/lang-json" "6.0.2" + "@codemirror/lang-markdown" "6.5.0" + "@codemirror/lang-php" "6.0.2" + "@codemirror/lang-python" "6.2.1" + "@codemirror/lang-rust" "6.0.2" + "@codemirror/lang-sql" "6.10.0" + "@codemirror/lang-xml" "6.1.0" + "@codemirror/lang-yaml" "6.1.2" + "@codemirror/language" "6.12.1" + "@codemirror/legacy-modes" "6.5.2" + "@codemirror/state" "6.5.4" + "@codemirror/view" "6.39.15" + "@config-plugins/react-native-webrtc" "^13.0.0" + "@elevenlabs/react" "^0.12.3" + "@elevenlabs/react-native" "^0.5.7" + "@expo/vector-icons" "^15.0.2" + "@happier-dev/agents" "0.0.0" + "@happier-dev/audio-stream-native" "0.0.0" + "@happier-dev/protocol" "0.0.0" + "@happier-dev/sherpa-native" "0.0.0" + "@legendapp/list" "2.0.0-beta.3" + "@livekit/react-native" "^2.9.0" + "@livekit/react-native-expo-plugin" "^1.0.1" + "@livekit/react-native-webrtc" "^137.0.0" + "@more-tech/react-native-libsodium" "^1.5.5" + "@peoplesgrocers/seti-ui-file-icons" "1.11.3" + "@pierre/diffs" "1.1.0-beta.13" + "@radix-ui/react-dialog" "^1.1.15" + "@radix-ui/react-dismissable-layer" "^1.1.11" + "@react-native-async-storage/async-storage" "2.2.0" + "@react-native-masked-view/masked-view" "0.3.2" + "@react-navigation/drawer" "^7.3.9" + "@react-navigation/native" "^7.1.8" + "@revenuecat/purchases-js" "^1.11.1" + "@sentry/react-native" "^8.1.0" + "@shopify/flash-list" "2.0.2" + "@shopify/react-native-skia" "2.2.12" + "@tauri-apps/api" "~2.8.0" + "@tauri-apps/cli" "~2.8.2" + "@tauri-apps/plugin-http" "^2.5.2" + "@types/libsodium-wrappers" "^0.7.14" + "@types/qrcode" "^1.5.5" + "@types/uuid" "^10.0.0" + axios "^1.10.0" + color "^5.0.0" + diff "^8.0.2" + expo "^54.0.0" + expo-application "~7.0.7" + expo-asset "~12.0.9" + expo-audio "~1.0.13" + expo-blur "~15.0.7" + expo-camera "~17.0.8" + expo-clipboard "^8.0.7" + expo-constants "~18.0.9" + expo-crypto "~15.0.7" + expo-dev-client "~6.0.12" + expo-device "~8.0.8" + expo-document-picker "~14.0.8" + expo-file-system "~19.0.14" + expo-font "~14.0.8" + expo-haptics "~15.0.7" + expo-image "~3.0.8" + expo-linear-gradient "~15.0.7" + expo-linking "~8.0.8" + expo-localization "~17.0.7" + expo-mail-composer "~15.0.7" + expo-notifications "~0.32.11" + expo-router "6.0.22" + expo-secure-store "~15.0.7" + expo-speech "~14.0.8" + expo-speech-recognition "^3.1.0" + expo-splash-screen "~31.0.10" + expo-status-bar "~3.0.8" + expo-store-review "~9.0.7" + expo-system-ui "~6.0.7" + expo-updates "~29.0.11" + expo-web-browser "~15.0.7" + fuse.js "^7.1.0" + kokoro-js "^1.2.1" + libsodium-wrappers "0.8.2" + livekit-client "^2.15.4" + mermaid "^11.12.1" + monaco-editor "^0.55.1" + posthog-react-native "^4.16.2" + qrcode "^1.5.4" + react "19.1.0" + react-dom "19.1.0" + react-native "0.81.4" + react-native-audio-api "^0.8.2" + react-native-device-info "^14.1.1" + react-native-gesture-handler "~2.28.0" + react-native-keyboard-controller "1.18.5" + react-native-localize "^3.5.1" + react-native-mmkv "^3.3.3" + react-native-nitro-modules "0.33.2" + react-native-purchases "^9.4.2" + react-native-purchases-ui "^9.4.2" + react-native-quick-base64 "^2.2.1" + react-native-reanimated "^4.2.1" + react-native-safe-area-context "~5.6.0" + react-native-screen-transitions "^1.2.0" + react-native-screens "~4.16.0" + react-native-svg "15.12.1" + react-native-typography "^1.4.1" + react-native-unistyles "^3.0.21" + react-native-vision-camera "^4.7.3" + react-native-web "^0.21.0" + react-native-webview "13.15.0" + react-native-worklets "^0.7.1" + react-textarea-autosize "^8.5.9" + rn-encryption "^2.5.0" + shiki "3.22.0" + socket.io-client "^4.8.1" + uuid "^11.1.0" + vitest "^3.2.4" + zod "4.3.6" + zustand "^5.0.6" + +"@happier-dev/audio-stream-native@0.0.0", "@happier-dev/audio-stream-native@file:/Users/cdenneen/code/workspace/work/happier-dev/packages/audio-stream-native": + version "0.0.0" + resolved "file:packages/audio-stream-native" + dependencies: + expo-modules-core "*" + +"@happier-dev/cli-common@0.0.0", "@happier-dev/cli-common@file:/Users/cdenneen/code/workspace/work/happier-dev/packages/cli-common": + version "0.0.0" + resolved "file:packages/cli-common" + dependencies: + "@happier-dev/agents" "0.0.0" + +"@happier-dev/cli@file:/Users/cdenneen/code/workspace/work/happier-dev/apps/cli": + version "0.1.0" + resolved "file:apps/cli" + dependencies: + "@agentclientprotocol/sdk" "^0.14.1" + "@anthropic-ai/claude-agent-sdk" "^0.2.34" + "@fastify/swagger" "9.7.0" + "@happier-dev/agents" "0.0.0" + "@happier-dev/cli-common" "0.0.0" + "@happier-dev/protocol" "0.0.0" + "@happier-dev/release-runtime" "0.0.0" + "@huggingface/transformers" "^3.8.1" + "@modelcontextprotocol/sdk" "^1.25.3" + "@stablelib/base64" "^2.0.1" + "@stablelib/hex" "^2.0.1" + "@types/cross-spawn" "^6.0.6" + "@types/http-proxy" "^1.17.17" + "@types/ps-list" "^6.2.1" + "@types/qrcode-terminal" "^0.12.2" + "@types/react" "^19.2.7" + "@types/tmp" "^0.2.6" + ai "^5.0.107" + axios "^1.13.2" + chalk "^5.6.2" + cross-spawn "^7.0.6" + expo-server-sdk "^3.15.0" + fastify "^5.6.2" + fastify-type-provider-zod "6.1.0" + http-proxy "^1.18.1" + http-proxy-middleware "^3.0.5" + https-proxy-agent "^7.0.6" + ink "^6.5.1" + open "^10.2.0" + openapi-types "12.1.3" + ps-list "^8.1.1" + qrcode-terminal "^0.12.0" + react "^19.2.0" + react-devtools-core "^6.1.5" + socket.io-client "^4.8.1" + tar "^7.5.2" + tmp "^0.2.5" + tweetnacl "^1.0.3" + zod "4.3.6" + +"@happier-dev/protocol@0.0.0", "@happier-dev/protocol@file:/Users/cdenneen/code/workspace/work/happier-dev/packages/protocol": + version "0.0.0" + resolved "file:packages/protocol" + dependencies: + "@noble/hashes" "^1.8.0" + base64-js "^1.5.1" + tweetnacl "^1.0.3" + zod "4.3.6" + zod-to-json-schema "3.25.1" + +"@happier-dev/relay-server@file:/Users/cdenneen/code/workspace/work/happier-dev/packages/relay-server": + version "0.1.2" + resolved "file:packages/relay-server" + dependencies: + "@happier-dev/release-runtime" "0.0.0" + +"@happier-dev/release-runtime@0.0.0", "@happier-dev/release-runtime@file:/Users/cdenneen/code/workspace/work/happier-dev/packages/release-runtime": + version "0.0.0" + resolved "file:packages/release-runtime" + +"@happier-dev/server@file:/Users/cdenneen/code/workspace/work/happier-dev/apps/server": + version "0.1.2" + resolved "file:apps/server" + dependencies: + "@date-fns/tz" "^1.2.0" + "@electric-sql/pglite" "^0.3.15" + "@electric-sql/pglite-socket" "^0.0.20" + "@fastify/bearer-auth" "^10.1.1" + "@fastify/cors" "^10.0.1" + "@fastify/rate-limit" "^10.3.0" + "@fastify/swagger" "9.7.0" + "@happier-dev/agents" "0.0.0" + "@happier-dev/protocol" "0.0.0" + "@prisma/client" "^6.11.1" + "@sentry/node" "10.39.0" + "@sentry/profiling-node" "^10.39.0" + "@socket.io/redis-streams-adapter" "^0.2.2" + "@types/jsonwebtoken" "^9.0.10" + "@types/semver" "^7.7.0" + axios "^1.6.8" + chalk "4.1.2" + cron-parser "^5.5.0" + date-fns "^4.1.0" + dotenv "^16.4.5" + elevenlabs "^1.54.0" + fastify "^5.2.0" + fastify-type-provider-zod "6.1.0" + ioredis "^5.6.1" + jsonwebtoken "^9.0.2" + minio "^8.0.5" + octokit "^5.0.3" + openapi-types "12.1.3" + openid-client "^6.0.0" + pino-pretty "^13.0.0" + prisma "^6.11.1" + prisma-json-types-generator "^3.5.1" + privacy-kit "^0.0.25" + prom-client "^15.1.3" + reflect-metadata "^0.2.2" + semver "^7.7.2" + sharp "^0.34.3" + socket.io "^4.8.1" + socket.io-adapter "^2.5.5" + tmp "^0.2.3" + tsx "^4.19.2" + tweetnacl "^1.0.3" + uuid "^9.0.1" + vite-tsconfig-paths "^5.1.4" + vitest "^3.2.0" + zod "4.3.6" + zod-to-json-schema "^3.24.3" + +"@happier-dev/sherpa-native@0.0.0", "@happier-dev/sherpa-native@file:/Users/cdenneen/code/workspace/work/happier-dev/packages/sherpa-native": + version "0.0.0" + resolved "file:packages/sherpa-native" + dependencies: + expo-modules-core "*" + +"@happier-dev/stack@file:/Users/cdenneen/code/workspace/work/happier-dev/apps/stack": + version "0.1.0" + resolved "file:apps/stack" + dependencies: + "@happier-dev/agents" "0.0.0" + "@happier-dev/cli-common" "0.0.0" + "@happier-dev/protocol" "0.0.0" + "@happier-dev/release-runtime" "0.0.0" + qrcode "^1.5.4" + qrcode-terminal "^0.12.0" + +"@happier-dev/tests@file:/Users/cdenneen/code/workspace/work/happier-dev/packages/tests": + version "0.0.0" + resolved "file:packages/tests" dependencies: - node-addon-api "^7.1.0" - prebuild-install "^7.1.2" + "@happier-dev/protocol" "0.0.0" + privacy-kit "^0.0.25" + socket.io-client "^4.8.1" + tweetnacl "^1.0.3" + +"@happier-dev/website@file:/Users/cdenneen/code/workspace/work/happier-dev/apps/website": + version "0.1.0" + resolved "file:apps/website" "@hono/node-server@^1.19.9": version "1.19.9" @@ -2437,148 +2279,6 @@ resolved "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz" integrity sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw== -"@img/sharp-darwin-arm64@0.34.5", "@img/sharp-darwin-arm64@^0.34.2": - version "0.34.5" - resolved "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz" - integrity sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w== - optionalDependencies: - "@img/sharp-libvips-darwin-arm64" "1.2.4" - -"@img/sharp-darwin-x64@0.34.5", "@img/sharp-darwin-x64@^0.34.2": - version "0.34.5" - resolved "https://registry.yarnpkg.com/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz#19bc1dd6eba6d5a96283498b9c9f401180ee9c7b" - integrity sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw== - optionalDependencies: - "@img/sharp-libvips-darwin-x64" "1.2.4" - -"@img/sharp-libvips-darwin-arm64@1.2.4": - version "1.2.4" - resolved "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz" - integrity sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g== - -"@img/sharp-libvips-darwin-x64@1.2.4": - version "1.2.4" - resolved "https://registry.yarnpkg.com/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz#e63681f4539a94af9cd17246ed8881734386f8cc" - integrity sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg== - -"@img/sharp-libvips-linux-arm64@1.2.4": - version "1.2.4" - resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz#b1b288b36864b3bce545ad91fa6dadcf1a4ad318" - integrity sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw== - -"@img/sharp-libvips-linux-arm@1.2.4": - version "1.2.4" - resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz#b9260dd1ebe6f9e3bdbcbdcac9d2ac125f35852d" - integrity sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A== - -"@img/sharp-libvips-linux-ppc64@1.2.4": - version "1.2.4" - resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz#4b83ecf2a829057222b38848c7b022e7b4d07aa7" - integrity sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA== - -"@img/sharp-libvips-linux-riscv64@1.2.4": - version "1.2.4" - resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz#880b4678009e5a2080af192332b00b0aaf8a48de" - integrity sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA== - -"@img/sharp-libvips-linux-s390x@1.2.4": - version "1.2.4" - resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz#74f343c8e10fad821b38f75ced30488939dc59ec" - integrity sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ== - -"@img/sharp-libvips-linux-x64@1.2.4": - version "1.2.4" - resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz#df4183e8bd8410f7d61b66859a35edeab0a531ce" - integrity sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw== - -"@img/sharp-libvips-linuxmusl-arm64@1.2.4": - version "1.2.4" - resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz#c8d6b48211df67137541007ee8d1b7b1f8ca8e06" - integrity sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw== - -"@img/sharp-libvips-linuxmusl-x64@1.2.4": - version "1.2.4" - resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz#be11c75bee5b080cbee31a153a8779448f919f75" - integrity sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg== - -"@img/sharp-linux-arm64@0.34.5", "@img/sharp-linux-arm64@^0.34.2": - version "0.34.5" - resolved "https://registry.yarnpkg.com/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz#7aa7764ef9c001f15e610546d42fce56911790cc" - integrity sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg== - optionalDependencies: - "@img/sharp-libvips-linux-arm64" "1.2.4" - -"@img/sharp-linux-arm@0.34.5", "@img/sharp-linux-arm@^0.34.2": - version "0.34.5" - resolved "https://registry.yarnpkg.com/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz#5fb0c3695dd12522d39c3ff7a6bc816461780a0d" - integrity sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw== - optionalDependencies: - "@img/sharp-libvips-linux-arm" "1.2.4" - -"@img/sharp-linux-ppc64@0.34.5": - version "0.34.5" - resolved "https://registry.yarnpkg.com/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz#9c213a81520a20caf66978f3d4c07456ff2e0813" - integrity sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA== - optionalDependencies: - "@img/sharp-libvips-linux-ppc64" "1.2.4" - -"@img/sharp-linux-riscv64@0.34.5": - version "0.34.5" - resolved "https://registry.yarnpkg.com/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz#cdd28182774eadbe04f62675a16aabbccb833f60" - integrity sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw== - optionalDependencies: - "@img/sharp-libvips-linux-riscv64" "1.2.4" - -"@img/sharp-linux-s390x@0.34.5": - version "0.34.5" - resolved "https://registry.yarnpkg.com/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz#93eac601b9f329bb27917e0e19098c722d630df7" - integrity sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg== - optionalDependencies: - "@img/sharp-libvips-linux-s390x" "1.2.4" - -"@img/sharp-linux-x64@0.34.5", "@img/sharp-linux-x64@^0.34.2": - version "0.34.5" - resolved "https://registry.yarnpkg.com/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz#55abc7cd754ffca5002b6c2b719abdfc846819a8" - integrity sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ== - optionalDependencies: - "@img/sharp-libvips-linux-x64" "1.2.4" - -"@img/sharp-linuxmusl-arm64@0.34.5", "@img/sharp-linuxmusl-arm64@^0.34.2": - version "0.34.5" - resolved "https://registry.yarnpkg.com/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz#d6515ee971bb62f73001a4829b9d865a11b77086" - integrity sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg== - optionalDependencies: - "@img/sharp-libvips-linuxmusl-arm64" "1.2.4" - -"@img/sharp-linuxmusl-x64@0.34.5", "@img/sharp-linuxmusl-x64@^0.34.2": - version "0.34.5" - resolved "https://registry.yarnpkg.com/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz#d97978aec7c5212f999714f2f5b736457e12ee9f" - integrity sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q== - optionalDependencies: - "@img/sharp-libvips-linuxmusl-x64" "1.2.4" - -"@img/sharp-wasm32@0.34.5": - version "0.34.5" - resolved "https://registry.yarnpkg.com/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz#2f15803aa626f8c59dd7c9d0bbc766f1ab52cfa0" - integrity sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw== - dependencies: - "@emnapi/runtime" "^1.7.0" - -"@img/sharp-win32-arm64@0.34.5", "@img/sharp-win32-arm64@^0.34.2": - version "0.34.5" - resolved "https://registry.yarnpkg.com/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz#3706e9e3ac35fddfc1c87f94e849f1b75307ce0a" - integrity sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g== - -"@img/sharp-win32-ia32@0.34.5": - version "0.34.5" - resolved "https://registry.yarnpkg.com/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz#0b71166599b049e032f085fb9263e02f4e4788de" - integrity sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg== - -"@img/sharp-win32-x64@0.34.5", "@img/sharp-win32-x64@^0.34.2": - version "0.34.5" - resolved "https://registry.yarnpkg.com/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz#a81ffb00e69267cd0a1d626eaedb8a8430b2b2f8" - integrity sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw== - "@inquirer/ansi@^1.0.2": version "1.0.2" resolved "https://registry.npmjs.org/@inquirer/ansi/-/ansi-1.0.2.tgz" @@ -2884,14 +2584,6 @@ resolved "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz" integrity sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og== -"@jridgewell/trace-mapping@0.3.9": - version "0.3.9" - resolved "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz" - integrity sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ== - dependencies: - "@jridgewell/resolve-uri" "^3.0.3" - "@jridgewell/sourcemap-codec" "^1.4.10" - "@jridgewell/trace-mapping@^0.3.18", "@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.25", "@jridgewell/trace-mapping@^0.3.28": version "0.3.31" resolved "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz" @@ -2900,6 +2592,14 @@ "@jridgewell/resolve-uri" "^3.1.0" "@jridgewell/sourcemap-codec" "^1.4.14" +"@jridgewell/trace-mapping@0.3.9": + version "0.3.9" + resolved "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz" + integrity sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ== + dependencies: + "@jridgewell/resolve-uri" "^3.0.3" + "@jridgewell/sourcemap-codec" "^1.4.10" + "@legendapp/list@2.0.0-beta.3": version "2.0.0-beta.3" resolved "https://registry.npmjs.org/@legendapp/list/-/list-2.0.0-beta.3.tgz" @@ -3062,7 +2762,7 @@ jose "^6.0.12" usehooks-ts "3.1.1" -"@livekit/mutex@1.1.1", "@livekit/mutex@^1.1.1": +"@livekit/mutex@^1.1.1", "@livekit/mutex@1.1.1": version "1.1.1" resolved "https://registry.npmjs.org/@livekit/mutex/-/mutex-1.1.1.tgz" integrity sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw== @@ -3079,7 +2779,7 @@ resolved "https://registry.npmjs.org/@livekit/react-native-expo-plugin/-/react-native-expo-plugin-1.0.1.tgz" integrity sha512-CSPjjzgDDlBH1ZyFyaw7/FW2Ql1S51eUkIxv/vjGwVshn+lUD6eQ9VgfUh7ha84itvjXi9X87FvP0XWKn9CiFQ== -"@livekit/react-native-webrtc@^137.0.0": +"@livekit/react-native-webrtc@^137.0.0", "@livekit/react-native-webrtc@^137.0.2": version "137.0.2" resolved "https://registry.npmjs.org/@livekit/react-native-webrtc/-/react-native-webrtc-137.0.2.tgz" integrity sha512-0aXYATcBraOMDTteKzmfH5ICNHw8xFyMPHmhKg14+94fAGZ2hGjdHZUSkzL14+e508W486aIAmbXipuSQCCJgA== @@ -3088,7 +2788,7 @@ debug "4.3.4" event-target-shim "6.0.2" -"@livekit/react-native@^2.9.0": +"@livekit/react-native@^2.1.0", "@livekit/react-native@^2.9.0", "@livekit/react-native@^2.9.2": version "2.9.6" resolved "https://registry.npmjs.org/@livekit/react-native/-/react-native-2.9.6.tgz" integrity sha512-4DOFJ/OO4yftZbm90QnVMDjwFXqSw2hcruSVO2idgwFxylDmH6NN2e1rs5ZCQPsnqlfwuMGz8vNfuYt++G4C/A== @@ -3197,80 +2897,19 @@ resolved "https://registry.npmjs.org/@msgpack/msgpack/-/msgpack-2.8.0.tgz" integrity sha512-h9u4u/jiIRKbq25PM+zymTyW6bhTzELvOoUd+AvYriWOAKpLGnIamaET3pnHYoI5iYphAHBI4ayx0MehR+VVPQ== -"@napi-rs/wasm-runtime@^1.1.0": - version "1.1.1" - resolved "https://registry.yarnpkg.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz#c3705ab549d176b8dc5172723d6156c3dc426af2" - integrity sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A== - dependencies: - "@emnapi/core" "^1.7.1" - "@emnapi/runtime" "^1.7.1" - "@tybys/wasm-util" "^0.10.1" - "@next/env@16.1.4": version "16.1.4" resolved "https://registry.npmjs.org/@next/env/-/env-16.1.4.tgz" integrity sha512-gkrXnZyxPUy0Gg6SrPQPccbNVLSP3vmW8LU5dwEttEEC1RwDivk8w4O+sZIjFvPrSICXyhQDCG+y3VmjlJf+9A== -"@next/swc-darwin-arm64@16.1.4": - version "16.1.4" - resolved "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.4.tgz" - integrity sha512-T8atLKuvk13XQUdVLCv1ZzMPgLPW0+DWWbHSQXs0/3TjPrKNxTmUIhOEaoEyl3Z82k8h/gEtqyuoZGv6+Ugawg== - -"@next/swc-darwin-x64@16.1.4": - version "16.1.4" - resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.4.tgz#2f8d4462f48d4cb3c927de1962ca7a7b2f8a5b03" - integrity sha512-AKC/qVjUGUQDSPI6gESTx0xOnOPQ5gttogNS3o6bA83yiaSZJek0Am5yXy82F1KcZCx3DdOwdGPZpQCluonuxg== - -"@next/swc-linux-arm64-gnu@16.1.4": - version "16.1.4" - resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.4.tgz#79fecac25ad4a0ee1081110f4c8863b87e754943" - integrity sha512-POQ65+pnYOkZNdngWfMEt7r53bzWiKkVNbjpmCt1Zb3V6lxJNXSsjwRuTQ8P/kguxDC8LRkqaL3vvsFrce4dMQ== - -"@next/swc-linux-arm64-musl@16.1.4": - version "16.1.4" - resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.4.tgz#e9a99b1ea9a68908c3d36a847a6fe367b4fc3855" - integrity sha512-3Wm0zGYVCs6qDFAiSSDL+Z+r46EdtCv/2l+UlIdMbAq9hPJBvGu/rZOeuvCaIUjbArkmXac8HnTyQPJFzFWA0Q== - -"@next/swc-linux-x64-gnu@16.1.4": - version "16.1.4" - resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.4.tgz#4804de5f42ac8333e0049ab538473cbd996507f6" - integrity sha512-lWAYAezFinaJiD5Gv8HDidtsZdT3CDaCeqoPoJjeB57OqzvMajpIhlZFce5sCAH6VuX4mdkxCRqecCJFwfm2nQ== - -"@next/swc-linux-x64-musl@16.1.4": - version "16.1.4" - resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.4.tgz#4aa01e59b0e0fd19ab493ee239e3904c42419ca6" - integrity sha512-fHaIpT7x4gA6VQbdEpYUXRGyge/YbRrkG6DXM60XiBqDM2g2NcrsQaIuj375egnGFkJow4RHacgBOEsHfGbiUw== - -"@next/swc-win32-arm64-msvc@16.1.4": - version "16.1.4" - resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.4.tgz#67652a5c57889f44c11e145d49f777ac2e6cde58" - integrity sha512-MCrXxrTSE7jPN1NyXJr39E+aNFBrQZtO154LoCz7n99FuKqJDekgxipoodLNWdQP7/DZ5tKMc/efybx1l159hw== - -"@next/swc-win32-x64-msvc@16.1.4": - version "16.1.4" - resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.4.tgz#3c51597eb64a96b8fcade74ab3f21ef3ad278a33" - integrity sha512-JSVlm9MDhmTXw/sO2PE/MRj+G6XOSMZB+BcZ0a7d6KwVFZVpkHcb2okyoYFBaco6LeiL53BBklRlOrDDbOeE5w== - -"@noble/curves@1.5.0": - version "1.5.0" - resolved "https://registry.npmjs.org/@noble/curves/-/curves-1.5.0.tgz" - integrity sha512-J5EKamIHnKPyClwVrzmaf5wSdQXgdHcPZIZLu3bwnbeCx8/7NPK5q2ZBWF+5FvYGByjiQQsJYX6jfgB2wDPn3A== - dependencies: - "@noble/hashes" "1.4.0" - -"@noble/curves@^1.9.0": +"@noble/curves@^1.9.0", "@noble/curves@1.5.0": version "1.9.7" resolved "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz" integrity sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw== dependencies: "@noble/hashes" "1.8.0" -"@noble/hashes@1.4.0": - version "1.4.0" - resolved "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz" - integrity sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg== - -"@noble/hashes@1.8.0", "@noble/hashes@^1.3.2", "@noble/hashes@^1.8.0": +"@noble/hashes@^1.3.2", "@noble/hashes@^1.8.0", "@noble/hashes@1.4.0", "@noble/hashes@1.8.0": version "1.8.0" resolved "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz" integrity sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A== @@ -3283,7 +2922,7 @@ "@nodelib/fs.stat" "2.0.5" run-parallel "^1.1.9" -"@nodelib/fs.stat@2.0.5", "@nodelib/fs.stat@^2.0.2": +"@nodelib/fs.stat@^2.0.2", "@nodelib/fs.stat@2.0.5": version "2.0.5" resolved "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz" integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A== @@ -3375,7 +3014,7 @@ "@octokit/request-error" "^7.0.2" "@octokit/types" "^16.0.0" -"@octokit/core@^7.0.5", "@octokit/core@^7.0.6": +"@octokit/core@^7.0.0", "@octokit/core@^7.0.5", "@octokit/core@^7.0.6", "@octokit/core@>=6", "@octokit/core@>=7": version "7.0.6" resolved "https://registry.npmjs.org/@octokit/core/-/core-7.0.6.tgz" integrity sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q== @@ -3555,16 +3194,23 @@ dependencies: "@opentelemetry/api" "^1.3.0" -"@opentelemetry/api@1.9.0", "@opentelemetry/api@^1.3.0", "@opentelemetry/api@^1.4.0", "@opentelemetry/api@^1.9.0": +"@opentelemetry/api@^1.1.0", "@opentelemetry/api@^1.3.0", "@opentelemetry/api@^1.4.0", "@opentelemetry/api@^1.7.0", "@opentelemetry/api@^1.8", "@opentelemetry/api@^1.9.0", "@opentelemetry/api@>=1.0.0 <1.10.0", "@opentelemetry/api@>=1.3.0 <1.10.0", "@opentelemetry/api@1.9.0": version "1.9.0" resolved "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz" integrity sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg== -"@opentelemetry/context-async-hooks@^2.5.0", "@opentelemetry/context-async-hooks@^2.5.1": +"@opentelemetry/context-async-hooks@^1.30.1 || ^2.1.0", "@opentelemetry/context-async-hooks@^2.5.0", "@opentelemetry/context-async-hooks@^2.5.1": version "2.5.1" resolved "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.5.1.tgz" integrity sha512-MHbu8XxCHcBn6RwvCt2Vpn1WnLMNECfNKYB14LI5XypcgH4IE0/DiVifVR9tAkwPMyLXN8dOoPJfya3IryLQVw== +"@opentelemetry/core@^1.30.1 || ^2.1.0", "@opentelemetry/core@^2.0.0", "@opentelemetry/core@^2.5.0", "@opentelemetry/core@^2.5.1", "@opentelemetry/core@2.5.1": + version "2.5.1" + resolved "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.1.tgz" + integrity sha512-Dwlc+3HAZqpgTYq0MUyZABjFkcrKTePwuiFVLjahGD8cx3enqihmpAmdgNFO1R4m/sIe5afjJrA25Prqy4NXlA== + dependencies: + "@opentelemetry/semantic-conventions" "^1.29.0" + "@opentelemetry/core@2.5.0": version "2.5.0" resolved "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.0.tgz" @@ -3572,13 +3218,6 @@ dependencies: "@opentelemetry/semantic-conventions" "^1.29.0" -"@opentelemetry/core@2.5.1", "@opentelemetry/core@^2.0.0", "@opentelemetry/core@^2.5.0", "@opentelemetry/core@^2.5.1": - version "2.5.1" - resolved "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.1.tgz" - integrity sha512-Dwlc+3HAZqpgTYq0MUyZABjFkcrKTePwuiFVLjahGD8cx3enqihmpAmdgNFO1R4m/sIe5afjJrA25Prqy4NXlA== - dependencies: - "@opentelemetry/semantic-conventions" "^1.29.0" - "@opentelemetry/instrumentation-amqplib@0.58.0": version "0.58.0" resolved "https://registry.npmjs.org/@opentelemetry/instrumentation-amqplib/-/instrumentation-amqplib-0.58.0.tgz" @@ -3713,23 +3352,23 @@ "@opentelemetry/instrumentation" "^0.211.0" "@opentelemetry/semantic-conventions" "^1.33.0" -"@opentelemetry/instrumentation-mysql2@0.57.0": +"@opentelemetry/instrumentation-mysql@0.57.0": version "0.57.0" - resolved "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql2/-/instrumentation-mysql2-0.57.0.tgz" - integrity sha512-nHSrYAwF7+aV1E1V9yOOP9TchOodb6fjn4gFvdrdQXiRE7cMuffyLLbCZlZd4wsspBzVwOXX8mpURdRserAhNA== + resolved "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql/-/instrumentation-mysql-0.57.0.tgz" + integrity sha512-HFS/+FcZ6Q7piM7Il7CzQ4VHhJvGMJWjx7EgCkP5AnTntSN5rb5Xi3TkYJHBKeR27A0QqPlGaCITi93fUDs++Q== dependencies: "@opentelemetry/instrumentation" "^0.211.0" "@opentelemetry/semantic-conventions" "^1.33.0" - "@opentelemetry/sql-common" "^0.41.2" + "@types/mysql" "2.15.27" -"@opentelemetry/instrumentation-mysql@0.57.0": +"@opentelemetry/instrumentation-mysql2@0.57.0": version "0.57.0" - resolved "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql/-/instrumentation-mysql-0.57.0.tgz" - integrity sha512-HFS/+FcZ6Q7piM7Il7CzQ4VHhJvGMJWjx7EgCkP5AnTntSN5rb5Xi3TkYJHBKeR27A0QqPlGaCITi93fUDs++Q== + resolved "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql2/-/instrumentation-mysql2-0.57.0.tgz" + integrity sha512-nHSrYAwF7+aV1E1V9yOOP9TchOodb6fjn4gFvdrdQXiRE7cMuffyLLbCZlZd4wsspBzVwOXX8mpURdRserAhNA== dependencies: "@opentelemetry/instrumentation" "^0.211.0" "@opentelemetry/semantic-conventions" "^1.33.0" - "@types/mysql" "2.15.27" + "@opentelemetry/sql-common" "^0.41.2" "@opentelemetry/instrumentation-pg@0.63.0": version "0.63.0" @@ -3770,15 +3409,6 @@ "@opentelemetry/instrumentation" "^0.211.0" "@opentelemetry/semantic-conventions" "^1.24.0" -"@opentelemetry/instrumentation@0.211.0", "@opentelemetry/instrumentation@^0.211.0": - version "0.211.0" - resolved "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.211.0.tgz" - integrity sha512-h0nrZEC/zvI994nhg7EgQ8URIHt0uDTwN90r3qQUdZORS455bbx+YebnGeEuFghUT0HlJSrLF4iHw67f+odY+Q== - dependencies: - "@opentelemetry/api-logs" "0.211.0" - import-in-the-middle "^2.0.0" - require-in-the-middle "^8.0.0" - "@opentelemetry/instrumentation@^0.207.0": version "0.207.0" resolved "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.207.0.tgz" @@ -3797,12 +3427,21 @@ import-in-the-middle "^2.0.0" require-in-the-middle "^8.0.0" +"@opentelemetry/instrumentation@^0.211.0", "@opentelemetry/instrumentation@>=0.57.1 <1", "@opentelemetry/instrumentation@0.211.0": + version "0.211.0" + resolved "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.211.0.tgz" + integrity sha512-h0nrZEC/zvI994nhg7EgQ8URIHt0uDTwN90r3qQUdZORS455bbx+YebnGeEuFghUT0HlJSrLF4iHw67f+odY+Q== + dependencies: + "@opentelemetry/api-logs" "0.211.0" + import-in-the-middle "^2.0.0" + require-in-the-middle "^8.0.0" + "@opentelemetry/redis-common@^0.38.2": version "0.38.2" resolved "https://registry.npmjs.org/@opentelemetry/redis-common/-/redis-common-0.38.2.tgz" integrity sha512-1BCcU93iwSRZvDAgwUxC/DV4T/406SkMfxGqu5ojc3AvNI+I9GhV7v0J1HljsczuuhcnFLYqD5VmwVXfCGHzxA== -"@opentelemetry/resources@2.5.1", "@opentelemetry/resources@^2.5.0", "@opentelemetry/resources@^2.5.1": +"@opentelemetry/resources@^1.30.1 || ^2.1.0", "@opentelemetry/resources@^2.5.0", "@opentelemetry/resources@^2.5.1", "@opentelemetry/resources@2.5.1": version "2.5.1" resolved "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.5.1.tgz" integrity sha512-BViBCdE/GuXRlp9k7nS1w6wJvY5fnFX5XvuEtWsTAOQFIO89Eru7lGW3WbfbxtCuZ/GbrJfAziXG0w0dpxL7eQ== @@ -3810,7 +3449,7 @@ "@opentelemetry/core" "2.5.1" "@opentelemetry/semantic-conventions" "^1.29.0" -"@opentelemetry/sdk-trace-base@^2.5.0", "@opentelemetry/sdk-trace-base@^2.5.1": +"@opentelemetry/sdk-trace-base@^1.30.1 || ^2.1.0", "@opentelemetry/sdk-trace-base@^2.5.0", "@opentelemetry/sdk-trace-base@^2.5.1": version "2.5.1" resolved "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.5.1.tgz" integrity sha512-iZH3Gw8cxQn0gjpOjJMmKLd9GIaNh/E3v3ST67vyzLSxHBs14HsG4dy7jMYyC5WXGdBVEcM7U/XTF5hCQxjDMw== @@ -3990,12 +3629,7 @@ resolved "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz" integrity sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg== -"@pkgjs/parseargs@^0.11.0": - version "0.11.0" - resolved "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz" - integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== - -"@playwright/test@^1.56.0": +"@playwright/test@^1.51.1", "@playwright/test@^1.56.0": version "1.58.2" resolved "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz" integrity sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA== @@ -4009,7 +3643,7 @@ dependencies: cross-spawn "^7.0.6" -"@prisma/client@^6.11.1": +"@prisma/client@^6.11.1", "@prisma/client@^6.14": version "6.19.2" resolved "https://registry.npmjs.org/@prisma/client/-/client-6.19.2.tgz" integrity sha512-gR2EMvfK/aTxsuooaDA32D8v+us/8AAet+C3J1cc04SW35FPdZYgLF+iN4NDLUgAaUGTKdAB0CYenu1TAgGdMg== @@ -4171,7 +3805,7 @@ dependencies: "@radix-ui/react-primitive" "2.1.3" -"@radix-ui/react-collapsible@1.1.12", "@radix-ui/react-collapsible@^1.1.12": +"@radix-ui/react-collapsible@^1.1.12", "@radix-ui/react-collapsible@1.1.12": version "1.1.12" resolved "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz" integrity sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA== @@ -4225,12 +3859,12 @@ aria-hidden "^1.2.4" react-remove-scroll "^2.6.3" -"@radix-ui/react-direction@1.1.1", "@radix-ui/react-direction@^1.1.1": +"@radix-ui/react-direction@^1.1.1", "@radix-ui/react-direction@1.1.1": version "1.1.1" resolved "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz" integrity sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw== -"@radix-ui/react-dismissable-layer@1.1.11", "@radix-ui/react-dismissable-layer@^1.1.11": +"@radix-ui/react-dismissable-layer@^1.1.11", "@radix-ui/react-dismissable-layer@1.1.11": version "1.1.11" resolved "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz" integrity sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg== @@ -4327,7 +3961,7 @@ "@radix-ui/react-primitive" "2.1.3" "@radix-ui/react-use-layout-effect" "1.1.1" -"@radix-ui/react-presence@1.1.5", "@radix-ui/react-presence@^1.1.5": +"@radix-ui/react-presence@^1.1.5", "@radix-ui/react-presence@1.1.5": version "1.1.5" resolved "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz" integrity sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ== @@ -4372,6 +4006,13 @@ "@radix-ui/react-use-callback-ref" "1.1.1" "@radix-ui/react-use-layout-effect" "1.1.1" +"@radix-ui/react-slot@^1.2.4": + version "1.2.4" + resolved "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz" + integrity sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA== + dependencies: + "@radix-ui/react-compose-refs" "1.1.2" + "@radix-ui/react-slot@1.2.0": version "1.2.0" resolved "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.0.tgz" @@ -4386,14 +4027,21 @@ dependencies: "@radix-ui/react-compose-refs" "1.1.2" -"@radix-ui/react-slot@^1.2.4": - version "1.2.4" - resolved "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz" - integrity sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA== +"@radix-ui/react-tabs@^1.1.12": + version "1.1.13" + resolved "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz" + integrity sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A== dependencies: - "@radix-ui/react-compose-refs" "1.1.2" + "@radix-ui/primitive" "1.1.3" + "@radix-ui/react-context" "1.1.2" + "@radix-ui/react-direction" "1.1.1" + "@radix-ui/react-id" "1.1.1" + "@radix-ui/react-presence" "1.1.5" + "@radix-ui/react-primitive" "2.1.3" + "@radix-ui/react-roving-focus" "1.1.11" + "@radix-ui/react-use-controllable-state" "1.2.2" -"@radix-ui/react-tabs@^1.1.12", "@radix-ui/react-tabs@^1.1.13": +"@radix-ui/react-tabs@^1.1.13": version "1.1.13" resolved "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz" integrity sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A== @@ -4470,14 +4118,14 @@ resolved "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz" integrity sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw== -"@react-native-async-storage/async-storage@2.2.0": +"@react-native-async-storage/async-storage@>=1.0.0", "@react-native-async-storage/async-storage@2.2.0": version "2.2.0" resolved "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz" integrity sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw== dependencies: merge-options "^3.0.4" -"@react-native-masked-view/masked-view@0.3.2": +"@react-native-masked-view/masked-view@>= 0.2.0", "@react-native-masked-view/masked-view@0.3.2": version "0.3.2" resolved "https://registry.npmjs.org/@react-native-masked-view/masked-view/-/masked-view-0.3.2.tgz" integrity sha512-XwuQoW7/GEgWRMovOQtX3A4PrXhyaZm0lVUiY8qJDvdngjLms9Cpdck6SmGAUNqQwcj2EadHC1HwL0bEyoa/SQ== @@ -4639,21 +4287,21 @@ resolved "https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.81.4.tgz" integrity sha512-sr42FaypKXJHMVHhgSbu2f/ZJfrLzgaoQ+HdpRvKEiEh2mhFf6XzZwecyLBvWqf2pMPZa+CpPfNPiejXjKEy8w== -"@react-native/normalize-colors@0.81.4": +"@react-native/normalize-colors@*", "@react-native/normalize-colors@0.81.4": version "0.81.4" resolved "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.81.4.tgz" integrity sha512-9nRRHO1H+tcFqjb9gAM105Urtgcanbta2tuqCVY0NATHeFPDEAB7gPyiLxCHKMi1NbhP6TH0kxgSWXKZl1cyRg== -"@react-native/normalize-colors@0.81.5": - version "0.81.5" - resolved "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.81.5.tgz" - integrity sha512-0HuJ8YtqlTVRXGZuGeBejLE04wSQsibpTI+RGOyVqxZvgtlLLC/Ssw0UmbHhT4lYMp2fhdtvKZSs5emWB1zR/g== - "@react-native/normalize-colors@^0.74.1": version "0.74.89" resolved "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.74.89.tgz" integrity sha512-qoMMXddVKVhZ8PA1AbUCk83trpd6N+1nF2A6k1i6LsQObyS92fELuk8kU/lQs6M7BsMHwqyLCpQJ1uFgNvIQXg== +"@react-native/normalize-colors@0.81.5": + version "0.81.5" + resolved "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.81.5.tgz" + integrity sha512-0HuJ8YtqlTVRXGZuGeBejLE04wSQsibpTI+RGOyVqxZvgtlLLC/Ssw0UmbHhT4lYMp2fhdtvKZSs5emWB1zR/g== + "@react-native/virtualized-lists@0.81.4": version "0.81.4" resolved "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.81.4.tgz" @@ -4685,7 +4333,7 @@ use-latest-callback "^0.2.4" use-sync-external-store "^1.5.0" -"@react-navigation/drawer@^7.3.9": +"@react-navigation/drawer@^7.3.9", "@react-navigation/drawer@^7.5.0": version "7.7.13" resolved "https://registry.npmjs.org/@react-navigation/drawer/-/drawer-7.7.13.tgz" integrity sha512-O//bZbZLUYhYIZLE+1K3XU02nBnAJR/mbKhp4pH6QBFITy6JXlCsiXwE4vVNsxcn6Y5GBgo47goEVCy/gsaofw== @@ -4704,7 +4352,7 @@ use-latest-callback "^0.2.4" use-sync-external-store "^1.5.0" -"@react-navigation/native-stack@^7.3.16": +"@react-navigation/native-stack@^7.3.16", "@react-navigation/native-stack@>=7.0.0": version "7.11.0" resolved "https://registry.npmjs.org/@react-navigation/native-stack/-/native-stack-7.11.0.tgz" integrity sha512-yNx9Wr4dfpOHpqjf2sGog4eH6KCYwTAEPlUPrKbvWlQbCRm5bglwPmaTXw9hTovX9v3HIa42yo7bXpbYfq4jzg== @@ -4714,7 +4362,7 @@ sf-symbols-typescript "^2.1.0" warn-once "^0.1.1" -"@react-navigation/native@^7.1.8": +"@react-navigation/native@^7.1.28", "@react-navigation/native@^7.1.8", "@react-navigation/native@>= 5.0.0", "@react-navigation/native@>=6.0.0": version "7.1.28" resolved "https://registry.npmjs.org/@react-navigation/native/-/native-7.1.28.tgz" integrity sha512-d1QDn+KNHfHGt3UIwOZvupvdsDdiHYZBEj7+wL2yDVo3tMezamYy60H9s3EnNVE1Ae1ty0trc7F2OKqo/RmsdQ== @@ -4751,7 +4399,7 @@ dependencies: "@revenuecat/purchases-js" "1.24.3" -"@revenuecat/purchases-js@1.24.3", "@revenuecat/purchases-js@^1.11.1": +"@revenuecat/purchases-js@^1.11.1", "@revenuecat/purchases-js@1.24.3": version "1.24.3" resolved "https://registry.npmjs.org/@revenuecat/purchases-js/-/purchases-js-1.24.3.tgz" integrity sha512-shkbf0zBG23rabEMDnHh8RZt6dqTey0eU1YJyRLTAB0Mr7ZqfiND/8h8K9PQ8SNmwgdma+NuuEgRbyk/AeFr2A== @@ -4833,26 +4481,6 @@ estree-walker "^2.0.2" picomatch "^4.0.2" -"@rollup/rollup-android-arm-eabi@4.57.0": - version "4.57.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.0.tgz#f762035679a6b168138c94c960fda0b0cdb00d98" - integrity sha512-tPgXB6cDTndIe1ah7u6amCI1T0SsnlOuKgg10Xh3uizJk4e5M1JGaUMk7J4ciuAUcFpbOiNhm2XIjP9ON0dUqA== - -"@rollup/rollup-android-arm-eabi@4.57.1": - version "4.57.1" - resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz#add5e608d4e7be55bc3ca3d962490b8b1890e088" - integrity sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg== - -"@rollup/rollup-android-arm64@4.57.0": - version "4.57.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.0.tgz#1061ce0bfa6a6da361bda52a2949612769cd22ef" - integrity sha512-sa4LyseLLXr1onr97StkU1Nb7fWcg6niokTwEVNOO7awaKaoRObQ54+V/hrF/BP1noMEaaAW6Fg2d/CfLiq3Mg== - -"@rollup/rollup-android-arm64@4.57.1": - version "4.57.1" - resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz#10bd0382b73592beee6e9800a69401a29da625c4" - integrity sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w== - "@rollup/rollup-darwin-arm64@4.57.0": version "4.57.0" resolved "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.0.tgz" @@ -4863,226 +4491,6 @@ resolved "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz" integrity sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg== -"@rollup/rollup-darwin-x64@4.57.0": - version "4.57.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.0.tgz#2a805303beb4cd44bfef993c39582cb0f1794f90" - integrity sha512-xoh8abqgPrPYPr7pTYipqnUi1V3em56JzE/HgDgitTqZBZ3yKCWI+7KUkceM6tNweyUKYru1UMi7FC060RyKwA== - -"@rollup/rollup-darwin-x64@4.57.1": - version "4.57.1" - resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz#69e741aeb2839d2e8f0da2ce7a33d8bd23632423" - integrity sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w== - -"@rollup/rollup-freebsd-arm64@4.57.0": - version "4.57.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.0.tgz#7cf26a60d7245e9207a253ac07f11ddfcc47d622" - integrity sha512-PCkMh7fNahWSbA0OTUQ2OpYHpjZZr0hPr8lId8twD7a7SeWrvT3xJVyza+dQwXSSq4yEQTMoXgNOfMCsn8584g== - -"@rollup/rollup-freebsd-arm64@4.57.1": - version "4.57.1" - resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz#3736c232a999c7bef7131355d83ebdf9651a0839" - integrity sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug== - -"@rollup/rollup-freebsd-x64@4.57.0": - version "4.57.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.0.tgz#2b1acc1e624b47f676f526df30bb4357ea21f9b6" - integrity sha512-1j3stGx+qbhXql4OCDZhnK7b01s6rBKNybfsX+TNrEe9JNq4DLi1yGiR1xW+nL+FNVvI4D02PUnl6gJ/2y6WJA== - -"@rollup/rollup-freebsd-x64@4.57.1": - version "4.57.1" - resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz#227dcb8f466684070169942bd3998901c9bfc065" - integrity sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q== - -"@rollup/rollup-linux-arm-gnueabihf@4.57.0": - version "4.57.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.0.tgz#1ba1ef444365a51687c7af2824b370791a1e3aaf" - integrity sha512-eyrr5W08Ms9uM0mLcKfM/Uzx7hjhz2bcjv8P2uynfj0yU8GGPdz8iYrBPhiLOZqahoAMB8ZiolRZPbbU2MAi6Q== - -"@rollup/rollup-linux-arm-gnueabihf@4.57.1": - version "4.57.1" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz#ba004b30df31b724f99ce66e7128248bea17cb0c" - integrity sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw== - -"@rollup/rollup-linux-arm-musleabihf@4.57.0": - version "4.57.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.0.tgz#e49863b683644bbbb9abc5b051c9b9d59774c3a0" - integrity sha512-Xds90ITXJCNyX9pDhqf85MKWUI4lqjiPAipJ8OLp8xqI2Ehk+TCVhF9rvOoN8xTbcafow3QOThkNnrM33uCFQA== - -"@rollup/rollup-linux-arm-musleabihf@4.57.1": - version "4.57.1" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz#6929f3e07be6b6da5991f63c6b68b3e473d0a65a" - integrity sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw== - -"@rollup/rollup-linux-arm64-gnu@4.57.0": - version "4.57.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.0.tgz#fda3bfd43d2390d2d99bc7d9617c2db2941da52b" - integrity sha512-Xws2KA4CLvZmXjy46SQaXSejuKPhwVdaNinldoYfqruZBaJHqVo6hnRa8SDo9z7PBW5x84SH64+izmldCgbezw== - -"@rollup/rollup-linux-arm64-gnu@4.57.1": - version "4.57.1" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz#06e89fd4a25d21fe5575d60b6f913c0e65297bfa" - integrity sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g== - -"@rollup/rollup-linux-arm64-musl@4.57.0": - version "4.57.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.0.tgz#aea6199031404f80a0ccf33d5d3a63de53819da0" - integrity sha512-hrKXKbX5FdaRJj7lTMusmvKbhMJSGWJ+w++4KmjiDhpTgNlhYobMvKfDoIWecy4O60K6yA4SnztGuNTQF+Lplw== - -"@rollup/rollup-linux-arm64-musl@4.57.1": - version "4.57.1" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz#fddabf395b90990d5194038e6cd8c00156ed8ac0" - integrity sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q== - -"@rollup/rollup-linux-loong64-gnu@4.57.0": - version "4.57.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.0.tgz#f467333a5691f69a18295a7051e1cebb6815fdfe" - integrity sha512-6A+nccfSDGKsPm00d3xKcrsBcbqzCTAukjwWK6rbuAnB2bHaL3r9720HBVZ/no7+FhZLz/U3GwwZZEh6tOSI8Q== - -"@rollup/rollup-linux-loong64-gnu@4.57.1": - version "4.57.1" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz#04c10bb764bbf09a3c1bd90432e92f58d6603c36" - integrity sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA== - -"@rollup/rollup-linux-loong64-musl@4.57.0": - version "4.57.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.0.tgz#e46dffc29692caa743140636eb0d1d9a24ed0fc3" - integrity sha512-4P1VyYUe6XAJtQH1Hh99THxr0GKMMwIXsRNOceLrJnaHTDgk1FTcTimDgneRJPvB3LqDQxUmroBclQ1S0cIJwQ== - -"@rollup/rollup-linux-loong64-musl@4.57.1": - version "4.57.1" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz#f2450361790de80581d8687ea19142d8a4de5c0f" - integrity sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw== - -"@rollup/rollup-linux-ppc64-gnu@4.57.0": - version "4.57.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.0.tgz#be5b4494047ccbaadf1542fe9ac45b7788e73968" - integrity sha512-8Vv6pLuIZCMcgXre6c3nOPhE0gjz1+nZP6T+hwWjr7sVH8k0jRkH+XnfjjOTglyMBdSKBPPz54/y1gToSKwrSQ== - -"@rollup/rollup-linux-ppc64-gnu@4.57.1": - version "4.57.1" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz#0474f4667259e407eee1a6d38e29041b708f6a30" - integrity sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w== - -"@rollup/rollup-linux-ppc64-musl@4.57.0": - version "4.57.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.0.tgz#b14ce2b0fe9c37fd0646ec3095087c1d64c791f4" - integrity sha512-r1te1M0Sm2TBVD/RxBPC6RZVwNqUTwJTA7w+C/IW5v9Ssu6xmxWEi+iJQlpBhtUiT1raJ5b48pI8tBvEjEFnFA== - -"@rollup/rollup-linux-ppc64-musl@4.57.1": - version "4.57.1" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz#9f32074819eeb1ddbe51f50ea9dcd61a6745ec33" - integrity sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw== - -"@rollup/rollup-linux-riscv64-gnu@4.57.0": - version "4.57.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.0.tgz#b78357f88ee7a34f677b118714594e37a2362a8c" - integrity sha512-say0uMU/RaPm3CDQLxUUTF2oNWL8ysvHkAjcCzV2znxBr23kFfaxocS9qJm+NdkRhF8wtdEEAJuYcLPhSPbjuQ== - -"@rollup/rollup-linux-riscv64-gnu@4.57.1": - version "4.57.1" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz#3fdb9d4b1e29fb6b6a6da9f15654d42eb77b99b2" - integrity sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A== - -"@rollup/rollup-linux-riscv64-musl@4.57.0": - version "4.57.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.0.tgz#f44107ec0c30d691552c89eb3e4f287c33c56c3c" - integrity sha512-/MU7/HizQGsnBREtRpcSbSV1zfkoxSTR7wLsRmBPQ8FwUj5sykrP1MyJTvsxP5KBq9SyE6kH8UQQQwa0ASeoQQ== - -"@rollup/rollup-linux-riscv64-musl@4.57.1": - version "4.57.1" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz#1de780d64e6be0e3e8762035c22e0d8ea68df8ed" - integrity sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw== - -"@rollup/rollup-linux-s390x-gnu@4.57.0": - version "4.57.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.0.tgz#ddb1cf80fb21b376a45a4e93ffdbeb15205d38f3" - integrity sha512-Q9eh+gUGILIHEaJf66aF6a414jQbDnn29zeu0eX3dHMuysnhTvsUvZTCAyZ6tJhUjnvzBKE4FtuaYxutxRZpOg== - -"@rollup/rollup-linux-s390x-gnu@4.57.1": - version "4.57.1" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz#1da022ffd2d9e9f0fd8344ea49e113001fbcac64" - integrity sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg== - -"@rollup/rollup-linux-x64-gnu@4.57.0": - version "4.57.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.0.tgz#0da46a644c87e1d8b13da5e2901037193caea8d3" - integrity sha512-OR5p5yG5OKSxHReWmwvM0P+VTPMwoBS45PXTMYaskKQqybkS3Kmugq1W+YbNWArF8/s7jQScgzXUhArzEQ7x0A== - -"@rollup/rollup-linux-x64-gnu@4.57.1": - version "4.57.1" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz#78c16eef9520bd10e1ea7a112593bb58e2842622" - integrity sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg== - -"@rollup/rollup-linux-x64-musl@4.57.0": - version "4.57.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.0.tgz#e561c93b6a23114a308396806551c25e28d3e303" - integrity sha512-XeatKzo4lHDsVEbm1XDHZlhYZZSQYym6dg2X/Ko0kSFgio+KXLsxwJQprnR48GvdIKDOpqWqssC3iBCjoMcMpw== - -"@rollup/rollup-linux-x64-musl@4.57.1": - version "4.57.1" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz#a7598591b4d9af96cb3167b50a5bf1e02dfea06c" - integrity sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw== - -"@rollup/rollup-openbsd-x64@4.57.0": - version "4.57.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.0.tgz#52490600775364a0476f26be7ddc416dfa11439b" - integrity sha512-Lu71y78F5qOfYmubYLHPcJm74GZLU6UJ4THkf/a1K7Tz2ycwC2VUbsqbJAXaR6Bx70SRdlVrt2+n5l7F0agTUw== - -"@rollup/rollup-openbsd-x64@4.57.1": - version "4.57.1" - resolved "https://registry.yarnpkg.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz#c51d48c07cd6c466560e5bed934aec688ce02614" - integrity sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw== - -"@rollup/rollup-openharmony-arm64@4.57.0": - version "4.57.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.0.tgz#c25988aae57bd21fa7d0fcb014ef85ec8987ad2c" - integrity sha512-v5xwKDWcu7qhAEcsUubiav7r+48Uk/ENWdr82MBZZRIm7zThSxCIVDfb3ZeRRq9yqk+oIzMdDo6fCcA5DHfMyA== - -"@rollup/rollup-openharmony-arm64@4.57.1": - version "4.57.1" - resolved "https://registry.yarnpkg.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz#f09921d0b2a0b60afbf3586d2a7a7f208ba6df17" - integrity sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ== - -"@rollup/rollup-win32-arm64-msvc@4.57.0": - version "4.57.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.0.tgz#572a8cd78442441121f1a6b5ad686ab723c31ae4" - integrity sha512-XnaaaSMGSI6Wk8F4KK3QP7GfuuhjGchElsVerCplUuxRIzdvZ7hRBpLR0omCmw+kI2RFJB80nenhOoGXlJ5TfQ== - -"@rollup/rollup-win32-arm64-msvc@4.57.1": - version "4.57.1" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz#08d491717135376e4a99529821c94ecd433d5b36" - integrity sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ== - -"@rollup/rollup-win32-ia32-msvc@4.57.0": - version "4.57.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.0.tgz#431fa95c0be8377907fe4e7070aaa4016c7b7e3b" - integrity sha512-3K1lP+3BXY4t4VihLw5MEg6IZD3ojSYzqzBG571W3kNQe4G4CcFpSUQVgurYgib5d+YaCjeFow8QivWp8vuSvA== - -"@rollup/rollup-win32-ia32-msvc@4.57.1": - version "4.57.1" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz#b0c12aac1104a8b8f26a5e0098e5facbb3e3964a" - integrity sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew== - -"@rollup/rollup-win32-x64-gnu@4.57.0": - version "4.57.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.0.tgz#19db67feb9c5fe09b1358efd1d97c5f6b299d347" - integrity sha512-MDk610P/vJGc5L5ImE4k5s+GZT3en0KoK1MKPXCRgzmksAMk79j4h3k1IerxTNqwDLxsGxStEZVBqG0gIqZqoA== - -"@rollup/rollup-win32-x64-gnu@4.57.1": - version "4.57.1" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz#b9cccef26f5e6fdc013bf3c0911a3c77428509d0" - integrity sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ== - -"@rollup/rollup-win32-x64-msvc@4.57.0": - version "4.57.0" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.0.tgz#6f38851da1123ac0380121108abd31ff21205c3d" - integrity sha512-Zv7v6q6aV+VslnpwzqKAmrk5JdVkLUzok2208ZXGipjb+msxBr/fJPZyeEXiFgH7k62Ak0SLIfxQRZQvTuf7rQ== - -"@rollup/rollup-win32-x64-msvc@4.57.1": - version "4.57.1" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz#a03348e7b559c792b6277cc58874b89ef46e1e72" - integrity sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA== - "@sentry-internal/browser-utils@10.39.0": version "10.39.0" resolved "https://registry.npmjs.org/@sentry-internal/browser-utils/-/browser-utils-10.39.0.tgz" @@ -5147,75 +4555,24 @@ resolved "https://registry.npmjs.org/@sentry/cli-darwin/-/cli-darwin-3.2.2.tgz" integrity sha512-y1uglMBbo9dYqC92hTQBkuGk7SegLPo1cVwJzX0dhplJoBMuanLMhOMYd1J20qhkDdBhguflCHGf0tOzNTGWhg== -"@sentry/cli-linux-arm64@3.2.0": - version "3.2.0" - resolved "https://registry.yarnpkg.com/@sentry/cli-linux-arm64/-/cli-linux-arm64-3.2.0.tgz#767e4f8328ad17570ef9fd4b95fa91e1c3bf057a" - integrity sha512-YFpYIfYpXYzpSrIspmPOOPjXbxWp/Ve+F2hSIQb1tSKmcxFgaUm6WfuniYF8EIaLNa8YOLECf0xo78yGXBa+ug== - -"@sentry/cli-linux-arm64@3.2.2": - version "3.2.2" - resolved "https://registry.yarnpkg.com/@sentry/cli-linux-arm64/-/cli-linux-arm64-3.2.2.tgz#bc1e1bca7e7b7dc21dc0430621899f6270f80402" - integrity sha512-SIGJknEQNDw9S/8QPTl8QLVe2IEiTKH3NeeHQ/Q2XWXig1ZebJfm4iTrdu47ypszIfxHeLvQkkVrr8mRKq16xA== - -"@sentry/cli-linux-arm@3.2.0": - version "3.2.0" - resolved "https://registry.yarnpkg.com/@sentry/cli-linux-arm/-/cli-linux-arm-3.2.0.tgz#8b6eaa900849e5b6d1675cd19c42155bd7c6b9b5" - integrity sha512-NVcGcS9mceivVhi8W6iEnlcK96OlPMzOdM6xkIm+7+J0uXHgUyMyc+rT6p2QG3j8jI5I7qAl7fTHWluSX2lx3g== - -"@sentry/cli-linux-arm@3.2.2": - version "3.2.2" - resolved "https://registry.yarnpkg.com/@sentry/cli-linux-arm/-/cli-linux-arm-3.2.2.tgz#14628eb95c4bb9c8d37f685248f4275fd991c76e" - integrity sha512-CC7N3hjOgs3cwrW0T9hqirFVUpKO6ASjdd0JT4DQHaAn34pruv8J+OoSnj1jkrT2DHxDkNNZPOFSK05AnHr8wA== - -"@sentry/cli-linux-i686@3.2.0": - version "3.2.0" - resolved "https://registry.yarnpkg.com/@sentry/cli-linux-i686/-/cli-linux-i686-3.2.0.tgz#4a745785cd3f1777b7686c8c3d487ae2701b4fdf" - integrity sha512-6SOxlF37NqRRKxEuvWryBM7MAgTisq0G6ZQzhI2iuWbcrmlLUDUW2Yssz3gAR01eYJ4jfpqvojSzPRDwqB0K+Q== - -"@sentry/cli-linux-i686@3.2.2": - version "3.2.2" - resolved "https://registry.yarnpkg.com/@sentry/cli-linux-i686/-/cli-linux-i686-3.2.2.tgz#cf764010230f310f9dbc313cec250b4e61ab97ad" - integrity sha512-W2hQ2DvIlZI05j2JN/87lfeo51F24zmQOJU6Uz+fZz/mkSvpnjeWxjAvfDNVGlLxp7XSoDbhHfrLBxdIh6jMeg== - -"@sentry/cli-linux-x64@3.2.0": - version "3.2.0" - resolved "https://registry.yarnpkg.com/@sentry/cli-linux-x64/-/cli-linux-x64-3.2.0.tgz#90081c9c8fb11112530350429162935a6d36b83e" - integrity sha512-7LRd0A74ma/1ejlTRPOoBiFiJr4LWkoE6CnA2XwAoYd0r62WUjGHtg6gIC+yElOCwtxXP7I9fOiV2BJIAq2DHw== - -"@sentry/cli-linux-x64@3.2.2": - version "3.2.2" - resolved "https://registry.yarnpkg.com/@sentry/cli-linux-x64/-/cli-linux-x64-3.2.2.tgz#7ced3f60c508c8ef8ecc71a9d9f925a370d084f8" - integrity sha512-4mh3yvOUxO63lq3teexRvalD1mWaRVjpgL2cCMKA2wkB69lcL5nK2gkdzDUKx2y/elluVdvGPPZaqOr1bfNI0w== - -"@sentry/cli-win32-arm64@3.2.0": - version "3.2.0" - resolved "https://registry.yarnpkg.com/@sentry/cli-win32-arm64/-/cli-win32-arm64-3.2.0.tgz#12a92d0d3c43e0e258530a6477cfad697dc46a59" - integrity sha512-uKRi/++gnGepsaGi4goIcC13YGNEHgOUcROkz4L3H8vAR7QxWv2SHy7SCN+2S6c+Q2mOd0DfY35cMW5MzTpb7g== - -"@sentry/cli-win32-arm64@3.2.2": - version "3.2.2" - resolved "https://registry.yarnpkg.com/@sentry/cli-win32-arm64/-/cli-win32-arm64-3.2.2.tgz#a884f8d9c18d2fdbcdb0a3c61c3bccaa662007a6" - integrity sha512-TQgfkdJgd8Y/lPzDibqc5Hamg8Hl5rN1sZwX80n4r9Ly46Yzu8Bv6KUhoNL/ktAvw9Aeko6Bx54rwZnzxFZHwg== - -"@sentry/cli-win32-i686@3.2.0": - version "3.2.0" - resolved "https://registry.yarnpkg.com/@sentry/cli-win32-i686/-/cli-win32-i686-3.2.0.tgz#952dd24be42c129821d302d4a98bbaa7197a19a8" - integrity sha512-/84NEPeFQne2bQWfDZ+3EzioxFR8ojSwExCgVsyTfqa/4PzmaerAGUAxiD+VcJLJTyJEHAjWXTmSZGbYjIgdyQ== - -"@sentry/cli-win32-i686@3.2.2": - version "3.2.2" - resolved "https://registry.yarnpkg.com/@sentry/cli-win32-i686/-/cli-win32-i686-3.2.2.tgz#7470e16be9abe4b55f97f1e58bd66b765c3f2b29" - integrity sha512-vAcnq0SdYuvwIdREgF5APocjW3d9Z17xLwugpaAz8wpOjCeC1iMEFWqbz5k49i4iDkDVNFRMENiVvWVSu1kEnA== - -"@sentry/cli-win32-x64@3.2.0": - version "3.2.0" - resolved "https://registry.yarnpkg.com/@sentry/cli-win32-x64/-/cli-win32-x64-3.2.0.tgz#3479f637887a9c28ebf33f930d45d057cb8c2a3f" - integrity sha512-N9qGGT91awsh4IMyXrRHH+qIWmv9MJk9tPPAWPvLMWgdlTHT/40WYwvA4tYj80uZXU0TN8ppBWbd3ebDyOjwxA== - -"@sentry/cli-win32-x64@3.2.2": +"@sentry/cli@^3.2.2": version "3.2.2" - resolved "https://registry.yarnpkg.com/@sentry/cli-win32-x64/-/cli-win32-x64-3.2.2.tgz#87db0a4b6de30c9b27d92a30b379d954e00c6204" - integrity sha512-xWPTXjSSdmoyG/0ee7A9KSfsScGHCdaXMP6ASt4bMx3yYJO7ziEoZzfJE2M6oglz+woAm0LV9+O/n7g80tixlQ== + resolved "https://registry.npmjs.org/@sentry/cli/-/cli-3.2.2.tgz" + integrity sha512-qmjsm9+Bq/3QGTnIfOsJdhq+8LI3imxAPbGNBpRj4R0YFk+b1ry9huRHCLgkMcRFWtPkJmGZwEq2Z7e+02QPLA== + dependencies: + progress "^2.0.3" + proxy-from-env "^1.1.0" + undici "^6.22.0" + which "^2.0.2" + optionalDependencies: + "@sentry/cli-darwin" "3.2.2" + "@sentry/cli-linux-arm" "3.2.2" + "@sentry/cli-linux-arm64" "3.2.2" + "@sentry/cli-linux-i686" "3.2.2" + "@sentry/cli-linux-x64" "3.2.2" + "@sentry/cli-win32-arm64" "3.2.2" + "@sentry/cli-win32-i686" "3.2.2" + "@sentry/cli-win32-x64" "3.2.2" "@sentry/cli@3.2.0": version "3.2.0" @@ -5236,25 +4593,6 @@ "@sentry/cli-win32-i686" "3.2.0" "@sentry/cli-win32-x64" "3.2.0" -"@sentry/cli@^3.2.2": - version "3.2.2" - resolved "https://registry.npmjs.org/@sentry/cli/-/cli-3.2.2.tgz" - integrity sha512-qmjsm9+Bq/3QGTnIfOsJdhq+8LI3imxAPbGNBpRj4R0YFk+b1ry9huRHCLgkMcRFWtPkJmGZwEq2Z7e+02QPLA== - dependencies: - progress "^2.0.3" - proxy-from-env "^1.1.0" - undici "^6.22.0" - which "^2.0.2" - optionalDependencies: - "@sentry/cli-darwin" "3.2.2" - "@sentry/cli-linux-arm" "3.2.2" - "@sentry/cli-linux-arm64" "3.2.2" - "@sentry/cli-linux-i686" "3.2.2" - "@sentry/cli-linux-x64" "3.2.2" - "@sentry/cli-win32-arm64" "3.2.2" - "@sentry/cli-win32-i686" "3.2.2" - "@sentry/cli-win32-x64" "3.2.2" - "@sentry/core@10.39.0": version "10.39.0" resolved "https://registry.npmjs.org/@sentry/core/-/core-10.39.0.tgz" @@ -5497,7 +4835,7 @@ dependencies: tslib "2.8.1" -"@shopify/react-native-skia@2.2.12": +"@shopify/react-native-skia@*", "@shopify/react-native-skia@2.2.12": version "2.2.12" resolved "https://registry.npmjs.org/@shopify/react-native-skia/-/react-native-skia-2.2.12.tgz" integrity sha512-P5wZSMPTp00hM0do+awNFtb5aPh5hSpodMGwy7NaxK90AV+SmUu7wZe6NGevzQIwgFa89Epn6xK3j4jKWdQi+A== @@ -5589,73 +4927,6 @@ source-map-js "^1.2.1" tailwindcss "4.1.18" -"@tailwindcss/oxide-android-arm64@4.1.18": - version "4.1.18" - resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz#79717f87e90135e5d3d23a3d3aecde4ca5595dd5" - integrity sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q== - -"@tailwindcss/oxide-darwin-arm64@4.1.18": - version "4.1.18" - resolved "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz" - integrity sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A== - -"@tailwindcss/oxide-darwin-x64@4.1.18": - version "4.1.18" - resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz#c05991c85aa2af47bf9d1f8172fe9e4636591e79" - integrity sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw== - -"@tailwindcss/oxide-freebsd-x64@4.1.18": - version "4.1.18" - resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz#3d48e8d79fd08ece0e02af8e72d5059646be34d0" - integrity sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA== - -"@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18": - version "4.1.18" - resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz#982ecd1a65180807ccfde67dc17c6897f2e50aa8" - integrity sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA== - -"@tailwindcss/oxide-linux-arm64-gnu@4.1.18": - version "4.1.18" - resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz#df49357bc9737b2e9810ea950c1c0647ba6573c3" - integrity sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw== - -"@tailwindcss/oxide-linux-arm64-musl@4.1.18": - version "4.1.18" - resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz#b266c12822bf87883cf152615f8fffb8519d689c" - integrity sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg== - -"@tailwindcss/oxide-linux-x64-gnu@4.1.18": - version "4.1.18" - resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz#5c737f13dd9529b25b314e6000ff54e05b3811da" - integrity sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g== - -"@tailwindcss/oxide-linux-x64-musl@4.1.18": - version "4.1.18" - resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz#3380e17f7be391f1ef924be9f0afe1f304fe3478" - integrity sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ== - -"@tailwindcss/oxide-wasm32-wasi@4.1.18": - version "4.1.18" - resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz#9464df0e28a499aab1c55e97682be37b3a656c88" - integrity sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA== - dependencies: - "@emnapi/core" "^1.7.1" - "@emnapi/runtime" "^1.7.1" - "@emnapi/wasi-threads" "^1.1.0" - "@napi-rs/wasm-runtime" "^1.1.0" - "@tybys/wasm-util" "^0.10.1" - tslib "^2.4.0" - -"@tailwindcss/oxide-win32-arm64-msvc@4.1.18": - version "4.1.18" - resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz#bbcdd59c628811f6a0a4d5b09616967d8fb0c4d4" - integrity sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA== - -"@tailwindcss/oxide-win32-x64-msvc@4.1.18": - version "4.1.18" - resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz#9c628d04623aa4c3536c508289f58d58ba4b3fb1" - integrity sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q== - "@tailwindcss/oxide@4.1.18": version "4.1.18" resolved "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz" @@ -5685,66 +4956,11 @@ postcss "^8.4.41" tailwindcss "4.1.18" -"@tauri-apps/api@2.8.0", "@tauri-apps/api@^2.8.0", "@tauri-apps/api@~2.8.0": +"@tauri-apps/api@^2.8.0", "@tauri-apps/api@~2.8.0": version "2.8.0" - resolved "https://registry.yarnpkg.com/@tauri-apps/api/-/api-2.8.0.tgz#0348a2b3ba5982ec67a7d569f329b4a55d7d5f1e" + resolved "https://registry.npmjs.org/@tauri-apps/api/-/api-2.8.0.tgz" integrity sha512-ga7zdhbS2GXOMTIZRT0mYjKJtR9fivsXzsyq5U3vjDL0s6DTMwYRm0UHNjzTY5dh4+LSC68Sm/7WEiimbQNYlw== -"@tauri-apps/cli-darwin-arm64@2.8.4": - version "2.8.4" - resolved "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.8.4.tgz" - integrity sha512-BKu8HRkYV01SMTa7r4fLx+wjgtRK8Vep7lmBdHDioP6b8XH3q2KgsAyPWfEZaZIkZ2LY4SqqGARaE9oilNe0oA== - -"@tauri-apps/cli-darwin-x64@2.8.4": - version "2.8.4" - resolved "https://registry.yarnpkg.com/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.8.4.tgz#b9c274fedce570da1910559add68657d264019db" - integrity sha512-imb9PfSd/7G6VAO7v1bQ2A3ZH4NOCbhGJFLchxzepGcXf9NKkfun157JH9mko29K6sqAwuJ88qtzbKCbWJTH9g== - -"@tauri-apps/cli-linux-arm-gnueabihf@2.8.4": - version "2.8.4" - resolved "https://registry.yarnpkg.com/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.8.4.tgz#657131a05f422b9141277f0668d370e8d671bdc0" - integrity sha512-Ml215UnDdl7/fpOrF1CNovym/KjtUbCuPgrcZ4IhqUCnhZdXuphud/JT3E8X97Y03TZ40Sjz8raXYI2ET0exzw== - -"@tauri-apps/cli-linux-arm64-gnu@2.8.4": - version "2.8.4" - resolved "https://registry.yarnpkg.com/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.8.4.tgz#35a14541e09b6548b811626d1a5d2574932116ef" - integrity sha512-pbcgBpMyI90C83CxE5REZ9ODyIlmmAPkkJXtV398X3SgZEIYy5TACYqlyyv2z5yKgD8F8WH4/2fek7+jH+ZXAw== - -"@tauri-apps/cli-linux-arm64-musl@2.8.4": - version "2.8.4" - resolved "https://registry.yarnpkg.com/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.8.4.tgz#bdd9deea17e0c2e4edf511a071c87670616af8a3" - integrity sha512-zumFeaU1Ws5Ay872FTyIm7z8kfzEHu8NcIn8M6TxbJs0a7GRV21KBdpW1zNj2qy7HynnpQCqjAYXTUUmm9JAOw== - -"@tauri-apps/cli-linux-riscv64-gnu@2.8.4": - version "2.8.4" - resolved "https://registry.yarnpkg.com/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.8.4.tgz#ac3c751ce5727fbd1da280f0aa2fb444fcd706b5" - integrity sha512-qiqbB3Zz6IyO201f+1ojxLj65WYj8mixL5cOMo63nlg8CIzsP23cPYUrx1YaDPsCLszKZo7tVs14pc7BWf+/aQ== - -"@tauri-apps/cli-linux-x64-gnu@2.8.4": - version "2.8.4" - resolved "https://registry.yarnpkg.com/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.8.4.tgz#7b2000b5e6597dc62f48cb67ee98f61b54493a19" - integrity sha512-TaqaDd9Oy6k45Hotx3pOf+pkbsxLaApv4rGd9mLuRM1k6YS/aw81YrsMryYPThrxrScEIUcmNIHaHsLiU4GMkw== - -"@tauri-apps/cli-linux-x64-musl@2.8.4": - version "2.8.4" - resolved "https://registry.yarnpkg.com/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.8.4.tgz#5dd9f6e666e004e00313d86a5d71480f7ac1269a" - integrity sha512-ot9STAwyezN8w+bBHZ+bqSQIJ0qPZFlz/AyscpGqB/JnJQVDFQcRDmUPFEaAtt2UUHSWzN3GoTJ5ypqLBp2WQA== - -"@tauri-apps/cli-win32-arm64-msvc@2.8.4": - version "2.8.4" - resolved "https://registry.yarnpkg.com/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.8.4.tgz#75eadbad9ae5726cc53139bbeae4c4a8fc4a92be" - integrity sha512-+2aJ/g90dhLiOLFSD1PbElXX3SoMdpO7HFPAZB+xot3CWlAZD1tReUFy7xe0L5GAR16ZmrxpIDM9v9gn5xRy/w== - -"@tauri-apps/cli-win32-ia32-msvc@2.8.4": - version "2.8.4" - resolved "https://registry.yarnpkg.com/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.8.4.tgz#60e6cdad4fb59d91a194652581742d08c952f0a7" - integrity sha512-yj7WDxkL1t9Uzr2gufQ1Hl7hrHuFKTNEOyascbc109EoiAqCp0tgZ2IykQqOZmZOHU884UAWI1pVMqBhS/BfhA== - -"@tauri-apps/cli-win32-x64-msvc@2.8.4": - version "2.8.4" - resolved "https://registry.yarnpkg.com/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.8.4.tgz#44461625197c531537ccba84b64c804a0d7228ae" - integrity sha512-XuvGB4ehBdd7QhMZ9qbj/8icGEatDuBNxyYHbLKsTYh90ggUlPa/AtaqcC1Fo69lGkTmq9BOKrs1aWSi7xDonA== - "@tauri-apps/cli@~2.8.2": version "2.8.4" resolved "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.8.4.tgz" @@ -5769,13 +4985,6 @@ dependencies: "@tauri-apps/api" "^2.8.0" -"@tauri-apps/plugin-notification@^2.3.1": - version "2.3.3" - resolved "https://registry.yarnpkg.com/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz#01581e3abd3bd18200121b213d3e6ebff2967fe2" - integrity sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg== - dependencies: - "@tauri-apps/api" "^2.8.0" - "@tootallnate/quickjs-emscripten@^0.23.0": version "0.23.0" resolved "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz" @@ -5801,20 +5010,6 @@ resolved "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz" integrity sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA== -"@tybys/wasm-util@^0.10.1": - version "0.10.1" - resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.10.1.tgz#ecddd3205cf1e2d5274649ff0eedd2991ed7f414" - integrity sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg== - dependencies: - tslib "^2.4.0" - -"@types/archiver@^7.0.0": - version "7.0.0" - resolved "https://registry.npmjs.org/@types/archiver/-/archiver-7.0.0.tgz" - integrity sha512-/3vwGwx9n+mCQdYZ2IKGGHEFL30I96UgBlk8EtRDDFQ9uxM1l4O5Ci6r00EMAkiDaTqD9DQ6nVrWRICnBPtzzg== - dependencies: - "@types/readdir-glob" "*" - "@types/aws-lambda@^8.10.83": version "8.10.160" resolved "https://registry.npmjs.org/@types/aws-lambda/-/aws-lambda-8.10.160.tgz" @@ -6117,7 +5312,7 @@ "@types/d3-transition" "*" "@types/d3-zoom" "*" -"@types/debug@^4.0.0": +"@types/debug@^4.0.0", "@types/debug@^4.1.12": version "4.1.12" resolved "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz" integrity sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ== @@ -6136,7 +5331,7 @@ dependencies: "@types/estree" "*" -"@types/estree@*", "@types/estree@1.0.8", "@types/estree@^1.0.0", "@types/estree@^1.0.6": +"@types/estree@*", "@types/estree@^1.0.0", "@types/estree@^1.0.6", "@types/estree@1.0.8": version "1.0.8" resolved "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz" integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== @@ -6289,20 +5484,13 @@ dependencies: "@types/node" "*" -"@types/node@*", "@types/node@>=10.0.0", "@types/node@>=20": +"@types/node@*", "@types/node@^18.0.0 || ^20.0.0 || >=22.0.0", "@types/node@^20.19.0 || >=22.12.0", "@types/node@>=10.0.0", "@types/node@>=18", "@types/node@>=20": version "25.0.10" resolved "https://registry.npmjs.org/@types/node/-/node-25.0.10.tgz" integrity sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg== dependencies: undici-types "~7.16.0" -"@types/node@>=13.7.0": - version "25.2.3" - resolved "https://registry.npmjs.org/@types/node/-/node-25.2.3.tgz" - integrity sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ== - dependencies: - undici-types "~7.16.0" - "@types/node@^20.12.3": version "20.19.30" resolved "https://registry.npmjs.org/@types/node/-/node-20.19.30.tgz" @@ -6317,6 +5505,13 @@ dependencies: undici-types "~7.16.0" +"@types/node@>=13.7.0": + version "25.2.3" + resolved "https://registry.npmjs.org/@types/node/-/node-25.2.3.tgz" + integrity sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ== + dependencies: + undici-types "~7.16.0" + "@types/parse-path@^7.0.0": version "7.0.3" resolved "https://registry.npmjs.org/@types/parse-path/-/parse-path-7.0.3.tgz" @@ -6376,7 +5571,7 @@ resolved "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz" integrity sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ== -"@types/react-dom@^19.2.3": +"@types/react-dom@*", "@types/react-dom@^19.2.3": version "19.2.3" resolved "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz" integrity sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ== @@ -6388,26 +5583,26 @@ dependencies: "@types/react" "*" -"@types/react@*", "@types/react@^19.2.7", "@types/react@^19.2.9": +"@types/react@*", "@types/react@^19.2.0", "@types/react@^19.2.9": version "19.2.10" resolved "https://registry.npmjs.org/@types/react/-/react-19.2.10.tgz" integrity sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw== dependencies: csstype "^3.2.2" -"@types/react@~19.1.10": +"@types/react@^19.1.0", "@types/react@>=18.0.0", "@types/react@~19.1.10": version "19.1.17" resolved "https://registry.npmjs.org/@types/react/-/react-19.1.17.tgz" integrity sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA== dependencies: csstype "^3.0.2" -"@types/readdir-glob@*": - version "1.1.5" - resolved "https://registry.npmjs.org/@types/readdir-glob/-/readdir-glob-1.1.5.tgz" - integrity sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg== +"@types/react@^19.2.7", "@types/react@>=19.0.0": + version "19.2.10" + resolved "https://registry.npmjs.org/@types/react/-/react-19.2.10.tgz" + integrity sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw== dependencies: - "@types/node" "*" + csstype "^3.2.2" "@types/resolve@1.20.2": version "1.20.2" @@ -6467,11 +5662,6 @@ resolved "https://registry.npmjs.org/@types/tmp/-/tmp-0.2.6.tgz" integrity sha512-chhaNf2oKHlRkDGt+tiKE2Z5aJ6qalm7Z9rlLdBwmOiAAf09YQvvoLXjWK4HWPF1xU/fqvMgfNfpVoBscA/tKA== -"@types/trusted-types@^2.0.7": - version "2.0.7" - resolved "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz" - integrity sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw== - "@types/unist@*", "@types/unist@^3.0.0": version "3.0.3" resolved "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz" @@ -6504,13 +5694,6 @@ dependencies: "@types/yargs-parser" "*" -"@types/yauzl@^2.9.1": - version "2.10.3" - resolved "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz" - integrity sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q== - dependencies: - "@types/node" "*" - "@ungap/structured-clone@^1.0.0", "@ungap/structured-clone@^1.3.0": version "1.3.0" resolved "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz" @@ -6557,7 +5740,7 @@ estree-walker "^3.0.3" magic-string "^0.30.17" -"@vitest/pretty-format@3.2.4", "@vitest/pretty-format@^3.2.4": +"@vitest/pretty-format@^3.2.4", "@vitest/pretty-format@3.2.4": version "3.2.4" resolved "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz" integrity sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA== @@ -6608,36 +5791,11 @@ resolved "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz" integrity sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw== -"@xterm/addon-fit@^0.10.0": - version "0.10.0" - resolved "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz" - integrity sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ== - -"@xterm/addon-web-links@^0.11.0": - version "0.11.0" - resolved "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.11.0.tgz" - integrity sha512-nIHQ38pQI+a5kXnRaTgwqSHnX7KE6+4SVoceompgHL26unAxdfP6IPqUTSYPQgSwM56hsElfoNrrW5V7BUED/Q== - -"@xterm/addon-webgl@^0.18.0": - version "0.18.0" - resolved "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.18.0.tgz" - integrity sha512-xCnfMBTI+/HKPdRnSOHaJDRqEpq2Ugy8LEj9GiY4J3zJObo3joylIFaMvzBwbYRg8zLtkO0KQaStCeSfoaI2/w== - -"@xterm/xterm@^5.5.0": - version "5.5.0" - resolved "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz" - integrity sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A== - "@yarnpkg/lockfile@^1.1.0": version "1.1.0" resolved "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz" integrity sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ== -"@zxing/text-encoding@0.9.0": - version "0.9.0" - resolved "https://registry.npmjs.org/@zxing/text-encoding/-/text-encoding-0.9.0.tgz" - integrity sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA== - abort-controller@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz" @@ -6658,6 +5816,14 @@ accepts@^1.3.7, accepts@^1.3.8, accepts@~1.3.4: mime-types "~2.1.34" negotiator "0.6.3" +accepts@^1.3.8: + version "1.3.8" + resolved "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz" + integrity sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw== + dependencies: + mime-types "~2.1.34" + negotiator "0.6.3" + accepts@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz" @@ -6683,7 +5849,7 @@ acorn-walk@^8.1.1: dependencies: acorn "^8.11.0" -acorn@^8.0.0, acorn@^8.11.0, acorn@^8.15.0, acorn@^8.4.1: +"acorn@^6.0.0 || ^7.0.0 || ^8.0.0", acorn@^8, acorn@^8.0.0, acorn@^8.11.0, acorn@^8.15.0, acorn@^8.4.1: version "8.15.0" resolved "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz" integrity sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg== @@ -6793,7 +5959,7 @@ any-promise@^1.0.0: resolved "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz" integrity sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A== -anymatch@^3.0.3, anymatch@~3.1.2: +anymatch@^3.0.3: version "3.1.3" resolved "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz" integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw== @@ -6801,36 +5967,13 @@ anymatch@^3.0.3, anymatch@~3.1.2: normalize-path "^3.0.0" picomatch "^2.0.4" -archiver-utils@^5.0.0, archiver-utils@^5.0.2: - version "5.0.2" - resolved "https://registry.npmjs.org/archiver-utils/-/archiver-utils-5.0.2.tgz" - integrity sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA== +anymatch@~3.1.2: + version "3.1.3" + resolved "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz" + integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw== dependencies: - glob "^10.0.0" - graceful-fs "^4.2.0" - is-stream "^2.0.1" - lazystream "^1.0.0" - lodash "^4.17.15" normalize-path "^3.0.0" - readable-stream "^4.0.0" - -archiver@^7.0.1: - version "7.0.1" - resolved "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz" - integrity sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ== - dependencies: - archiver-utils "^5.0.2" - async "^3.2.4" - buffer-crc32 "^1.0.0" - readable-stream "^4.0.0" - readdir-glob "^1.1.2" - tar-stream "^3.0.0" - zip-stream "^6.0.1" - -arg@4.1.0: - version "4.1.0" - resolved "https://registry.npmjs.org/arg/-/arg-4.1.0.tgz" - integrity sha512-ZWc51jO3qegGkVh8Hwpv636EkbesNV5ZNQPCtRa+0qytRYPEs9IYT9qITY9buezqUH5uqyzlWLcufrzU2rffdg== + picomatch "^2.0.4" arg@^4.1.0: version "4.1.3" @@ -6842,6 +5985,11 @@ arg@^5.0.2: resolved "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz" integrity sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg== +arg@4.1.0: + version "4.1.0" + resolved "https://registry.npmjs.org/arg/-/arg-4.1.0.tgz" + integrity sha512-ZWc51jO3qegGkVh8Hwpv636EkbesNV5ZNQPCtRa+0qytRYPEs9IYT9qITY9buezqUH5uqyzlWLcufrzU2rffdg== + argparse@^1.0.7: version "1.0.10" resolved "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz" @@ -6906,7 +6054,12 @@ arraybuffer.prototype.slice@^1.0.4: get-intrinsic "^1.2.6" is-array-buffer "^3.0.4" -asap@~2.0.3, asap@~2.0.6: +asap@~2.0.3: + version "2.0.6" + resolved "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz" + integrity sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA== + +asap@~2.0.6: version "2.0.6" resolved "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz" integrity sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA== @@ -7020,11 +6173,6 @@ axios@^1.10.0, axios@^1.13.2, axios@^1.6.8: form-data "^4.0.4" proxy-from-env "^1.1.0" -b4a@^1.6.4: - version "1.8.0" - resolved "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz" - integrity sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg== - babel-jest@^29.7.0: version "29.7.0" resolved "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz" @@ -7094,7 +6242,7 @@ babel-plugin-polyfill-regenerator@^0.6.5: dependencies: "@babel/helper-define-polyfill-provider" "^0.6.6" -babel-plugin-react-compiler@^1.0.0: +babel-plugin-react-compiler@*, babel-plugin-react-compiler@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz" integrity sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw== @@ -7106,7 +6254,7 @@ babel-plugin-react-native-web@~0.21.0: resolved "https://registry.npmjs.org/babel-plugin-react-native-web/-/babel-plugin-react-native-web-0.21.2.tgz" integrity sha512-SPD0J6qjJn8231i0HZhlAGH6NORe+QvRSQM2mwQEzJ2Fb3E4ruWTiiicPlHjmeWShDXLcvoorOCXjeR7k/lyWA== -babel-plugin-syntax-hermes-parser@0.29.1, babel-plugin-syntax-hermes-parser@^0.29.1: +babel-plugin-syntax-hermes-parser@^0.29.1, babel-plugin-syntax-hermes-parser@0.29.1: version "0.29.1" resolved "https://registry.npmjs.org/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.29.1.tgz" integrity sha512-2WFYnoWGdmih1I1J5eIqxATOeycOqRwYxAQBu3cUu/rhwInwHUg7k60AFNbuGjSDL8tje5GDrAnxzRLcu2pYcA== @@ -7202,55 +6350,12 @@ balanced-match@^4.0.2: resolved "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz" integrity sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA== -bare-events@^2.5.4, bare-events@^2.7.0: - version "2.8.2" - resolved "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz" - integrity sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ== - -bare-fs@^4.5.5: - version "4.5.5" - resolved "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.5.tgz" - integrity sha512-XvwYM6VZqKoqDll8BmSww5luA5eflDzY0uEFfBJtFKe4PAAtxBjU3YIxzIBzhyaEQBy1VXEQBto4cpN5RZJw+w== - dependencies: - bare-events "^2.5.4" - bare-path "^3.0.0" - bare-stream "^2.6.4" - bare-url "^2.2.2" - fast-fifo "^1.3.2" - -bare-os@^3.0.1: - version "3.7.1" - resolved "https://registry.npmjs.org/bare-os/-/bare-os-3.7.1.tgz" - integrity sha512-ebvMaS5BgZKmJlvuWh14dg9rbUI84QeV3WlWn6Ph6lFI8jJoh7ADtVTyD2c93euwbe+zgi0DVrl4YmqXeM9aIA== - -bare-path@^3.0.0: - version "3.0.0" - resolved "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz" - integrity sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw== - dependencies: - bare-os "^3.0.1" - -bare-stream@^2.6.4: - version "2.8.0" - resolved "https://registry.npmjs.org/bare-stream/-/bare-stream-2.8.0.tgz" - integrity sha512-reUN0M2sHRqCdG4lUK3Fw8w98eeUIZHL5c3H7Mbhk2yVBL+oofgaIp0ieLfD5QXwPCypBpmEEKU2WZKzbAk8GA== - dependencies: - streamx "^2.21.0" - teex "^1.0.1" - -bare-url@^2.2.2: - version "2.3.2" - resolved "https://registry.npmjs.org/bare-url/-/bare-url-2.3.2.tgz" - integrity sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw== - dependencies: - bare-path "^3.0.0" - -base64-js@1.5.1, base64-js@^1.2.3, base64-js@^1.3.0, base64-js@^1.3.1, base64-js@^1.5.1: +base64-js@^1.2.3, base64-js@^1.3.0, base64-js@^1.3.1, base64-js@^1.5.1, base64-js@1.5.1: version "1.5.1" resolved "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz" integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== -base64id@2.0.0, base64id@~2.0.0: +base64id@~2.0.0, base64id@2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz" integrity sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog== @@ -7297,15 +6402,6 @@ bintrees@1.0.2: resolved "https://registry.npmjs.org/bintrees/-/bintrees-1.0.2.tgz" integrity sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw== -bl@^4.0.3: - version "4.1.0" - resolved "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz" - integrity sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w== - dependencies: - buffer "^5.5.0" - inherits "^2.0.4" - readable-stream "^3.4.0" - block-stream2@^2.1.0: version "2.1.0" resolved "https://registry.npmjs.org/block-stream2/-/block-stream2-2.1.0.tgz" @@ -7350,13 +6446,6 @@ bplist-creator@0.1.0: dependencies: stream-buffers "2.2.x" -bplist-parser@0.3.1: - version "0.3.1" - resolved "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.3.1.tgz" - integrity sha512-PyJxiNtA5T2PlLIeBot4lbp7rj4OadzjnMZD/G5zuBNt8ei/yCU7+wW0h2bag9vr8c+/WuRWmSxbqAl9hL1rBA== - dependencies: - big-integer "1.6.x" - bplist-parser@^0.3.1: version "0.3.2" resolved "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.3.2.tgz" @@ -7364,6 +6453,13 @@ bplist-parser@^0.3.1: dependencies: big-integer "1.6.x" +bplist-parser@0.3.1: + version "0.3.1" + resolved "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.3.1.tgz" + integrity sha512-PyJxiNtA5T2PlLIeBot4lbp7rj4OadzjnMZD/G5zuBNt8ei/yCU7+wW0h2bag9vr8c+/WuRWmSxbqAl9hL1rBA== + dependencies: + big-integer "1.6.x" + brace-expansion@^1.1.7: version "1.1.12" resolved "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz" @@ -7398,7 +6494,7 @@ browser-or-node@^2.1.1: resolved "https://registry.npmjs.org/browser-or-node/-/browser-or-node-2.1.1.tgz" integrity sha512-8CVjaLJGuSKMVTxJ2DpBl5XnlNDiT4cQFeuCJJrvJmts9YrTZDizTX7PjC2s6W4x+MBGZeEY6dGMrF04/6Hgqg== -browserslist@^4.24.0, browserslist@^4.25.0, browserslist@^4.28.1: +browserslist@^4.24.0, browserslist@^4.25.0, browserslist@^4.28.1, "browserslist@>= 4.21.0": version "4.28.1" resolved "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz" integrity sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA== @@ -7436,7 +6532,7 @@ buffer-from@^1.0.0: resolved "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz" integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== -buffer@^5.4.3, buffer@^5.5.0: +buffer@^5.4.3: version "5.7.1" resolved "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz" integrity sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ== @@ -7459,7 +6555,7 @@ bundle-name@^4.1.0: dependencies: run-applescript "^7.0.0" -bytes@3.1.2, bytes@^3.1.2, bytes@~3.1.2: +bytes@^3.1.2, bytes@~3.1.2, bytes@3.1.2: version "3.1.2" resolved "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz" integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== @@ -7559,12 +6655,22 @@ camelcase-css@^2.0.1: resolved "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz" integrity sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA== -camelcase@^5.0.0, camelcase@^5.3.1: +camelcase@^5.0.0: + version "5.3.1" + resolved "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz" + integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== + +camelcase@^5.3.1: version "5.3.1" resolved "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz" integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== -camelcase@^6.2.0, camelcase@^6.3.0: +camelcase@^6.2.0: + version "6.3.0" + resolved "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz" + integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== + +camelcase@^6.3.0: version "6.3.0" resolved "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz" integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== @@ -7597,20 +6703,21 @@ chai@^5.2.0: loupe "^3.1.0" pathval "^2.0.0" -chalk@*, chalk@^5.6.0, chalk@^5.6.2: +chalk@*: version "5.6.2" resolved "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz" integrity sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA== -chalk@4.1.2, chalk@^4.0.0, chalk@^4.1.0, chalk@^4.1.2: - version "4.1.2" - resolved "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz" - integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== +chalk@^2.0.1: + version "2.4.2" + resolved "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz" + integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== dependencies: - ansi-styles "^4.1.0" - supports-color "^7.1.0" + ansi-styles "^3.2.1" + escape-string-regexp "^1.0.5" + supports-color "^5.3.0" -chalk@^2.0.1, chalk@^2.4.2: +chalk@^2.4.2: version "2.4.2" resolved "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz" integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== @@ -7619,6 +6726,19 @@ chalk@^2.0.1, chalk@^2.4.2: escape-string-regexp "^1.0.5" supports-color "^5.3.0" +chalk@^4.0.0, chalk@^4.1.0, chalk@^4.1.2, chalk@4.1.2: + version "4.1.2" + resolved "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz" + integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== + dependencies: + ansi-styles "^4.1.0" + supports-color "^7.1.0" + +chalk@^5.6.0, chalk@^5.6.2: + version "5.6.2" + resolved "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz" + integrity sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA== + character-entities-html4@^2.0.0: version "2.1.0" resolved "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz" @@ -7656,7 +6776,7 @@ chevrotain-allstar@~0.3.0: dependencies: lodash-es "^4.17.21" -chevrotain@~11.0.3: +chevrotain@^11.0.0, chevrotain@~11.0.3: version "11.0.3" resolved "https://registry.npmjs.org/chevrotain/-/chevrotain-11.0.3.tgz" integrity sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw== @@ -7697,11 +6817,6 @@ chokidar@^5.0.0: dependencies: readdirp "^5.0.0" -chownr@^1.1.1: - version "1.1.4" - resolved "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz" - integrity sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg== - chownr@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz" @@ -7739,6 +6854,11 @@ ci-info@^3.2.0, ci-info@^3.3.0, ci-info@^3.7.0: resolved "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz" integrity sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ== +ci-info@^3.3.0: + version "3.9.0" + resolved "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz" + integrity sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ== + ci-info@^4.3.1: version "4.3.1" resolved "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz" @@ -7817,7 +6937,7 @@ cli-width@^4.1.0: resolved "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz" integrity sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ== -client-only@0.0.1, client-only@^0.0.1: +client-only@^0.0.1, client-only@0.0.1: version "0.0.1" resolved "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz" integrity sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA== @@ -7852,7 +6972,12 @@ clone@^1.0.2: resolved "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz" integrity sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg== -clsx@2.1.1, clsx@^2.1.1: +clsx@^2.1.1: + version "2.1.1" + resolved "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz" + integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA== + +clsx@2.1.1: version "2.1.1" resolved "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz" integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA== @@ -7895,11 +7020,6 @@ color-convert@^3.1.3: dependencies: color-name "^2.0.0" -color-name@1.1.3: - version "1.1.3" - resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz" - integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw== - color-name@^1.0.0, color-name@~1.1.4: version "1.1.4" resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz" @@ -7910,6 +7030,11 @@ color-name@^2.0.0: resolved "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz" integrity sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg== +color-name@1.1.3: + version "1.1.3" + resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz" + integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw== + color-string@^1.9.0: version "1.9.1" resolved "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz" @@ -7963,11 +7088,6 @@ command-exists@^1.2.9: resolved "https://registry.npmjs.org/command-exists/-/command-exists-1.2.9.tgz" integrity sha512-LTQ/SGc+s0Xc0Fu5WaKnR0YiygZkm9eKFvyS+fRsU7/ZWFF8ykFM6Pc9aCVf1+xasOOZpO3BAVgVrKvsqKHV7w== -commander@7, commander@^7.2.0: - version "7.2.0" - resolved "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz" - integrity sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw== - commander@^12.0.0: version "12.1.0" resolved "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz" @@ -7983,6 +7103,11 @@ commander@^4.0.0: resolved "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz" integrity sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA== +commander@^7.2.0, commander@7: + version "7.2.0" + resolved "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz" + integrity sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw== + commander@^8.3.0: version "8.3.0" resolved "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz" @@ -7993,17 +7118,6 @@ commondir@^1.0.1: resolved "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz" integrity sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg== -compress-commons@^6.0.2: - version "6.0.2" - resolved "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz" - integrity sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg== - dependencies: - crc-32 "^1.2.0" - crc32-stream "^6.0.0" - is-stream "^2.0.1" - normalize-path "^3.0.0" - readable-stream "^4.0.0" - compressible@~2.0.18: version "2.0.18" resolved "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz" @@ -8054,6 +7168,16 @@ connect@^3.6.5, connect@^3.7.0: parseurl "~1.3.3" utils-merge "1.0.1" +connect@^3.7.0: + version "3.7.0" + resolved "https://registry.npmjs.org/connect/-/connect-3.7.0.tgz" + integrity sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ== + dependencies: + debug "2.6.9" + finalhandler "1.1.2" + parseurl "~1.3.3" + utils-merge "1.0.1" + consola@^3.2.3, consola@^3.4.0: version "3.4.2" resolved "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz" @@ -8069,7 +7193,7 @@ content-type@^1.0.5: resolved "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz" integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== -convert-source-map@2.0.0, convert-source-map@^2.0.0: +convert-source-map@^2.0.0, convert-source-map@2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz" integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg== @@ -8101,11 +7225,6 @@ core-js-compat@^3.43.0: dependencies: browserslist "^4.28.1" -core-util-is@~1.0.0: - version "1.0.3" - resolved "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz" - integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ== - cors@^2.8.5, cors@~2.8.5: version "2.8.6" resolved "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz" @@ -8128,19 +7247,6 @@ cose-base@^2.2.0: dependencies: layout-base "^2.0.0" -crc-32@^1.2.0: - version "1.2.2" - resolved "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz" - integrity sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ== - -crc32-stream@^6.0.0: - version "6.0.0" - resolved "https://registry.npmjs.org/crc32-stream/-/crc32-stream-6.0.0.tgz" - integrity sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g== - dependencies: - crc-32 "^1.2.0" - readable-stream "^4.0.0" - create-require@^1.1.0: version "1.1.1" resolved "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz" @@ -8250,11 +7356,18 @@ cytoscape-fcose@^2.2.0: dependencies: cose-base "^2.2.0" -cytoscape@^3.29.3: +cytoscape@^3.2.0, cytoscape@^3.29.3: version "3.33.1" resolved "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz" integrity sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ== +d3-array@^3.2.0, "d3-array@2 - 3", "d3-array@2.10.0 - 3", "d3-array@2.5.0 - 3", d3-array@3: + version "3.2.4" + resolved "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz" + integrity sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg== + dependencies: + internmap "1 - 2" + "d3-array@1 - 2": version "2.12.1" resolved "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz" @@ -8262,13 +7375,6 @@ cytoscape@^3.29.3: dependencies: internmap "^1.0.0" -"d3-array@2 - 3", "d3-array@2.10.0 - 3", "d3-array@2.5.0 - 3", d3-array@3, d3-array@^3.2.0: - version "3.2.4" - resolved "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz" - integrity sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg== - dependencies: - internmap "1 - 2" - d3-axis@3: version "3.0.0" resolved "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz" @@ -8378,16 +7484,16 @@ d3-hierarchy@3: dependencies: d3-color "1 - 3" +d3-path@^3.1.0, "d3-path@1 - 3", d3-path@3: + version "3.1.0" + resolved "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz" + integrity sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ== + d3-path@1: version "1.0.9" resolved "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz" integrity sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg== -"d3-path@1 - 3", d3-path@3, d3-path@^3.1.0: - version "3.1.0" - resolved "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz" - integrity sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ== - d3-polygon@3: version "3.0.1" resolved "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz" @@ -8435,13 +7541,6 @@ d3-scale@4: resolved "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz" integrity sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ== -d3-shape@3: - version "3.2.0" - resolved "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz" - integrity sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA== - dependencies: - d3-path "^3.1.0" - d3-shape@^1.2.0: version "1.3.7" resolved "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz" @@ -8449,6 +7548,13 @@ d3-shape@^1.2.0: dependencies: d3-path "1" +d3-shape@3: + version "3.2.0" + resolved "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz" + integrity sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA== + dependencies: + d3-path "^3.1.0" + "d3-time-format@2 - 4", d3-time-format@4: version "4.1.0" resolved "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz" @@ -8589,27 +7695,13 @@ dayjs@^1.11.18: resolved "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz" integrity sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw== -debug@2.6.9, debug@^2.6.9: +debug@^2.6.9: version "2.6.9" resolved "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz" integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== dependencies: ms "2.0.0" -debug@4, debug@^4.0.0, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.3.5, debug@^4.3.6, debug@^4.4.0, debug@^4.4.1, debug@^4.4.3, debug@~4.4.1: - version "4.4.3" - resolved "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz" - integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== - dependencies: - ms "^2.1.3" - -debug@4.3.4: - version "4.3.4" - resolved "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz" - integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== - dependencies: - ms "2.1.2" - debug@^3.1.0: version "3.2.7" resolved "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz" @@ -8617,6 +7709,13 @@ debug@^3.1.0: dependencies: ms "^2.1.1" +debug@^4.0.0, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.3.5, debug@^4.3.6, debug@^4.4.0, debug@^4.4.1, debug@^4.4.3, debug@~4.4.1, debug@4: + version "4.4.3" + resolved "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz" + integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== + dependencies: + ms "^2.1.3" + debug@~4.3.1: version "4.3.7" resolved "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz" @@ -8624,6 +7723,20 @@ debug@~4.3.1: dependencies: ms "^2.1.3" +debug@2.6.9: + version "2.6.9" + resolved "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz" + integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== + dependencies: + ms "2.0.0" + +debug@4.3.4: + version "4.3.4" + resolved "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz" + integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== + dependencies: + ms "2.1.2" + decamelize@^1.2.0: version "1.2.0" resolved "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz" @@ -8762,7 +7875,7 @@ denque@^2.1.0: resolved "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz" integrity sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw== -depd@2.0.0, depd@^2.0.0, depd@~2.0.0: +depd@^2.0.0, depd@~2.0.0, depd@2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz" integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== @@ -8782,7 +7895,7 @@ destroy@1.2.0: resolved "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz" integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg== -detect-libc@^2.0.0, detect-libc@^2.0.3, detect-libc@^2.1.2: +detect-libc@^2.0.3, detect-libc@^2.1.2: version "2.1.2" resolved "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz" integrity sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ== @@ -8809,16 +7922,16 @@ didyoumean@^1.2.2: resolved "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz" integrity sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw== -diff@8.0.3, diff@^8.0.2: - version "8.0.3" - resolved "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz" - integrity sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ== - diff@^4.0.1: version "4.0.4" resolved "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz" integrity sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ== +diff@^8.0.2, diff@8.0.3: + version "8.0.3" + resolved "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz" + integrity sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ== + dijkstrajs@^1.0.1: version "1.0.3" resolved "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz" @@ -8829,6 +7942,19 @@ dlv@^1.1.3: resolved "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz" integrity sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA== +"docs@file:/Users/cdenneen/code/workspace/work/happier-dev/apps/docs": + version "0.0.0" + resolved "file:apps/docs" + dependencies: + fumadocs-core "16.4.11" + fumadocs-mdx "14.2.6" + fumadocs-ui "16.4.11" + lucide-react "^0.563.0" + next "16.1.4" + react "^19.2.3" + react-dom "^19.2.3" + tailwind-merge "^3.4.0" + dom-serializer@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz" @@ -8850,13 +7976,6 @@ domhandler@^5.0.2, domhandler@^5.0.3: dependencies: domelementtype "^2.3.0" -dompurify@3.2.7: - version "3.2.7" - resolved "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz" - integrity sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw== - optionalDependencies: - "@types/trusted-types" "^2.0.7" - dompurify@^3.2.5: version "3.3.1" resolved "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz" @@ -8864,6 +7983,13 @@ dompurify@^3.2.5: optionalDependencies: "@types/trusted-types" "^2.0.7" +dompurify@3.2.7: + version "3.2.7" + resolved "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz" + integrity sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw== + optionalDependencies: + "@types/trusted-types" "^2.0.7" + domutils@^3.0.1: version "3.2.2" resolved "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz" @@ -8950,9 +8076,7 @@ electron-to-chromium@^1.5.263: integrity sha512-0bblUU5UNdOt5G7XqGiJtpZMONma6WAfq9vsFmtn9x1+joAObr6x1chfqyxFSDCAFwFhCQDrqeAr6MYdpwJ9Hg== elevenlabs@^1.54.0: - version "1.59.0" - resolved "https://registry.yarnpkg.com/elevenlabs/-/elevenlabs-1.59.0.tgz#8db85064e3b70b65c63aaa25c105e1e3701c0fe4" - integrity sha512-OVKOd+lxNya8h4Rn5fcjv00Asd+DGWfTT6opGrQ16sTI+1HwdLn/kYtjl8tRMhDXbNmksD/9SBRKjb9neiUuVg== + version "v1.59.0" dependencies: command-exists "^1.2.9" execa "^5.1.1" @@ -8994,7 +8118,7 @@ encodeurl@~1.0.2: resolved "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz" integrity sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w== -end-of-stream@^1.1.0, end-of-stream@^1.4.1: +end-of-stream@^1.1.0: version "1.4.5" resolved "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz" integrity sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg== @@ -9390,7 +8514,7 @@ eslint-visitor-keys@^4.2.1: resolved "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz" integrity sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ== -eslint@^9: +"eslint@^6.0.0 || ^7.0.0 || >=8.0.0", "eslint@^8.40 || 9", eslint@^9, eslint@>=7.0.0: version "9.39.2" resolved "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz" integrity sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw== @@ -9439,7 +8563,12 @@ espree@^10.0.1, espree@^10.4.0: acorn-jsx "^5.3.2" eslint-visitor-keys "^4.2.1" -esprima@^4.0.0, esprima@^4.0.1: +esprima@^4.0.0: + version "4.0.1" + resolved "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz" + integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== + +esprima@^4.0.1: version "4.0.1" resolved "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz" integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== @@ -9527,7 +8656,14 @@ estree-walker@^2.0.2: resolved "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz" integrity sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w== -estree-walker@^3.0.0, estree-walker@^3.0.3: +estree-walker@^3.0.0: + version "3.0.3" + resolved "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz" + integrity sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g== + dependencies: + "@types/estree" "^1.0.0" + +estree-walker@^3.0.3: version "3.0.3" resolved "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz" integrity sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g== @@ -9549,16 +8685,16 @@ etag@^1.8.1, etag@~1.8.1: resolved "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz" integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg== -event-target-shim@6.0.2: - version "6.0.2" - resolved "https://registry.npmjs.org/event-target-shim/-/event-target-shim-6.0.2.tgz" - integrity sha512-8q3LsZjRezbFZ2PN+uP+Q7pnHUMmAOziU2vA2OwoFaKIXxlxl38IylhSSgUorWu/rf4er67w0ikBqjBFk/pomA== - event-target-shim@^5.0.0: version "5.0.1" resolved "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz" integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ== +event-target-shim@6.0.2: + version "6.0.2" + resolved "https://registry.npmjs.org/event-target-shim/-/event-target-shim-6.0.2.tgz" + integrity sha512-8q3LsZjRezbFZ2PN+uP+Q7pnHUMmAOziU2vA2OwoFaKIXxlxl38IylhSSgUorWu/rf4er67w0ikBqjBFk/pomA== + eventemitter3@^4.0.0: version "4.0.7" resolved "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz" @@ -9569,13 +8705,6 @@ eventemitter3@^5.0.1: resolved "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz" integrity sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw== -events-universal@^1.0.0: - version "1.0.1" - resolved "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz" - integrity sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw== - dependencies: - bare-events "^2.7.0" - events@^3.3.0: version "3.3.0" resolved "https://registry.npmjs.org/events/-/events-3.3.0.tgz" @@ -9628,22 +8757,17 @@ execa@^8.0.1: signal-exit "^4.1.0" strip-final-newline "^3.0.0" -expand-template@^2.0.3: - version "2.0.3" - resolved "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz" - integrity sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg== - expect-type@^1.2.1: version "1.3.0" resolved "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz" integrity sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA== -expo-application@~7.0.7, expo-application@~7.0.8: +"expo-application@>= 4.0.0", expo-application@~7.0.7, expo-application@~7.0.8: version "7.0.8" resolved "https://registry.npmjs.org/expo-application/-/expo-application-7.0.8.tgz" integrity sha512-qFGyxk7VJbrNOQWBbE09XUuGuvkOgFS9QfToaK2FdagM2aQ+x3CvGV2DuVgl/l4ZxPgIf3b/MNh9xHpwSwn74Q== -expo-asset@~12.0.12, expo-asset@~12.0.9: +expo-asset@*, expo-asset@~12.0.12, expo-asset@~12.0.9: version "12.0.12" resolved "https://registry.npmjs.org/expo-asset/-/expo-asset-12.0.12.tgz" integrity sha512-CsXFCQbx2fElSMn0lyTdRIyKlSXOal6ilLJd+yeZ6xaC7I9AICQgscY5nj0QcwgA+KYYCCEQEBndMsmj7drOWQ== @@ -9673,7 +8797,7 @@ expo-clipboard@^8.0.7: resolved "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-8.0.8.tgz" integrity sha512-VKoBkHIpZZDJTB0jRO4/PZskHdMNOEz3P/41tmM6fDuODMpqhvyWK053X0ebspkxiawJX9lX33JXHBCvVsTTOA== -expo-constants@~18.0.12, expo-constants@~18.0.13, expo-constants@~18.0.9: +expo-constants@^18.0.13, expo-constants@~18.0.12, expo-constants@~18.0.13, expo-constants@~18.0.9: version "18.0.13" resolved "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz" integrity sha512-FnZn12E1dRYKDHlAdIyNFhBurKTS3F9CrfrBDJI5m3D7U17KBHMQ6JEfYlSj7LG7t+Ulr+IKaj58L1k5gBwTcQ== @@ -9720,7 +8844,7 @@ expo-dev-menu@7.0.18: dependencies: expo-dev-menu-interface "2.0.0" -expo-device@~8.0.8: +"expo-device@>= 4.0.0", expo-device@~8.0.8: version "8.0.10" resolved "https://registry.npmjs.org/expo-device/-/expo-device-8.0.10.tgz" integrity sha512-jd5BxjaF7382JkDMaC+P04aXXknB2UhWaVx5WiQKA05ugm/8GH5uaz9P9ckWdMKZGQVVEOC8MHaUADoT26KmFA== @@ -9737,12 +8861,12 @@ expo-eas-client@~1.0.8: resolved "https://registry.npmjs.org/expo-eas-client/-/expo-eas-client-1.0.8.tgz" integrity sha512-5or11NJhSeDoHHI6zyvQDW2cz/yFyE+1Cz8NTs5NK8JzC7J0JrkUgptWtxyfB6Xs/21YRNifd3qgbBN3hfKVgA== -expo-file-system@~19.0.14, expo-file-system@~19.0.21: +"expo-file-system@>= 13.0.0", expo-file-system@~19.0.14, expo-file-system@~19.0.21: version "19.0.21" resolved "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.21.tgz" integrity sha512-s3DlrDdiscBHtab/6W1osrjGL+C2bvoInPJD7sOwmxfJ5Woynv2oc+Fz1/xVXaE/V7HE/+xrHC/H45tu6lZzzg== -expo-font@~14.0.11, expo-font@~14.0.8: +expo-font@>=14.0.4, expo-font@~14.0.11, expo-font@~14.0.8: version "14.0.11" resolved "https://registry.npmjs.org/expo-font/-/expo-font-14.0.11.tgz" integrity sha512-ga0q61ny4s/kr4k8JX9hVH69exVSIfcIc19+qZ7gt71Mqtm7xy2c6kwsPTCyhBW2Ro5yXTT8EaZOpuRi35rHbg== @@ -9754,18 +8878,6 @@ expo-haptics@~15.0.7: resolved "https://registry.npmjs.org/expo-haptics/-/expo-haptics-15.0.8.tgz" integrity sha512-lftutojy8Qs8zaDzzjwM3gKHFZ8bOOEZDCkmh2Ddpe95Ra6kt2izeOfOfKuP/QEh0MZ1j9TfqippyHdRd1ZM9g== -expo-image-loader@~5.0.0: - version "5.0.0" - resolved "https://registry.yarnpkg.com/expo-image-loader/-/expo-image-loader-5.0.0.tgz#4f58a21ab26e40d6fccc211b664fd9fe21a5dcb8" - integrity sha512-Eg+5FHtyzv3Jjw9dHwu2pWy4xjf8fu3V0Asyy42kO+t/FbvW/vjUixpTjPtgKQLQh+2/9Nk4JjFDV6FwCnF2ZA== - -expo-image-picker@~16.0.6: - version "16.0.6" - resolved "https://registry.yarnpkg.com/expo-image-picker/-/expo-image-picker-16.0.6.tgz#ab1378711813a8c857ee577a97ce43e31ea84ab5" - integrity sha512-HN4xZirFjsFDIsWFb12AZh19fRzuvZjj2ll17cGr19VNRP06S/VPQU3Tdccn5vwUzQhOBlLu704CnNm278boiQ== - dependencies: - expo-image-loader "~5.0.0" - expo-image@~3.0.8: version "3.0.11" resolved "https://registry.npmjs.org/expo-image/-/expo-image-3.0.11.tgz" @@ -9786,7 +8898,7 @@ expo-linear-gradient@~15.0.7: resolved "https://registry.npmjs.org/expo-linear-gradient/-/expo-linear-gradient-15.0.8.tgz" integrity sha512-V2d8Wjn0VzhPHO+rrSBtcl+Fo+jUUccdlmQ6OoL9/XQB7Qk3d9lYrqKDJyccwDxmQT10JdST3Tmf2K52NLc3kw== -expo-linking@~8.0.8: +expo-linking@^8.0.11, expo-linking@~8.0.8: version "8.0.11" resolved "https://registry.npmjs.org/expo-linking/-/expo-linking-8.0.11.tgz" integrity sha512-+VSaNL5om3kOp/SSKO5qe6cFgfSIWnnQDSbA7XLs3ECkYzXRquk5unxNS3pg7eK5kNUmQ4kgLI7MhTggAEUBLA== @@ -9794,7 +8906,7 @@ expo-linking@~8.0.8: expo-constants "~18.0.12" invariant "^2.2.4" -expo-localization@~17.0.7: +"expo-localization@>= 11.0.0", expo-localization@~17.0.7: version "17.0.8" resolved "https://registry.npmjs.org/expo-localization/-/expo-localization-17.0.8.tgz" integrity sha512-UrdwklZBDJ+t+ZszMMiE0SXZ2eJxcquCuQcl6EvGHM9K+e6YqKVRQ+w8qE+iIB3H75v2RJy6MHAaLK+Mqeo04g== @@ -9845,7 +8957,7 @@ expo-notifications@~0.32.11: expo-application "~7.0.8" expo-constants "~18.0.13" -expo-router@6.0.22: +expo-router@*, expo-router@6.0.22: version "6.0.22" resolved "https://registry.npmjs.org/expo-router/-/expo-router-6.0.22.tgz" integrity sha512-6eOwobaVZQRsSQv0IoWwVlPbJru1zbreVsuPFIWwk7HApENStU2MggrceHXJqXjGho+FKeXxUop/gqOFDzpOMg== @@ -9893,11 +9005,6 @@ expo-server@^1.0.5: resolved "https://registry.npmjs.org/expo-server/-/expo-server-1.0.5.tgz" integrity sha512-IGR++flYH70rhLyeXF0Phle56/k4cee87WeQ4mamS+MkVAVP+dDlOHf2nN06Z9Y2KhU0Gp1k+y61KkghF7HdhA== -expo-sharing@~14.0.8: - version "14.0.8" - resolved "https://registry.npmjs.org/expo-sharing/-/expo-sharing-14.0.8.tgz" - integrity sha512-A1pPr2iBrxypFDCWVAESk532HK+db7MFXbvO2sCV9ienaFXAk7lIBm6bkqgE6vzRd9O3RGdEGzYx80cYlc089Q== - expo-speech-recognition@^3.1.0: version "3.1.0" resolved "https://registry.npmjs.org/expo-speech-recognition/-/expo-speech-recognition-3.1.0.tgz" @@ -9970,7 +9077,7 @@ expo-web-browser@~15.0.7: resolved "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-15.0.10.tgz" integrity sha512-fvDhW4bhmXAeWFNFiInmsGCK83PAqAcQaFyp/3pE/jbdKmFKoRCWr46uZGIfN4msLK/OODhaQ/+US7GSJNDHJg== -expo@^54.0.0: +expo@*, expo@^54, expo@^54.0.0, expo@>=49.0.0: version "54.0.32" resolved "https://registry.npmjs.org/expo/-/expo-54.0.32.tgz" integrity sha512-yL9eTxiQ/QKKggVDAWO5CLjUl6IS0lPYgEvC3QM4q4fxd6rs7ks3DnbXSGVU3KNFoY/7cRNYihvd0LKYP+MCXA== @@ -10007,7 +9114,7 @@ express-rate-limit@^7.5.0: resolved "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz" integrity sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw== -express@^5.0.1: +express@^5.0.1, "express@>= 4.11": version "5.2.1" resolved "https://registry.npmjs.org/express/-/express-5.2.1.tgz" integrity sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw== @@ -10089,11 +9196,6 @@ fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: resolved "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz" integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== -fast-fifo@^1.2.0, fast-fifo@^1.3.2: - version "1.3.2" - resolved "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz" - integrity sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ== - fast-glob@^3.2.12, fast-glob@^3.3.2: version "3.3.3" resolved "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz" @@ -10105,7 +9207,12 @@ fast-glob@^3.2.12, fast-glob@^3.3.2: merge2 "^1.3.0" micromatch "^4.0.8" -fast-json-stable-stringify@^2.0.0, fast-json-stable-stringify@^2.1.0: +fast-json-stable-stringify@^2.0.0: + version "2.1.0" + resolved "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz" + integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== + +fast-json-stable-stringify@^2.1.0: version "2.1.0" resolved "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz" integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== @@ -10163,7 +9270,7 @@ fastify-type-provider-zod@6.1.0: dependencies: "@fastify/error" "^4.2.0" -fastify@^5.2.0, fastify@^5.6.2: +fastify@^5.2.0, fastify@^5.5.0, fastify@^5.6.2: version "5.7.2" resolved "https://registry.npmjs.org/fastify/-/fastify-5.7.2.tgz" integrity sha512-dBJolW+hm6N/yJVf6J5E1BxOBNkuXNl405nrfeR8SpvGWG3aCC2XDHyiFBdow8Win1kj7sjawQc257JlYY6M/A== @@ -10247,6 +9354,18 @@ filter-obj@^1.1.0: resolved "https://registry.npmjs.org/filter-obj/-/filter-obj-1.1.0.tgz" integrity sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ== +finalhandler@^2.1.0: + version "2.1.1" + resolved "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz" + integrity sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA== + dependencies: + debug "^4.4.0" + encodeurl "^2.0.0" + escape-html "^1.0.3" + on-finished "^2.4.1" + parseurl "^1.3.3" + statuses "^2.0.1" + finalhandler@1.1.2: version "1.1.2" resolved "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz" @@ -10260,18 +9379,6 @@ finalhandler@1.1.2: statuses "~1.5.0" unpipe "~1.0.0" -finalhandler@^2.1.0: - version "2.1.1" - resolved "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz" - integrity sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA== - dependencies: - debug "^4.4.0" - encodeurl "^2.0.0" - escape-html "^1.0.3" - on-finished "^2.4.1" - parseurl "^1.3.3" - statuses "^2.0.1" - find-babel-config@^2.1.1: version "2.1.2" resolved "https://registry.npmjs.org/find-babel-config/-/find-babel-config-2.1.2.tgz" @@ -10431,11 +9538,6 @@ fresh@~0.5.2: resolved "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz" integrity sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q== -fs-constants@^1.0.0: - version "1.0.0" - resolved "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz" - integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow== - fs-extra@^10.0.0: version "10.1.0" resolved "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz" @@ -10450,17 +9552,7 @@ fs.realpath@^1.0.0: resolved "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz" integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== -fsevents@2.3.2: - version "2.3.2" - resolved "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz" - integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== - -fsevents@^2.3.2, fsevents@~2.3.2, fsevents@~2.3.3: - version "2.3.3" - resolved "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz" - integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== - -fumadocs-core@16.4.11: +"fumadocs-core@^15.0.0 || ^16.0.0", fumadocs-core@16.4.11: version "16.4.11" resolved "https://registry.npmjs.org/fumadocs-core/-/fumadocs-core-16.4.11.tgz" integrity sha512-ORjWgYetxDgyHZocuvEghfxt6tuEPWE+Km5KvwNKlXPxcNdBIiSVCED8WEMwiw1n/FZ/ys+W+BOe58ZXxhWg2A== @@ -10690,17 +9782,12 @@ git-url-parse@16.1.0: dependencies: git-up "^8.1.0" -github-from-package@0.0.0: - version "0.0.0" - resolved "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz" - integrity sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw== - github-slugger@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz" integrity sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw== -glob-parent@^5.1.2, glob-parent@~5.1.2: +glob-parent@^5.1.2: version "5.1.2" resolved "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz" integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== @@ -10714,7 +9801,14 @@ glob-parent@^6.0.2: dependencies: is-glob "^4.0.3" -glob@^10.0.0, glob@^10.3.7: +glob-parent@~5.1.2: + version "5.1.2" + resolved "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz" + integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== + dependencies: + is-glob "^4.0.1" + +glob@^10.3.7: version "10.5.0" resolved "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz" integrity sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg== @@ -10735,7 +9829,19 @@ glob@^13.0.0: minipass "^7.1.2" path-scurry "^2.0.0" -glob@^7.0.0, glob@^7.1.1, glob@^7.1.3, glob@^7.1.4: +glob@^7.0.0: + version "7.2.3" + resolved "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz" + integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== + dependencies: + fs.realpath "^1.0.0" + inflight "^1.0.4" + inherits "2" + minimatch "^3.1.1" + once "^1.3.0" + path-is-absolute "^1.0.0" + +glob@^7.1.1, glob@^7.1.3, glob@^7.1.4: version "7.2.3" resolved "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz" integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== @@ -10781,7 +9887,15 @@ globals@^14.0.0: resolved "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz" integrity sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ== -globalthis@^1.0.1, globalthis@^1.0.4: +globalthis@^1.0.1: + version "1.0.4" + resolved "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz" + integrity sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ== + dependencies: + define-properties "^1.2.1" + gopd "^1.0.1" + +globalthis@^1.0.4: version "1.0.4" resolved "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz" integrity sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ== @@ -10901,7 +10015,7 @@ hast-util-to-estree@^3.0.0, hast-util-to-estree@^3.1.3: unist-util-position "^5.0.0" zwitch "^2.0.0" -hast-util-to-html@9.0.5, hast-util-to-html@^9.0.5: +hast-util-to-html@^9.0.5, hast-util-to-html@9.0.5: version "9.0.5" resolved "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz" integrity sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw== @@ -10968,7 +10082,7 @@ hermes-estree@0.32.0: resolved "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.32.0.tgz" integrity sha512-KWn3BqnlDOl97Xe1Yviur6NbgIZ+IP+UVSpshlZWkq+EtoHg6/cwiDj/osP9PCEgFE15KBm1O55JRwbMEm5ejQ== -hermes-parser@0.29.1, hermes-parser@^0.29.1: +hermes-parser@^0.29.1, hermes-parser@0.29.1: version "0.29.1" resolved "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.29.1.tgz" integrity sha512-xBHWmUtRC5e/UL0tI7Ivt2riA/YBq9+SiYFU7C1oBa/j2jYGlIF9043oak1F47ihuDIxQ5nbsKueYJDRY02UgA== @@ -11084,17 +10198,24 @@ hyphenate-style-name@^1.0.3: resolved "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.1.0.tgz" integrity sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw== -iconv-lite@0.6, iconv-lite@0.6.3: +iconv-lite@^0.7.0, iconv-lite@~0.7.0: + version "0.7.2" + resolved "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz" + integrity sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw== + dependencies: + safer-buffer ">= 2.1.2 < 3.0.0" + +iconv-lite@0.6: version "0.6.3" resolved "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz" integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw== dependencies: safer-buffer ">= 2.1.2 < 3.0.0" -iconv-lite@^0.7.0, iconv-lite@~0.7.0: - version "0.7.2" - resolved "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz" - integrity sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw== +iconv-lite@0.6.3: + version "0.6.3" + resolved "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz" + integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw== dependencies: safer-buffer ">= 2.1.2 < 3.0.0" @@ -11120,7 +10241,7 @@ image-size@^2.0.2: resolved "https://registry.npmjs.org/image-size/-/image-size-2.0.2.tgz" integrity sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w== -immer@>=10.0.0, immer@^11.0.0: +immer@^11.0.0, immer@>=10.0.0, immer@>=9.0.6: version "11.1.3" resolved "https://registry.npmjs.org/immer/-/immer-11.1.3.tgz" integrity sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q== @@ -11161,7 +10282,7 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@2, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.3, inherits@~2.0.4: +inherits@^2.0.3, inherits@~2.0.3, inherits@~2.0.4, inherits@2: version "2.0.4" resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -11234,22 +10355,22 @@ internal-slot@^1.1.0: hasown "^2.0.2" side-channel "^1.1.0" -"internmap@1 - 2": - version "2.0.3" - resolved "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz" - integrity sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg== - internmap@^1.0.0: version "1.0.1" resolved "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz" integrity sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw== +"internmap@1 - 2": + version "2.0.3" + resolved "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz" + integrity sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg== + interpret@^1.0.0: version "1.4.0" resolved "https://registry.npmjs.org/interpret/-/interpret-1.4.0.tgz" integrity sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA== -invariant@2.2.4, invariant@^2.2.4: +invariant@^2.2.4, invariant@2.2.4: version "2.2.4" resolved "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz" integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA== @@ -11276,16 +10397,16 @@ ip-address@^10.0.1: resolved "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz" integrity sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q== -ipaddr.js@1.9.1: - version "1.9.1" - resolved "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz" - integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g== - ipaddr.js@^2.0.1, ipaddr.js@^2.1.0: version "2.3.0" resolved "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz" integrity sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg== +ipaddr.js@1.9.1: + version "1.9.1" + resolved "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz" + integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g== + is-alphabetical@^2.0.0: version "2.0.1" resolved "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz" @@ -11299,7 +10420,15 @@ is-alphanumerical@^2.0.0: is-alphabetical "^2.0.0" is-decimal "^2.0.0" -is-arguments@^1.0.4, is-arguments@^1.1.1: +is-arguments@^1.0.4: + version "1.2.0" + resolved "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz" + integrity sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA== + dependencies: + call-bound "^1.0.2" + has-tostringtag "^1.0.2" + +is-arguments@^1.1.1: version "1.2.0" resolved "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz" integrity sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA== @@ -11422,7 +10551,18 @@ is-fullwidth-code-point@^5.0.0: dependencies: get-east-asian-width "^1.3.1" -is-generator-function@^1.0.10, is-generator-function@^1.0.7: +is-generator-function@^1.0.10: + version "1.1.2" + resolved "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz" + integrity sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA== + dependencies: + call-bound "^1.0.4" + generator-function "^2.0.0" + get-proto "^1.0.1" + has-tostringtag "^1.0.2" + safe-regex-test "^1.1.0" + +is-generator-function@^1.0.7: version "1.1.2" resolved "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz" integrity sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA== @@ -11559,7 +10699,7 @@ is-ssh@^1.4.0: dependencies: protocols "^2.0.1" -is-stream@^2.0.0, is-stream@^2.0.1: +is-stream@^2.0.0: version "2.0.1" resolved "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz" integrity sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg== @@ -11586,7 +10726,14 @@ is-symbol@^1.0.4, is-symbol@^1.1.1: has-symbols "^1.1.0" safe-regex-test "^1.1.0" -is-typed-array@^1.1.13, is-typed-array@^1.1.14, is-typed-array@^1.1.15, is-typed-array@^1.1.3: +is-typed-array@^1.1.13, is-typed-array@^1.1.14, is-typed-array@^1.1.15: + version "1.1.15" + resolved "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz" + integrity sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ== + dependencies: + which-typed-array "^1.1.16" + +is-typed-array@^1.1.3: version "1.1.15" resolved "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz" integrity sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ== @@ -11637,11 +10784,6 @@ isarray@^2.0.5: resolved "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz" integrity sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw== -isarray@~1.0.0: - version "1.0.0" - resolved "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz" - integrity sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ== - isexe@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz" @@ -11800,16 +10942,16 @@ jimp-compact@0.16.1: resolved "https://registry.npmjs.org/jimp-compact/-/jimp-compact-0.16.1.tgz" integrity sha512-dZ6Ra7u1G8c4Letq/B5EzAxj4tLFHL+cGtdpR+PVm4yzPDj+lCk+AbivWt1eOM+ikzkowtyV7qSqX6qr3t71Ww== +jiti@*, jiti@^2.4.2, jiti@^2.6.1, jiti@>=1.21.0: + version "2.6.1" + resolved "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz" + integrity sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ== + jiti@^1.21.7: version "1.21.7" resolved "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz" integrity sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A== -jiti@^2.4.2, jiti@^2.6.1: - version "2.6.1" - resolved "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz" - integrity sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ== - jose@^6.0.11, jose@^6.0.12, jose@^6.1.0, jose@^6.1.1: version "6.1.3" resolved "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz" @@ -11850,7 +10992,7 @@ jsc-safe-url@^0.2.2, jsc-safe-url@^0.2.4: resolved "https://registry.npmjs.org/jsc-safe-url/-/jsc-safe-url-0.2.4.tgz" integrity sha512-0wM3YBWtYePOjfyXQH5MWQ8H7sdk5EXSwZvmSLKk2RboVQ2Bu239jycHDz5J/8Blf3K0Qnoy2b6xD+z10MFB+Q== -jsdom@^24.1.0: +jsdom@*, jsdom@^24.1.0: version "24.1.3" resolved "https://registry.npmjs.org/jsdom/-/jsdom-24.1.3.tgz" integrity sha512-MyL55p3Ut3cXbeBEG7Hcv0mVM8pp8PBNWxRqchZnSfAiES1v1mRnMeFfaHWIPULpwsYfvO+ZmMZz5tGCnjzDUQ== @@ -12061,13 +11203,6 @@ layout-base@^2.0.0: resolved "https://registry.npmjs.org/layout-base/-/layout-base-2.0.1.tgz" integrity sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg== -lazystream@^1.0.0: - version "1.0.1" - resolved "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz" - integrity sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw== - dependencies: - readable-stream "^2.0.5" - leven@^3.1.0: version "3.1.0" resolved "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz" @@ -12134,16 +11269,6 @@ lighthouse-logger@^1.0.0: debug "^2.6.9" marky "^1.2.2" -lightningcss-android-arm64@1.30.2: - version "1.30.2" - resolved "https://registry.yarnpkg.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz#6966b7024d39c94994008b548b71ab360eb3a307" - integrity sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A== - -lightningcss-android-arm64@1.31.1: - version "1.31.1" - resolved "https://registry.yarnpkg.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz#609ff48332adff452a8157a7c2842fd692a8eac4" - integrity sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg== - lightningcss-darwin-arm64@1.30.2: version "1.30.2" resolved "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz" @@ -12154,97 +11279,7 @@ lightningcss-darwin-arm64@1.31.1: resolved "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz" integrity sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg== -lightningcss-darwin-x64@1.30.2: - version "1.30.2" - resolved "https://registry.yarnpkg.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz#5ce87e9cd7c4f2dcc1b713f5e8ee185c88d9b7cd" - integrity sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ== - -lightningcss-darwin-x64@1.31.1: - version "1.31.1" - resolved "https://registry.yarnpkg.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz#f7482c311273571ec0c2bd8277c1f5f6e90e03a4" - integrity sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA== - -lightningcss-freebsd-x64@1.30.2: - version "1.30.2" - resolved "https://registry.yarnpkg.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz#6ae1d5e773c97961df5cff57b851807ef33692a5" - integrity sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA== - -lightningcss-freebsd-x64@1.31.1: - version "1.31.1" - resolved "https://registry.yarnpkg.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz#91df1bb290f1cb7bb2af832d7d0d8809225e0124" - integrity sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A== - -lightningcss-linux-arm-gnueabihf@1.30.2: - version "1.30.2" - resolved "https://registry.yarnpkg.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz#62c489610c0424151a6121fa99d77731536cdaeb" - integrity sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA== - -lightningcss-linux-arm-gnueabihf@1.31.1: - version "1.31.1" - resolved "https://registry.yarnpkg.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz#c3cad5ae8b70045f21600dc95295ab6166acf57e" - integrity sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g== - -lightningcss-linux-arm64-gnu@1.30.2: - version "1.30.2" - resolved "https://registry.yarnpkg.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz#2a3661b56fe95a0cafae90be026fe0590d089298" - integrity sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A== - -lightningcss-linux-arm64-gnu@1.31.1: - version "1.31.1" - resolved "https://registry.yarnpkg.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz#a5c4f6a5ac77447093f61b209c0bd7fef1f0a3e3" - integrity sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg== - -lightningcss-linux-arm64-musl@1.30.2: - version "1.30.2" - resolved "https://registry.yarnpkg.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz#d7ddd6b26959245e026bc1ad9eb6aa983aa90e6b" - integrity sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA== - -lightningcss-linux-arm64-musl@1.31.1: - version "1.31.1" - resolved "https://registry.yarnpkg.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz#af26ab8f829b727ada0a200938a6c8796ff36900" - integrity sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg== - -lightningcss-linux-x64-gnu@1.30.2: - version "1.30.2" - resolved "https://registry.yarnpkg.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz#5a89814c8e63213a5965c3d166dff83c36152b1a" - integrity sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w== - -lightningcss-linux-x64-gnu@1.31.1: - version "1.31.1" - resolved "https://registry.yarnpkg.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz#a891d44e84b71c0d88959feb9a7522bbf61450ee" - integrity sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA== - -lightningcss-linux-x64-musl@1.30.2: - version "1.30.2" - resolved "https://registry.yarnpkg.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz#808c2e91ce0bf5d0af0e867c6152e5378c049728" - integrity sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA== - -lightningcss-linux-x64-musl@1.31.1: - version "1.31.1" - resolved "https://registry.yarnpkg.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz#8c8b21def851f4d477fa897b80cb3db2b650bc6e" - integrity sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA== - -lightningcss-win32-arm64-msvc@1.30.2: - version "1.30.2" - resolved "https://registry.yarnpkg.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz#ab4a8a8a2e6a82a4531e8bbb6bf0ff161ee6625a" - integrity sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ== - -lightningcss-win32-arm64-msvc@1.31.1: - version "1.31.1" - resolved "https://registry.yarnpkg.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz#79000fb8c57e94a91b8fc643e74d5a54407d7080" - integrity sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w== - -lightningcss-win32-x64-msvc@1.30.2: - version "1.30.2" - resolved "https://registry.yarnpkg.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz#f01f382c8e0a27e1c018b0bee316d210eac43b6e" - integrity sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw== - -lightningcss-win32-x64-msvc@1.31.1: - version "1.31.1" - resolved "https://registry.yarnpkg.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz#7f025274c81c7d659829731e09c8b6f442209837" - integrity sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw== - -lightningcss@1.30.2: +lightningcss@^1.21.0, lightningcss@1.30.2: version "1.30.2" resolved "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz" integrity sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ== @@ -12292,7 +11327,23 @@ lines-and-columns@^1.1.6: resolved "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz" integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg== -livekit-client@^2.11.4, livekit-client@^2.15.4: +livekit-client@^2.11.4: + version "2.17.0" + resolved "https://registry.npmjs.org/livekit-client/-/livekit-client-2.17.0.tgz" + integrity sha512-BD1QUS44ancVTBdnAher0aO7DV5holFYH2lYradYT/HgXtn6R8xPyvtDAH3UH40jGcesDo9fEopCFwEdOgrIhg== + dependencies: + "@livekit/mutex" "1.1.1" + "@livekit/protocol" "1.42.2" + events "^3.3.0" + jose "^6.1.0" + loglevel "^1.9.2" + sdp-transform "^2.15.0" + ts-debounce "^4.0.0" + tslib "2.8.1" + typed-emitter "^2.1.0" + webrtc-adapter "^9.0.1" + +livekit-client@^2.15.14, livekit-client@^2.15.4, livekit-client@^2.15.8, livekit-client@^2.16.0: version "2.17.0" resolved "https://registry.npmjs.org/livekit-client/-/livekit-client-2.17.0.tgz" integrity sha512-BD1QUS44ancVTBdnAher0aO7DV5holFYH2lYradYT/HgXtn6R8xPyvtDAH3UH40jGcesDo9fEopCFwEdOgrIhg== @@ -12337,16 +11388,16 @@ lockfile@^1.0.4: dependencies: signal-exit "^3.0.2" -lodash-es@4.17.21: - version "4.17.21" - resolved "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz" - integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw== - lodash-es@^4.17.21: version "4.17.23" resolved "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz" integrity sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg== +lodash-es@4.17.21: + version "4.17.21" + resolved "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz" + integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw== + lodash.capitalize@^4.2.1: version "4.2.1" resolved "https://registry.npmjs.org/lodash.capitalize/-/lodash.capitalize-4.2.1.tgz" @@ -12402,7 +11453,7 @@ lodash.isstring@^4.0.1: resolved "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz" integrity sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw== -lodash.merge@4.6.2, lodash.merge@^4.6.2: +lodash.merge@^4.6.2, lodash.merge@4.6.2: version "4.6.2" resolved "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz" integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== @@ -12422,7 +11473,7 @@ lodash.uniqby@^4.7.0: resolved "https://registry.npmjs.org/lodash.uniqby/-/lodash.uniqby-4.7.0.tgz" integrity sha512-e/zcLx6CSbmaEgFHCA7BnoQKyCtKMxnuWrJygbwPs/AIn+IMKl66L8/s+wBUn5LRw2pZx3bUHibiV1b6aTWIww== -lodash@^4.15.0, lodash@^4.17.15, lodash@^4.17.21: +lodash@^4.15.0, lodash@^4.17.21: version "4.17.23" resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz" integrity sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w== @@ -12442,16 +11493,16 @@ log-symbols@^7.0.1: is-unicode-supported "^2.0.0" yoctocolors "^2.1.1" -loglevel@1.9.1: - version "1.9.1" - resolved "https://registry.npmjs.org/loglevel/-/loglevel-1.9.1.tgz" - integrity sha512-hP3I3kCrDIMuRwAwHltphhDM1r8i55H33GgqjXbrisuJhF4kRhW1dNuxsRklp4bXl8DSdLaNLuiL4A/LWRfxvg== - loglevel@^1.8.0, loglevel@^1.9.2: version "1.9.2" resolved "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz" integrity sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg== +loglevel@1.9.1: + version "1.9.1" + resolved "https://registry.npmjs.org/loglevel/-/loglevel-1.9.1.tgz" + integrity sha512-hP3I3kCrDIMuRwAwHltphhDM1r8i55H33GgqjXbrisuJhF4kRhW1dNuxsRklp4bXl8DSdLaNLuiL4A/LWRfxvg== + long@^5.0.0, long@^5.2.3: version "5.3.2" resolved "https://registry.npmjs.org/long/-/long-5.3.2.tgz" @@ -12479,6 +11530,11 @@ lowercase-keys@^2.0.0: resolved "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz" integrity sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA== +lru_map@0.4.1: + version "0.4.1" + resolved "https://registry.npmjs.org/lru_map/-/lru_map-0.4.1.tgz" + integrity sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg== + lru-cache@^10.0.1, lru-cache@^10.2.0, lru-cache@^10.4.3: version "10.4.3" resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz" @@ -12501,12 +11557,7 @@ lru-cache@^7.14.1: resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz" integrity sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA== -lru_map@0.4.1: - version "0.4.1" - resolved "https://registry.npmjs.org/lru_map/-/lru_map-0.4.1.tgz" - integrity sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg== - -lucide-react@^0.563.0: +lucide-react@*, lucide-react@^0.563.0: version "0.563.0" resolved "https://registry.npmjs.org/lucide-react/-/lucide-react-0.563.0.tgz" integrity sha512-8dXPB2GI4dI8jV4MgUDGBeLdGk8ekfqVZ0BdLcrRzocGgG75ltNEmWS+gE7uokKF/0oSUuczNDT+g9hFJ23FkA== @@ -12557,16 +11608,16 @@ markdown-table@^3.0.0: resolved "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz" integrity sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw== -marked@14.0.0: - version "14.0.0" - resolved "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz" - integrity sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ== - marked@^16.2.1: version "16.4.2" resolved "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz" integrity sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA== +marked@14.0.0: + version "14.0.0" + resolved "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz" + integrity sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ== + marky@^1.2.2: version "1.3.0" resolved "https://registry.npmjs.org/marky/-/marky-1.3.0.tgz" @@ -12870,7 +11921,7 @@ metro-cache@0.83.3: https-proxy-agent "^7.0.5" metro-core "0.83.3" -metro-config@0.83.3, metro-config@^0.83.1: +metro-config@^0.83.1, metro-config@0.83.3: version "0.83.3" resolved "https://registry.npmjs.org/metro-config/-/metro-config-0.83.3.tgz" integrity sha512-mTel7ipT0yNjKILIan04bkJkuCzUUkm2SeEaTads8VfEecCh+ltXchdq6DovXJqzQAXuR2P9cxZB47Lg4klriA== @@ -12884,7 +11935,7 @@ metro-config@0.83.3, metro-config@^0.83.1: metro-runtime "0.83.3" yaml "^2.6.1" -metro-core@0.83.3, metro-core@^0.83.1: +metro-core@^0.83.1, metro-core@0.83.3: version "0.83.3" resolved "https://registry.npmjs.org/metro-core/-/metro-core-0.83.3.tgz" integrity sha512-M+X59lm7oBmJZamc96usuF1kusd5YimqG/q97g4Ac7slnJ3YiGglW5CsOlicTR5EWf8MQFxxjDoB6ytTqRe8Hw== @@ -12923,7 +11974,7 @@ metro-resolver@0.83.3: dependencies: flow-enums-runtime "^0.0.6" -metro-runtime@0.83.3, metro-runtime@^0.83.1: +metro-runtime@^0.83.1, metro-runtime@0.83.3: version "0.83.3" resolved "https://registry.npmjs.org/metro-runtime/-/metro-runtime-0.83.3.tgz" integrity sha512-JHCJb9ebr9rfJ+LcssFYA2x1qPYuSD/bbePupIGhpMrsla7RCwC/VL3yJ9cSU+nUhU4c9Ixxy8tBta+JbDeZWw== @@ -12931,7 +11982,7 @@ metro-runtime@0.83.3, metro-runtime@^0.83.1: "@babel/runtime" "^7.25.0" flow-enums-runtime "^0.0.6" -metro-source-map@0.83.3, metro-source-map@^0.83.1: +metro-source-map@^0.83.1, metro-source-map@0.83.3: version "0.83.3" resolved "https://registry.npmjs.org/metro-source-map/-/metro-source-map-0.83.3.tgz" integrity sha512-xkC3qwUBh2psVZgVavo8+r2C9Igkk3DibiOXSAht1aYRRcztEZNFtAMtfSB7sdO2iFMx2Mlyu++cBxz/fhdzQg== @@ -12990,7 +12041,7 @@ metro-transform-worker@0.83.3: metro-transform-plugins "0.83.3" nullthrows "^1.1.1" -metro@0.83.3, metro@^0.83.1: +metro@^0.83.1, metro@0.83.3: version "0.83.3" resolved "https://registry.npmjs.org/metro/-/metro-0.83.3.tgz" integrity sha512-+rP+/GieOzkt97hSJ0MrPOuAH/jpaS21ZDvL9DJ35QYRDlQcwzcvUlGUf79AnQxq/2NPiS/AULhhM4TKutIt8Q== @@ -13411,29 +12462,43 @@ micromatch@^4.0.2, micromatch@^4.0.4, micromatch@^4.0.8: braces "^3.0.3" picomatch "^2.3.1" +mime-db@^1.54.0, "mime-db@>= 1.43.0 < 2": + version "1.54.0" + resolved "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz" + integrity sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ== + mime-db@1.52.0: version "1.52.0" resolved "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz" integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== -"mime-db@>= 1.43.0 < 2", mime-db@^1.54.0: - version "1.54.0" - resolved "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz" - integrity sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ== +mime-types@^2.1.12: + version "2.1.35" + resolved "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz" + integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== + dependencies: + mime-db "1.52.0" -mime-types@3.0.2, mime-types@^3.0.0, mime-types@^3.0.2: - version "3.0.2" - resolved "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz" - integrity sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A== +mime-types@^2.1.27, mime-types@~2.1.34: + version "2.1.35" + resolved "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz" + integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== dependencies: - mime-db "^1.54.0" + mime-db "1.52.0" -mime-types@^2.1.12, mime-types@^2.1.27, mime-types@^2.1.35, mime-types@~2.1.34: +mime-types@^2.1.35: version "2.1.35" resolved "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz" integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== dependencies: - mime-db "1.52.0" + mime-db "1.52.0" + +mime-types@^3.0.0, mime-types@^3.0.2, mime-types@3.0.2: + version "3.0.2" + resolved "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz" + integrity sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A== + dependencies: + mime-db "^1.54.0" mime@1.6.0: version "1.6.0" @@ -13491,13 +12556,6 @@ minimatch@^3.0.4, minimatch@^3.1.1, minimatch@^3.1.2: dependencies: brace-expansion "^1.1.7" -minimatch@^5.1.0: - version "5.1.9" - resolved "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz" - integrity sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw== - dependencies: - brace-expansion "^2.0.1" - minimatch@^8.0.2: version "8.0.4" resolved "https://registry.npmjs.org/minimatch/-/minimatch-8.0.4.tgz" @@ -13505,7 +12563,14 @@ minimatch@^8.0.2: dependencies: brace-expansion "^2.0.1" -minimatch@^9.0.0, minimatch@^9.0.4: +minimatch@^9.0.0: + version "9.0.5" + resolved "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz" + integrity sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow== + dependencies: + brace-expansion "^2.0.1" + +minimatch@^9.0.4: version "9.0.5" resolved "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz" integrity sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow== @@ -13554,11 +12619,6 @@ minizlib@^3.1.0: dependencies: minipass "^7.1.2" -mkdirp-classic@^0.5.2, mkdirp-classic@^0.5.3: - version "0.5.3" - resolved "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz" - integrity sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A== - mkdirp@^1.0.4: version "1.0.4" resolved "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz" @@ -13594,6 +12654,11 @@ monaco-editor@^0.55.1: dompurify "3.2.7" marked "14.0.0" +ms@^2.1.1, ms@^2.1.3, ms@2.1.3: + version "2.1.3" + resolved "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + ms@2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz" @@ -13604,11 +12669,6 @@ ms@2.1.2: resolved "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== -ms@2.1.3, ms@^2.1.1, ms@^2.1.3: - version "2.1.3" - resolved "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz" - integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== - mute-stream@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz" @@ -13628,21 +12688,11 @@ nanoid@^3.3.11, nanoid@^3.3.6, nanoid@^3.3.7, nanoid@^3.3.8: resolved "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz" integrity sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w== -napi-build-utils@^2.0.0: - version "2.0.0" - resolved "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz" - integrity sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA== - natural-compare@^1.4.0: version "1.4.0" resolved "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz" integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== -negotiator@0.6.3: - version "0.6.3" - resolved "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz" - integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg== - negotiator@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz" @@ -13653,6 +12703,11 @@ negotiator@~0.6.4: resolved "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz" integrity sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w== +negotiator@0.6.3: + version "0.6.3" + resolved "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz" + integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg== + nested-error-stacks@~2.0.1: version "2.0.1" resolved "https://registry.npmjs.org/nested-error-stacks/-/nested-error-stacks-2.0.1.tgz" @@ -13675,7 +12730,7 @@ next-themes@^0.4.6: resolved "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz" integrity sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA== -next@16.1.4: +"next@^15.3.0 || ^16.0.0", next@16.1.4, next@16.x.x: version "16.1.4" resolved "https://registry.npmjs.org/next/-/next-16.1.4.tgz" integrity sha512-gKSecROqisnV7Buen5BfjmXAm7Xlpx9o2ueVQRo5DxQcjC8d330dOM1xiGWc2k3Dcnz0In3VybyRPOsudwgiqQ== @@ -13697,18 +12752,13 @@ next@16.1.4: "@next/swc-win32-x64-msvc" "16.1.4" sharp "^0.34.4" -node-abi@^3.3.0, node-abi@^3.73.0: +node-abi@^3.73.0: version "3.87.0" resolved "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz" integrity sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ== dependencies: semver "^7.3.5" -node-addon-api@^7.1.0: - version "7.1.1" - resolved "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz" - integrity sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ== - node-fetch-native@^1.6.6: version "1.6.7" resolved "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz" @@ -13731,13 +12781,6 @@ node-int64@^0.4.0: resolved "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz" integrity sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw== -node-pty@^1.1.0: - version "1.1.0" - resolved "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz" - integrity sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg== - dependencies: - node-addon-api "^7.1.0" - node-releases@^2.0.27: version "2.0.27" resolved "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz" @@ -13848,7 +12891,19 @@ object-keys@^1.1.1: resolved "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz" integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA== -object.assign@^4.1.4, object.assign@^4.1.7: +object.assign@^4.1.4: + version "4.1.7" + resolved "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz" + integrity sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw== + dependencies: + call-bind "^1.0.8" + call-bound "^1.0.3" + define-properties "^1.2.1" + es-object-atoms "^1.0.0" + has-symbols "^1.1.0" + object-keys "^1.1.1" + +object.assign@^4.1.7: version "4.1.7" resolved "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz" integrity sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw== @@ -13991,7 +13046,7 @@ onnxruntime-web@1.22.0-dev.20250409-89f8206ba4: platform "^1.3.6" protobufjs "^7.2.4" -open@10.2.0, open@^10.2.0: +open@^10.2.0: version "10.2.0" resolved "https://registry.npmjs.org/open/-/open-10.2.0.tgz" integrity sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA== @@ -14018,7 +13073,17 @@ open@^8.0.4: is-docker "^2.1.1" is-wsl "^2.2.0" -openapi-types@12.1.3, openapi-types@^12.1.3: +open@10.2.0: + version "10.2.0" + resolved "https://registry.npmjs.org/open/-/open-10.2.0.tgz" + integrity sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA== + dependencies: + default-browser "^5.2.1" + define-lazy-prop "^3.0.0" + is-inside-container "^1.0.0" + wsl-utils "^0.1.0" + +openapi-types@^12.1.3, openapi-types@12.1.3: version "12.1.3" resolved "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz" integrity sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw== @@ -14043,6 +13108,18 @@ optionator@^0.9.3: type-check "^0.4.0" word-wrap "^1.2.5" +ora@^3.4.0: + version "3.4.0" + resolved "https://registry.npmjs.org/ora/-/ora-3.4.0.tgz" + integrity sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg== + dependencies: + chalk "^2.4.2" + cli-cursor "^2.1.0" + cli-spinners "^2.0.0" + log-symbols "^2.2.0" + strip-ansi "^5.2.0" + wcwidth "^1.0.1" + ora@9.0.0: version "9.0.0" resolved "https://registry.npmjs.org/ora/-/ora-9.0.0.tgz" @@ -14058,18 +13135,6 @@ ora@9.0.0: string-width "^8.1.0" strip-ansi "^7.1.2" -ora@^3.4.0: - version "3.4.0" - resolved "https://registry.npmjs.org/ora/-/ora-3.4.0.tgz" - integrity sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg== - dependencies: - chalk "^2.4.2" - cli-cursor "^2.1.0" - cli-spinners "^2.0.0" - log-symbols "^2.2.0" - strip-ansi "^5.2.0" - wcwidth "^1.0.1" - os-name@6.1.0: version "6.1.0" resolved "https://registry.npmjs.org/os-name/-/os-name-6.1.0.tgz" @@ -14099,7 +13164,14 @@ p-limit@^2.0.0, p-limit@^2.2.0: dependencies: p-try "^2.0.0" -p-limit@^3.0.2, p-limit@^3.1.0: +p-limit@^3.0.2: + version "3.1.0" + resolved "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz" + integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== + dependencies: + yocto-queue "^0.1.0" + +p-limit@^3.1.0: version "3.1.0" resolved "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz" integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== @@ -14243,7 +13315,7 @@ patch-package@^8.0.0: tmp "^0.2.4" yaml "^2.2.2" -path-data-parser@0.1.0, path-data-parser@^0.1.0: +path-data-parser@^0.1.0, path-data-parser@0.1.0: version "0.1.0" resolved "https://registry.npmjs.org/path-data-parser/-/path-data-parser-0.1.0.tgz" integrity sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w== @@ -14355,33 +13427,31 @@ picocolors@^1.0.0, picocolors@^1.1.1: resolved "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz" integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== -picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.3, picomatch@^2.3.1: +picomatch@^2.0.4, picomatch@^2.2.3, picomatch@^2.3.1: version "2.3.1" resolved "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz" integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== -picomatch@^3.0.1: - version "3.0.1" - resolved "https://registry.npmjs.org/picomatch/-/picomatch-3.0.1.tgz" - integrity sha512-I3EurrIQMlRc9IaAZnqRR044Phh2DXY+55o7uJ0V+hYZAcQYSuFWsc9q5PvyDHUSCe1Qxn/iBz+78s86zWnGag== +picomatch@^2.2.1: + version "2.3.1" + resolved "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz" + integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== -picomatch@^4.0.2, picomatch@^4.0.3: +"picomatch@^3 || ^4", picomatch@^4.0.2, picomatch@^4.0.3: version "4.0.3" resolved "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz" integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q== +picomatch@^3.0.1: + version "3.0.1" + resolved "https://registry.npmjs.org/picomatch/-/picomatch-3.0.1.tgz" + integrity sha512-I3EurrIQMlRc9IaAZnqRR044Phh2DXY+55o7uJ0V+hYZAcQYSuFWsc9q5PvyDHUSCe1Qxn/iBz+78s86zWnGag== + pify@^2.3.0: version "2.3.0" resolved "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz" integrity sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog== -pino-abstract-transport@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz#de241578406ac7b8a33ce0d77ae6e8a0b3b68a60" - integrity sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw== - dependencies: - split2 "^4.0.0" - pino-abstract-transport@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz" @@ -14430,24 +13500,12 @@ pino@^10.1.0: sonic-boom "^4.0.1" thread-stream "^4.0.0" -pino@^9.9.0: - version "9.14.0" - resolved "https://registry.yarnpkg.com/pino/-/pino-9.14.0.tgz#673d9711c2d1e64d18670c1ec05ef7ba14562556" - integrity sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w== - dependencies: - "@pinojs/redact" "^0.4.0" - atomic-sleep "^1.0.0" - on-exit-leak-free "^2.1.0" - pino-abstract-transport "^2.0.0" - pino-std-serializers "^7.0.0" - process-warning "^5.0.0" - quick-format-unescaped "^4.0.3" - real-require "^0.2.0" - safe-stable-stringify "^2.3.1" - sonic-boom "^4.0.1" - thread-stream "^3.0.0" +pirates@^4.0.1: + version "4.0.7" + resolved "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz" + integrity sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA== -pirates@^4.0.1, pirates@^4.0.4: +pirates@^4.0.4: version "4.0.7" resolved "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz" integrity sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA== @@ -14544,7 +13602,7 @@ pngjs@^5.0.0: resolved "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz" integrity sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw== -points-on-curve@0.2.0, points-on-curve@^0.2.0: +points-on-curve@^0.2.0, points-on-curve@0.2.0: version "0.2.0" resolved "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz" integrity sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A== @@ -14613,16 +13671,7 @@ postcss-value-parser@^4.0.0, postcss-value-parser@^4.2.0: resolved "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz" integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ== -postcss@8.4.31: - version "8.4.31" - resolved "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz" - integrity sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ== - dependencies: - nanoid "^3.3.6" - picocolors "^1.0.0" - source-map-js "^1.0.2" - -postcss@^8.4.41, postcss@^8.4.47, postcss@^8.5.3, postcss@^8.5.6: +postcss@^8.0.0, postcss@^8.1.0, postcss@^8.2.14, postcss@^8.4.21, postcss@^8.4.41, postcss@^8.4.47, postcss@^8.5.3, postcss@^8.5.6, postcss@>=8.0.9: version "8.5.6" resolved "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz" integrity sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg== @@ -14640,6 +13689,15 @@ postcss@~8.4.32: picocolors "^1.1.1" source-map-js "^1.2.1" +postcss@8.4.31: + version "8.4.31" + resolved "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz" + integrity sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ== + dependencies: + nanoid "^3.3.6" + picocolors "^1.0.0" + source-map-js "^1.0.2" + postgres-array@~2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz" @@ -14669,24 +13727,6 @@ posthog-react-native@^4.16.2: dependencies: "@posthog/core" "1.14.1" -prebuild-install@^7.1.2: - version "7.1.3" - resolved "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz" - integrity sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug== - dependencies: - detect-libc "^2.0.0" - expand-template "^2.0.3" - github-from-package "0.0.0" - minimist "^1.2.3" - mkdirp-classic "^0.5.3" - napi-build-utils "^2.0.0" - node-abi "^3.3.0" - pump "^3.0.0" - rc "^1.2.7" - simple-get "^4.0.0" - tar-fs "^2.0.0" - tunnel-agent "^0.6.0" - prelude-ls@^1.2.1: version "1.2.1" resolved "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz" @@ -14715,7 +13755,7 @@ prisma-json-types-generator@^3.5.1: semver "^7.7.2" tslib "^2.8.1" -prisma@^6.11.1: +prisma@*, prisma@^6.11.1, prisma@^6.14: version "6.19.2" resolved "https://registry.npmjs.org/prisma/-/prisma-6.19.2.tgz" integrity sha512-XTKeKxtQElcq3U9/jHyxSPgiRgeYDKxWTPOf6NkXA0dNj5j40MfEsZkMbyNpwDWCUv7YBFUl7I2VK/6ALbmhEg== @@ -14744,11 +13784,6 @@ proc-log@^4.0.0: resolved "https://registry.npmjs.org/proc-log/-/proc-log-4.2.0.tgz" integrity sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA== -process-nextick-args@~2.0.0: - version "2.0.1" - resolved "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz" - integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== - process-warning@^4.0.0: version "4.0.1" resolved "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz" @@ -14926,16 +13961,16 @@ pvutils@^1.1.3: resolved "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz" integrity sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA== -qrcode-terminal@0.11.0: - version "0.11.0" - resolved "https://registry.npmjs.org/qrcode-terminal/-/qrcode-terminal-0.11.0.tgz" - integrity sha512-Uu7ii+FQy4Qf82G4xu7ShHhjhGahEpCWc3x8UavY3CTcWV+ufmmCtwkr7ZKsX42jdL0kr1B5FKUeqJvAn51jzQ== - qrcode-terminal@^0.12.0: version "0.12.0" resolved "https://registry.npmjs.org/qrcode-terminal/-/qrcode-terminal-0.12.0.tgz" integrity sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ== +qrcode-terminal@0.11.0: + version "0.11.0" + resolved "https://registry.npmjs.org/qrcode-terminal/-/qrcode-terminal-0.11.0.tgz" + integrity sha512-Uu7ii+FQy4Qf82G4xu7ShHhjhGahEpCWc3x8UavY3CTcWV+ufmmCtwkr7ZKsX42jdL0kr1B5FKUeqJvAn51jzQ== + qrcode@^1.5.4: version "1.5.4" resolved "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz" @@ -15004,15 +14039,7 @@ raw-body@^3.0.0, raw-body@^3.0.1: iconv-lite "~0.7.0" unpipe "~1.0.0" -rc9@^2.1.2: - version "2.1.2" - resolved "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz" - integrity sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg== - dependencies: - defu "^6.1.4" - destr "^2.0.3" - -rc@^1.2.7, rc@~1.2.7: +rc@~1.2.7: version "1.2.8" resolved "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz" integrity sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw== @@ -15022,7 +14049,15 @@ rc@^1.2.7, rc@~1.2.7: minimist "^1.2.0" strip-json-comments "~2.0.1" -react-devtools-core@^6.1.5: +rc9@^2.1.2: + version "2.1.2" + resolved "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz" + integrity sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg== + dependencies: + defu "^6.1.4" + destr "^2.0.3" + +react-devtools-core@^6.1.2, react-devtools-core@^6.1.5: version "6.1.5" resolved "https://registry.npmjs.org/react-devtools-core/-/react-devtools-core-6.1.5.tgz" integrity sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA== @@ -15030,14 +14065,14 @@ react-devtools-core@^6.1.5: shell-quote "^1.6.1" ws "^7" -react-dom@19.1.0: +react-dom@*, "react-dom@^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom@^18.0.0 || ^19.0.0", "react-dom@^18.3.1 || ^19.0.0", react-dom@>=18, react-dom@19.1.0: version "19.1.0" resolved "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz" integrity sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g== dependencies: scheduler "^0.26.0" -react-dom@^19.2.3: +"react-dom@^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom@^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom@^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom@^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", react-dom@^19.2.0, react-dom@^19.2.3, react-dom@>=16.8.0: version "19.2.4" resolved "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz" integrity sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ== @@ -15079,7 +14114,7 @@ react-native-audio-api@^0.8.2: resolved "https://registry.npmjs.org/react-native-audio-api/-/react-native-audio-api-0.8.4.tgz" integrity sha512-OvS5KFK1lAcile4z4easHUxS5i/D2UKF2kTRjEv6HVARs/zqUXahFj6NxhS/PzZ0VEmRDZD7d3V6rfCcO7xgEQ== -react-native-device-info@^14.1.1: +react-native-device-info@^14.1.1, "react-native-device-info@>= 10.0.0": version "14.1.1" resolved "https://registry.npmjs.org/react-native-device-info/-/react-native-device-info-14.1.1.tgz" integrity sha512-lXFpe6DJmzbQXNLWxlMHP2xuTU5gwrKAvI8dCAZuERhW9eOXSubOQIesk9lIBnsi9pI19GMrcpJEvs4ARPRYmw== @@ -15092,7 +14127,7 @@ react-native-drawer-layout@^4.2.1: color "^4.2.3" use-latest-callback "^0.2.4" -react-native-gesture-handler@~2.28.0: +react-native-gesture-handler@*, "react-native-gesture-handler@>= 2.0.0", react-native-gesture-handler@>=2.16.1, react-native-gesture-handler@~2.28.0: version "2.28.0" resolved "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.28.0.tgz" integrity sha512-0msfJ1vRxXKVgTgvL+1ZOoYw3/0z1R+Ked0+udoJhyplC2jbVKIJ8Z1bzWdpQRCV3QcQ87Op0zJVE5DhKK2A0A== @@ -15101,7 +14136,7 @@ react-native-gesture-handler@~2.28.0: hoist-non-react-statics "^3.3.0" invariant "^2.2.4" -react-native-is-edge-to-edge@1.2.1, react-native-is-edge-to-edge@^1.1.6, react-native-is-edge-to-edge@^1.2.1: +react-native-is-edge-to-edge@^1.1.6, react-native-is-edge-to-edge@^1.2.1, react-native-is-edge-to-edge@1.2.1: version "1.2.1" resolved "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.2.1.tgz" integrity sha512-FLbPWl/MyYQWz+KwqOZsSyj2JmLKglHatd3xLZWskXOpRaio4LfEDEz8E/A6uD8QoTHW6Aobw1jbEwK7KMgR7Q== @@ -15113,7 +14148,7 @@ react-native-keyboard-controller@1.18.5: dependencies: react-native-is-edge-to-edge "^1.2.1" -react-native-localize@^3.5.1: +react-native-localize@^3.5.1, "react-native-localize@>= 3.0.0": version "3.6.1" resolved "https://registry.npmjs.org/react-native-localize/-/react-native-localize-3.6.1.tgz" integrity sha512-0kVFUh8TWeh+8p13yVIDjbcdxR2ZOfPBFNcY/VBh/3/g8KfvXywY/XJkCuCVrX5dCLumMi3aXmy+MmJNy4IaYQ== @@ -15123,7 +14158,7 @@ react-native-mmkv@^3.3.3: resolved "https://registry.npmjs.org/react-native-mmkv/-/react-native-mmkv-3.3.3.tgz" integrity sha512-GMsfOmNzx0p5+CtrCFRVtpOOMYNJXuksBVARSQrCFaZwjUyHJdQzcN900GGaFFNTxw2fs8s5Xje//RDKj9+PZA== -react-native-nitro-modules@0.33.2: +react-native-nitro-modules@*, react-native-nitro-modules@0.33.2: version "0.33.2" resolved "https://registry.npmjs.org/react-native-nitro-modules/-/react-native-nitro-modules-0.33.2.tgz" integrity sha512-ZlfOe6abODeHv/eZf8PxeSkrxIUhEKha6jaAAA9oXy7I6VPr7Ff4dUsAq3cyF3kX0L6qt2Dh9nzD2NdSsDwGpA== @@ -15135,7 +14170,7 @@ react-native-purchases-ui@^9.4.2: dependencies: "@revenuecat/purchases-typescript-internal" "17.29.0" -react-native-purchases@^9.4.2: +react-native-purchases@^9.4.2, react-native-purchases@9.7.2: version "9.7.2" resolved "https://registry.npmjs.org/react-native-purchases/-/react-native-purchases-9.7.2.tgz" integrity sha512-vOHDhPlK/XDS+o0VZzKoXoRPdAN6/E/m2bnnp3PKWxxVX4aFcytlh3tY/KiWpHlQPCraWcZay+oF+E4zGgxkZw== @@ -15148,7 +14183,7 @@ react-native-quick-base64@^2.2.1: resolved "https://registry.npmjs.org/react-native-quick-base64/-/react-native-quick-base64-2.2.2.tgz" integrity sha512-WLHSifHLoamr2kF00Gov0W9ud6CfPshe1rmqWTquVIi9c62qxOaJCFVDrXFZhEBU8B8PvGLVuOlVKH78yhY0Fg== -react-native-reanimated@^4.2.1: +react-native-reanimated@*, react-native-reanimated@^4.2.1, "react-native-reanimated@>= 2.0.0", react-native-reanimated@>=3.0.0, react-native-reanimated@>=3.16.0, react-native-reanimated@>=3.19.1: version "4.2.1" resolved "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.2.1.tgz" integrity sha512-/NcHnZMyOvsD/wYXug/YqSKw90P9edN0kEPL5lP4PFf1aQ4F1V7MKe/E0tvfkXKIajy3Qocp5EiEnlcrK/+BZg== @@ -15156,7 +14191,7 @@ react-native-reanimated@^4.2.1: react-native-is-edge-to-edge "1.2.1" semver "7.7.3" -react-native-safe-area-context@~5.6.0: +react-native-safe-area-context@*, "react-native-safe-area-context@>= 4.0.0", "react-native-safe-area-context@>= 5.4.0", react-native-safe-area-context@~5.6.0: version "5.6.2" resolved "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.6.2.tgz" integrity sha512-4XGqMNj5qjUTYywJqpdWZ9IG8jgkS3h06sfVjfw5yZQZfWnRFXczi0GnYyFyCc2EBps/qFmoCH8fez//WumdVg== @@ -15168,7 +14203,7 @@ react-native-screen-transitions@^1.2.0: dependencies: immer ">=10.0.0" -react-native-screens@~4.16.0: +react-native-screens@*, "react-native-screens@>= 4.0.0", react-native-screens@~4.16.0: version "4.16.0" resolved "https://registry.npmjs.org/react-native-screens/-/react-native-screens-4.16.0.tgz" integrity sha512-yIAyh7F/9uWkOzCi1/2FqvNvK6Wb9Y1+Kzn16SuGfN9YFJDTbwlzGRvePCNTOX0recpLQF3kc2FmvMUhyTCH1Q== @@ -15177,7 +14212,7 @@ react-native-screens@~4.16.0: react-native-is-edge-to-edge "^1.2.1" warn-once "^0.1.0" -react-native-svg@15.12.1: +"react-native-svg@>= 15.0.0", react-native-svg@15.12.1: version "15.12.1" resolved "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.12.1.tgz" integrity sha512-vCuZJDf8a5aNC2dlMovEv4Z0jjEUET53lm/iILFnFewa15b4atjVxU6Wirm6O9y6dEsdjDZVD7Q3QM4T1wlI8g== @@ -15208,7 +14243,7 @@ react-native-vision-camera@^4.7.3: resolved "https://registry.npmjs.org/react-native-vision-camera/-/react-native-vision-camera-4.7.3.tgz" integrity sha512-g1/neOyjSqn1kaAa2FxI/qp5KzNvPcF0bnQw6NntfbxH6tm0+8WFZszlgb5OV+iYlB6lFUztCbDtyz5IpL47OA== -react-native-web@^0.21.0: +react-native-web@*, react-native-web@^0.21.0: version "0.21.2" resolved "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz" integrity sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg== @@ -15222,7 +14257,7 @@ react-native-web@^0.21.0: postcss-value-parser "^4.2.0" styleq "^0.1.3" -react-native-webview@13.15.0: +react-native-webview@*, react-native-webview@13.15.0: version "13.15.0" resolved "https://registry.npmjs.org/react-native-webview/-/react-native-webview-13.15.0.tgz" integrity sha512-Vzjgy8mmxa/JO6l5KZrsTC7YemSdq+qB01diA0FqjUTaWGAGwuykpJ73MDj3+mzBSlaDxAEugHzTtkUQkQEQeQ== @@ -15230,7 +14265,7 @@ react-native-webview@13.15.0: escape-string-regexp "^4.0.0" invariant "2.2.4" -react-native-worklets@^0.7.1: +react-native-worklets@^0.7.1, react-native-worklets@>=0.7.0: version "0.7.2" resolved "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.7.2.tgz" integrity sha512-DuLu1kMV/Uyl9pQHp3hehAlThoLw7Yk2FwRTpzASOmI+cd4845FWn3m2bk9MnjUw8FBRIyhwLqYm2AJaXDXsog== @@ -15247,10 +14282,9 @@ react-native-worklets@^0.7.1: convert-source-map "2.0.0" semver "7.7.3" -react-native@0.81.4: +react-native@*, "react-native@^0.0.0-0 || >=0.65 <1.0": version "0.81.4" - resolved "https://registry.npmjs.org/react-native/-/react-native-0.81.4.tgz" - integrity sha512-bt5bz3A/+Cv46KcjV0VQa+fo7MKxs17RCcpzjftINlen4ZDUl0I6Ut+brQ2FToa5oD0IB0xvQHfmsg2EDqsZdQ== + resolved "file:apps/ui/node_modules/react-native" dependencies: "@jest/create-cache-key-function" "^29.7.0" "@react-native/assets-registry" "0.81.4" @@ -15287,12 +14321,45 @@ react-native@0.81.4: ws "^6.2.3" yargs "^17.6.2" -react-reconciler@0.31.0: - version "0.31.0" - resolved "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.31.0.tgz" - integrity sha512-7Ob7Z+URmesIsIVRjnLoDGwBEG/tVitidU0nMsqX/eeJaLY89RISO/10ERe0MqmzuKUUB1rmY+h1itMbUHg9BQ== +"react-native@>= 0.73.0", react-native@>=0.57, react-native@>=0.60.0, react-native@>=0.65.0, react-native@>=0.70.0, react-native@>=0.76.0, react-native@>=0.78, react-native@0.81.4: + version "0.81.4" + resolved "https://registry.npmjs.org/react-native/-/react-native-0.81.4.tgz" + integrity sha512-bt5bz3A/+Cv46KcjV0VQa+fo7MKxs17RCcpzjftINlen4ZDUl0I6Ut+brQ2FToa5oD0IB0xvQHfmsg2EDqsZdQ== dependencies: - scheduler "^0.25.0" + "@jest/create-cache-key-function" "^29.7.0" + "@react-native/assets-registry" "0.81.4" + "@react-native/codegen" "0.81.4" + "@react-native/community-cli-plugin" "0.81.4" + "@react-native/gradle-plugin" "0.81.4" + "@react-native/js-polyfills" "0.81.4" + "@react-native/normalize-colors" "0.81.4" + "@react-native/virtualized-lists" "0.81.4" + abort-controller "^3.0.0" + anser "^1.4.9" + ansi-regex "^5.0.0" + babel-jest "^29.7.0" + babel-plugin-syntax-hermes-parser "0.29.1" + base64-js "^1.5.1" + commander "^12.0.0" + flow-enums-runtime "^0.0.6" + glob "^7.1.1" + invariant "^2.2.4" + jest-environment-node "^29.7.0" + memoize-one "^5.0.0" + metro-runtime "^0.83.1" + metro-source-map "^0.83.1" + nullthrows "^1.1.1" + pretty-format "^29.7.0" + promise "^8.3.0" + react-devtools-core "^6.1.5" + react-refresh "^0.14.0" + regenerator-runtime "^0.13.2" + scheduler "0.26.0" + semver "^7.1.3" + stacktrace-parser "^0.1.10" + whatwg-fetch "^3.0.0" + ws "^6.2.3" + yargs "^17.6.2" react-reconciler@^0.33.0: version "0.33.0" @@ -15301,7 +14368,14 @@ react-reconciler@^0.33.0: dependencies: scheduler "^0.27.0" -react-refresh@^0.14.0, react-refresh@^0.14.2: +react-reconciler@0.31.0: + version "0.31.0" + resolved "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.31.0.tgz" + integrity sha512-7Ob7Z+URmesIsIVRjnLoDGwBEG/tVitidU0nMsqX/eeJaLY89RISO/10ERe0MqmzuKUUB1rmY+h1itMbUHg9BQ== + dependencies: + scheduler "^0.25.0" + +react-refresh@^0.14.0, react-refresh@^0.14.2, "react-refresh@>=0.14.0 <1.0.0": version "0.14.2" resolved "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz" integrity sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA== @@ -15350,22 +14424,29 @@ react-textarea-autosize@^8.5.9: use-composed-ref "^1.3.0" use-latest "^1.2.1" -react@19.1.0: +react@*, "react@^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react@^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react@^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react@^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc", react@^19.1.0, "react@>= 18.2.0", react@>=16.8: + version "19.1.0" + resolved "file:apps/ui/node_modules/react" + +"react@^16.14.0 || 17.x || 18.x || 19.x", "react@^16.8.0 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react@^18.0.0 || ^19.0.0", "react@^18.3.1 || ^19.0.0", react@^19.0.0, react@^19.1.0, "react@>= 16.6.3", react@>=16, react@>=16.8.0, react@>=17.0.0, react@>=18, react@>=18.0.0, react@>=19.0, react@19.1.0: version "19.1.0" resolved "https://registry.npmjs.org/react/-/react-19.1.0.tgz" integrity sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg== -react@^18.3.1: +"react@^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react@^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react@^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", react@^19.2.0, react@^19.2.3, react@^19.2.4, "react@>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0", react@>=16.8.0, react@>=19.0.0: + version "19.2.4" + resolved "https://registry.npmjs.org/react/-/react-19.2.4.tgz" + integrity sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ== + +"react@^16.9.0 || ^17.0.0 || ^18 || ^19", react@^18.3.1: version "18.3.1" resolved "https://registry.npmjs.org/react/-/react-18.3.1.tgz" integrity sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ== dependencies: loose-envify "^1.1.0" -react@^19.2.0, react@^19.2.3: +react@^19.2.4: version "19.2.4" - resolved "https://registry.npmjs.org/react/-/react-19.2.4.tgz" - integrity sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ== read-cache@^1.0.0: version "1.0.0" @@ -15374,7 +14455,7 @@ read-cache@^1.0.0: dependencies: pify "^2.3.0" -readable-stream@3, readable-stream@^3.1.1, readable-stream@^3.4.0: +readable-stream@^3.4.0, readable-stream@3: version "3.6.2" resolved "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz" integrity sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA== @@ -15383,20 +14464,7 @@ readable-stream@3, readable-stream@^3.1.1, readable-stream@^3.4.0: string_decoder "^1.1.1" util-deprecate "^1.0.1" -readable-stream@^2.0.5: - version "2.3.8" - resolved "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz" - integrity sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA== - dependencies: - core-util-is "~1.0.0" - inherits "~2.0.3" - isarray "~1.0.0" - process-nextick-args "~2.0.0" - safe-buffer "~5.1.1" - string_decoder "~1.1.1" - util-deprecate "~1.0.1" - -readable-stream@^4.0.0, readable-stream@^4.5.2: +readable-stream@^4.5.2: version "4.7.0" resolved "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz" integrity sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg== @@ -15407,13 +14475,6 @@ readable-stream@^4.0.0, readable-stream@^4.5.2: process "^0.11.10" string_decoder "^1.3.0" -readdir-glob@^1.1.2: - version "1.1.3" - resolved "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz" - integrity sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA== - dependencies: - minimatch "^5.1.0" - readdirp@^4.0.1: version "4.1.2" resolved "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz" @@ -15520,7 +14581,7 @@ redux-thunk@^3.1.0: resolved "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz" integrity sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw== -redux@^5.0.1: +redux@^5.0.0, redux@^5.0.1: version "5.0.1" resolved "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz" integrity sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w== @@ -15850,16 +14911,16 @@ ret@~0.5.0: resolved "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz" integrity sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw== -retry@0.13.1: - version "0.13.1" - resolved "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz" - integrity sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg== - retry@^0.12.0: version "0.12.0" resolved "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz" integrity sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow== +retry@0.13.1: + version "0.13.1" + resolved "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz" + integrity sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg== + reusify@^1.0.4: version "1.1.0" resolved "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz" @@ -15915,6 +14976,40 @@ rollup-pluginutils@^2.8.2: dependencies: estree-walker "^0.6.1" +rollup@^1.20.0||^2.0.0||^3.0.0||^4.0.0, rollup@^2.14.0||^3.0.0||^4.0.0, rollup@^2.68.0||^3.0.0||^4.0.0, rollup@^2.78.0||^3.0.0||^4.0.0, rollup@^4.43.0, rollup@^4.53.1, rollup@>=4.0.0: + version "4.57.0" + resolved "https://registry.npmjs.org/rollup/-/rollup-4.57.0.tgz" + integrity sha512-e5lPJi/aui4TO1LpAXIRLySmwXSE8k3b9zoGfd42p67wzxog4WHjiZF3M2uheQih4DGyc25QEV4yRBbpueNiUA== + dependencies: + "@types/estree" "1.0.8" + optionalDependencies: + "@rollup/rollup-android-arm-eabi" "4.57.0" + "@rollup/rollup-android-arm64" "4.57.0" + "@rollup/rollup-darwin-arm64" "4.57.0" + "@rollup/rollup-darwin-x64" "4.57.0" + "@rollup/rollup-freebsd-arm64" "4.57.0" + "@rollup/rollup-freebsd-x64" "4.57.0" + "@rollup/rollup-linux-arm-gnueabihf" "4.57.0" + "@rollup/rollup-linux-arm-musleabihf" "4.57.0" + "@rollup/rollup-linux-arm64-gnu" "4.57.0" + "@rollup/rollup-linux-arm64-musl" "4.57.0" + "@rollup/rollup-linux-loong64-gnu" "4.57.0" + "@rollup/rollup-linux-loong64-musl" "4.57.0" + "@rollup/rollup-linux-ppc64-gnu" "4.57.0" + "@rollup/rollup-linux-ppc64-musl" "4.57.0" + "@rollup/rollup-linux-riscv64-gnu" "4.57.0" + "@rollup/rollup-linux-riscv64-musl" "4.57.0" + "@rollup/rollup-linux-s390x-gnu" "4.57.0" + "@rollup/rollup-linux-x64-gnu" "4.57.0" + "@rollup/rollup-linux-x64-musl" "4.57.0" + "@rollup/rollup-openbsd-x64" "4.57.0" + "@rollup/rollup-openharmony-arm64" "4.57.0" + "@rollup/rollup-win32-arm64-msvc" "4.57.0" + "@rollup/rollup-win32-ia32-msvc" "4.57.0" + "@rollup/rollup-win32-x64-gnu" "4.57.0" + "@rollup/rollup-win32-x64-msvc" "4.57.0" + fsevents "~2.3.2" + rollup@^4.34.9: version "4.57.1" resolved "https://registry.npmjs.org/rollup/-/rollup-4.57.1.tgz" @@ -15949,40 +15044,6 @@ rollup@^4.34.9: "@rollup/rollup-win32-x64-msvc" "4.57.1" fsevents "~2.3.2" -rollup@^4.43.0, rollup@^4.53.1: - version "4.57.0" - resolved "https://registry.npmjs.org/rollup/-/rollup-4.57.0.tgz" - integrity sha512-e5lPJi/aui4TO1LpAXIRLySmwXSE8k3b9zoGfd42p67wzxog4WHjiZF3M2uheQih4DGyc25QEV4yRBbpueNiUA== - dependencies: - "@types/estree" "1.0.8" - optionalDependencies: - "@rollup/rollup-android-arm-eabi" "4.57.0" - "@rollup/rollup-android-arm64" "4.57.0" - "@rollup/rollup-darwin-arm64" "4.57.0" - "@rollup/rollup-darwin-x64" "4.57.0" - "@rollup/rollup-freebsd-arm64" "4.57.0" - "@rollup/rollup-freebsd-x64" "4.57.0" - "@rollup/rollup-linux-arm-gnueabihf" "4.57.0" - "@rollup/rollup-linux-arm-musleabihf" "4.57.0" - "@rollup/rollup-linux-arm64-gnu" "4.57.0" - "@rollup/rollup-linux-arm64-musl" "4.57.0" - "@rollup/rollup-linux-loong64-gnu" "4.57.0" - "@rollup/rollup-linux-loong64-musl" "4.57.0" - "@rollup/rollup-linux-ppc64-gnu" "4.57.0" - "@rollup/rollup-linux-ppc64-musl" "4.57.0" - "@rollup/rollup-linux-riscv64-gnu" "4.57.0" - "@rollup/rollup-linux-riscv64-musl" "4.57.0" - "@rollup/rollup-linux-s390x-gnu" "4.57.0" - "@rollup/rollup-linux-x64-gnu" "4.57.0" - "@rollup/rollup-linux-x64-musl" "4.57.0" - "@rollup/rollup-openbsd-x64" "4.57.0" - "@rollup/rollup-openharmony-arm64" "4.57.0" - "@rollup/rollup-win32-arm64-msvc" "4.57.0" - "@rollup/rollup-win32-ia32-msvc" "4.57.0" - "@rollup/rollup-win32-x64-gnu" "4.57.0" - "@rollup/rollup-win32-x64-msvc" "4.57.0" - fsevents "~2.3.2" - roughjs@^4.6.6: version "4.6.6" resolved "https://registry.npmjs.org/roughjs/-/roughjs-4.6.6.tgz" @@ -16041,7 +15102,7 @@ rw@1: resolved "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz" integrity sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ== -rxjs@7.8.2, rxjs@^7.5.2, rxjs@^7.8.2: +rxjs@*, rxjs@^7.8.2, rxjs@7.8.2: version "7.8.2" resolved "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz" integrity sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA== @@ -16059,16 +15120,11 @@ safe-array-concat@^1.1.3: has-symbols "^1.1.0" isarray "^2.0.5" -safe-buffer@5.2.1, safe-buffer@^5.0.1, safe-buffer@~5.2.0: +safe-buffer@^5.0.1, safe-buffer@~5.2.0, safe-buffer@5.2.1: version "5.2.1" resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== -safe-buffer@~5.1.0, safe-buffer@~5.1.1: - version "5.1.2" - resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz" - integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== - safe-push-apply@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz" @@ -16115,16 +15171,16 @@ saxes@^6.0.0: dependencies: xmlchars "^2.2.0" -scheduler@0.26.0, scheduler@^0.26.0: - version "0.26.0" - resolved "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz" - integrity sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA== - scheduler@^0.25.0: version "0.25.0" resolved "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz" integrity sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA== +scheduler@^0.26.0, scheduler@0.26.0: + version "0.26.0" + resolved "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz" + integrity sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA== + scheduler@^0.27.0: version "0.27.0" resolved "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz" @@ -16157,16 +15213,26 @@ semver-compare@^1.0.0: resolved "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz" integrity sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow== -semver@7.7.3, semver@^7.1.3, semver@^7.3.5, semver@^7.5.3, semver@^7.5.4, semver@^7.6.0, semver@^7.7.2, semver@^7.7.3: - version "7.7.3" - resolved "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz" - integrity sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q== +semver@^6.0.0: + version "6.3.1" + resolved "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz" + integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== -semver@^6.0.0, semver@^6.3.0, semver@^6.3.1: +semver@^6.3.0: version "6.3.1" resolved "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz" integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== +semver@^6.3.1: + version "6.3.1" + resolved "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz" + integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== + +semver@^7.1.3, semver@^7.3.5, semver@^7.5.3, semver@^7.5.4, semver@^7.6.0, semver@^7.7.2, semver@^7.7.3, semver@7.7.3: + version "7.7.3" + resolved "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz" + integrity sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q== + semver@^7.3.2: version "7.7.4" resolved "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz" @@ -16371,7 +15437,7 @@ shelljs@^0.8.5: interpret "^1.0.0" rechoir "^0.6.2" -shiki@3.22.0, shiki@^3.0.0, shiki@^3.21.0: +shiki@^3.0.0, shiki@^3.21.0, shiki@3.22.0: version "3.22.0" resolved "https://registry.npmjs.org/shiki/-/shiki-3.22.0.tgz" integrity sha512-LBnhsoYEe0Eou4e1VgJACes+O6S6QC0w71fCSp5Oya79inkwkm15gQ1UF6VtQ8j/taMDh79hAB49WUk8ALQW3g== @@ -16443,24 +15509,15 @@ signal-exit@^3.0.2, signal-exit@^3.0.3, signal-exit@^3.0.7: resolved "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz" integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== -signal-exit@^4.0.1, signal-exit@^4.1.0: +signal-exit@^4.0.1: version "4.1.0" resolved "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz" integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw== -simple-concat@^1.0.0: - version "1.0.1" - resolved "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz" - integrity sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q== - -simple-get@^4.0.0: - version "4.0.1" - resolved "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz" - integrity sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA== - dependencies: - decompress-response "^6.0.0" - once "^1.3.1" - simple-concat "^1.0.0" +signal-exit@^4.1.0: + version "4.1.0" + resolved "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz" + integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw== simple-plist@^1.1.0: version "1.3.1" @@ -16511,7 +15568,7 @@ smart-buffer@^4.2.0: resolved "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz" integrity sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg== -socket.io-adapter@^2.5.5, socket.io-adapter@~2.5.2: +socket.io-adapter@^2.5.4, socket.io-adapter@^2.5.5, socket.io-adapter@~2.5.2: version "2.5.6" resolved "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.6.tgz" integrity sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ== @@ -16587,12 +15644,25 @@ source-map-support@~0.5.20, source-map-support@~0.5.21: buffer-from "^1.0.0" source-map "^0.6.0" +source-map-support@~0.5.21: + version "0.5.21" + resolved "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz" + integrity sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w== + dependencies: + buffer-from "^1.0.0" + source-map "^0.6.0" + source-map@^0.5.6: version "0.5.7" resolved "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz" integrity sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ== -source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.1: +source-map@^0.6.0: + version "0.6.1" + resolved "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz" + integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== + +source-map@^0.6.1: version "0.6.1" resolved "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz" integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== @@ -16602,6 +15672,11 @@ source-map@^0.7.0: resolved "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz" integrity sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ== +source-map@~0.6.1: + version "0.6.1" + resolved "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz" + integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== + space-separated-tokens@^2.0.0: version "2.0.2" resolved "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz" @@ -16627,7 +15702,14 @@ sprintf-js@~1.0.2: resolved "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz" integrity sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g== -stack-utils@^2.0.3, stack-utils@^2.0.6: +stack-utils@^2.0.3: + version "2.0.6" + resolved "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz" + integrity sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ== + dependencies: + escape-string-regexp "^2.0.0" + +stack-utils@^2.0.6: version "2.0.6" resolved "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz" integrity sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ== @@ -16701,20 +15783,18 @@ stream-json@^1.8.0: dependencies: stream-chain "^2.2.5" -streamx@^2.12.5, streamx@^2.15.0, streamx@^2.21.0: - version "2.23.0" - resolved "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz" - integrity sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg== - dependencies: - events-universal "^1.0.0" - fast-fifo "^1.3.2" - text-decoder "^1.1.0" - strict-uri-encode@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz" integrity sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ== +string_decoder@^1.1.1, string_decoder@^1.3.0: + version "1.3.0" + resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz" + integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== + dependencies: + safe-buffer "~5.2.0" + "string-width-cjs@npm:string-width@^4.2.0": version "4.2.3" resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" @@ -16751,7 +15831,15 @@ string-width@^7.0.0: get-east-asian-width "^1.0.0" strip-ansi "^7.1.0" -string-width@^8.0.0, string-width@^8.1.0: +string-width@^8.0.0: + version "8.1.0" + resolved "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz" + integrity sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg== + dependencies: + get-east-asian-width "^1.3.0" + strip-ansi "^7.1.0" + +string-width@^8.1.0: version "8.1.0" resolved "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz" integrity sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg== @@ -16791,20 +15879,6 @@ string.prototype.trimstart@^1.0.8: define-properties "^1.2.1" es-object-atoms "^1.0.0" -string_decoder@^1.1.1, string_decoder@^1.3.0: - version "1.3.0" - resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz" - integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== - dependencies: - safe-buffer "~5.2.0" - -string_decoder@~1.1.1: - version "1.1.1" - resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz" - integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg== - dependencies: - safe-buffer "~5.1.0" - stringify-entities@^4.0.0: version "4.0.4" resolved "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz" @@ -16834,7 +15908,14 @@ strip-ansi@^6.0.0, strip-ansi@^6.0.1: dependencies: ansi-regex "^5.0.1" -strip-ansi@^7.0.1, strip-ansi@^7.1.0, strip-ansi@^7.1.2: +strip-ansi@^7.0.1: + version "7.1.2" + resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz" + integrity sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA== + dependencies: + ansi-regex "^6.0.1" + +strip-ansi@^7.1.0, strip-ansi@^7.1.2: version "7.1.2" resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz" integrity sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA== @@ -16976,11 +16057,6 @@ tailwind-merge@^3.4.0: resolved "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz" integrity sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g== -tailwindcss@4.1.18, tailwindcss@^4.1.18: - version "4.1.18" - resolved "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz" - integrity sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw== - tailwindcss@^3.4.17: version "3.4.19" resolved "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz" @@ -17009,42 +16085,16 @@ tailwindcss@^3.4.17: resolve "^1.22.8" sucrase "^3.35.0" +tailwindcss@^4.0.0, tailwindcss@^4.1.18, tailwindcss@4.1.18: + version "4.1.18" + resolved "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz" + integrity sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw== + tapable@^2.2.0: version "2.3.0" resolved "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz" integrity sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg== -tar-fs@^2.0.0: - version "2.1.4" - resolved "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz" - integrity sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ== - dependencies: - chownr "^1.1.1" - mkdirp-classic "^0.5.2" - pump "^3.0.0" - tar-stream "^2.1.4" - -tar-stream@^2.1.4: - version "2.2.0" - resolved "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz" - integrity sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ== - dependencies: - bl "^4.0.3" - end-of-stream "^1.4.1" - fs-constants "^1.0.0" - inherits "^2.0.3" - readable-stream "^3.1.1" - -tar-stream@^3.0.0: - version "3.1.8" - resolved "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.8.tgz" - integrity sha512-U6QpVRyCGHva435KoNWy9PRoi2IFYCgtEhq9nmrPPpbRacPs9IH4aJ3gbrFC8dPcXvdSZ4XXfXT5Fshbp2MtlQ== - dependencies: - b4a "^1.6.4" - bare-fs "^4.5.5" - fast-fifo "^1.2.0" - streamx "^2.15.0" - tar@^7.0.1, tar@^7.5.2, tar@^7.5.4: version "7.5.7" resolved "https://registry.npmjs.org/tar/-/tar-7.5.7.tgz" @@ -17063,13 +16113,6 @@ tdigest@^0.1.1: dependencies: bintrees "1.0.2" -teex@^1.0.1: - version "1.0.1" - resolved "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz" - integrity sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg== - dependencies: - streamx "^2.12.5" - temp-dir@~2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz" @@ -17083,7 +16126,7 @@ terminal-link@^2.1.1: ansi-escapes "^4.2.1" supports-hyperlinks "^2.0.0" -terser@^5.15.0: +terser@^5.15.0, terser@^5.16.0: version "5.46.0" resolved "https://registry.npmjs.org/terser/-/terser-5.46.0.tgz" integrity sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg== @@ -17102,13 +16145,6 @@ test-exclude@^6.0.0: glob "^7.1.4" minimatch "^3.0.4" -text-decoder@^1.1.0: - version "1.2.7" - resolved "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz" - integrity sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ== - dependencies: - b4a "^1.6.4" - thenify-all@^1.0.0: version "1.6.0" resolved "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz" @@ -17123,13 +16159,6 @@ thenify-all@^1.0.0: dependencies: any-promise "^1.0.0" -thread-stream@^3.0.0: - version "3.1.0" - resolved "https://registry.yarnpkg.com/thread-stream/-/thread-stream-3.1.0.tgz#4b2ef252a7c215064507d4ef70c05a5e2d34c4f1" - integrity sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A== - dependencies: - real-require "^0.2.0" - thread-stream@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz" @@ -17164,7 +16193,7 @@ tinyexec@^1.0.1, tinyexec@^1.0.2: resolved "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz" integrity sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg== -tinyglobby@0.2.15, tinyglobby@^0.2.11, tinyglobby@^0.2.13, tinyglobby@^0.2.14, tinyglobby@^0.2.15: +tinyglobby@^0.2.11, tinyglobby@^0.2.13, tinyglobby@^0.2.14, tinyglobby@^0.2.15, tinyglobby@0.2.15: version "0.2.15" resolved "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz" integrity sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ== @@ -17285,7 +16314,7 @@ tsconfck@^3.0.3: resolved "https://registry.npmjs.org/tsconfck/-/tsconfck-3.1.6.tgz" integrity sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w== -tslib@2.8.1, tslib@^2.0.0, tslib@^2.0.1, tslib@^2.1.0, tslib@^2.4.0, tslib@^2.8.0, tslib@^2.8.1: +tslib@*, tslib@^2.0.0, tslib@^2.0.1, tslib@^2.1.0, tslib@^2.6.2, tslib@^2.8.0, tslib@^2.8.1, tslib@2.8.1: version "2.8.1" resolved "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz" integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== @@ -17295,7 +16324,7 @@ tslib@^1.9.3: resolved "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== -tsx@^4.19.2, tsx@^4.20.4, tsx@^4.20.6: +tsx@^4.19.2, tsx@^4.20.4, tsx@^4.20.6, tsx@^4.8.1: version "4.21.0" resolved "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz" integrity sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw== @@ -17312,13 +16341,6 @@ tsyringe@^4.10.0: dependencies: tslib "^1.9.3" -tunnel-agent@^0.6.0: - version "0.6.0" - resolved "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz" - integrity sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w== - dependencies: - safe-buffer "^5.0.1" - tweetnacl@^1.0.3: version "1.0.3" resolved "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz" @@ -17420,9 +16442,9 @@ typed-emitter@^2.1.0: resolved "https://registry.npmjs.org/typed-emitter/-/typed-emitter-2.1.0.tgz" integrity sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA== optionalDependencies: - rxjs "^7.5.2" + rxjs "*" -typescript@5.9.3, typescript@^5.9.2, typescript@^5.9.3: +"typescript@^4.1 || ^5.0", typescript@^5.0.0, typescript@^5.9.2, typescript@^5.9.3, typescript@>=2.7, typescript@>=3.7.0, typescript@>=5.1.0, typescript@5.9.3: version "5.9.3" resolved "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz" integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw== @@ -17462,7 +16484,7 @@ undici-types@~7.16.0: resolved "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz" integrity sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw== -undici@6.23.0, undici@^6.18.2, undici@^6.22.0: +undici@^6.18.2, undici@^6.22.0, undici@6.23.0: version "6.23.0" resolved "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz" integrity sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g== @@ -17658,7 +16680,7 @@ use-sidecar@^1.1.3: detect-node-es "^1.1.0" tslib "^2.0.0" -use-sync-external-store@^1.5.0: +use-sync-external-store@^1.5.0, use-sync-external-store@>=1.2.0: version "1.6.0" resolved "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz" integrity sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w== @@ -17670,7 +16692,7 @@ usehooks-ts@3.1.1: dependencies: lodash.debounce "^4.0.8" -util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1: +util-deprecate@^1.0.1, util-deprecate@^1.0.2: version "1.0.2" resolved "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz" integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== @@ -17774,7 +16796,7 @@ vite-tsconfig-paths@^5.1.4: globrex "^0.1.2" tsconfck "^3.0.3" -"vite@^5.0.0 || ^6.0.0 || ^7.0.0-0": +vite@*, "vite@^5.0.0 || ^6.0.0 || ^7.0.0-0", "vite@6.x.x || 7.x.x": version "7.3.1" resolved "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz" integrity sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA== @@ -17802,7 +16824,36 @@ vite@^6.0.7: optionalDependencies: fsevents "~2.3.3" -vitest@^3.2.0, vitest@^3.2.4: +vitest@^3.2.0: + version "3.2.4" + resolved "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz" + integrity sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A== + dependencies: + "@types/chai" "^5.2.2" + "@vitest/expect" "3.2.4" + "@vitest/mocker" "3.2.4" + "@vitest/pretty-format" "^3.2.4" + "@vitest/runner" "3.2.4" + "@vitest/snapshot" "3.2.4" + "@vitest/spy" "3.2.4" + "@vitest/utils" "3.2.4" + chai "^5.2.0" + debug "^4.4.1" + expect-type "^1.2.1" + magic-string "^0.30.17" + pathe "^2.0.3" + picomatch "^4.0.2" + std-env "^3.9.0" + tinybench "^2.9.0" + tinyexec "^0.3.2" + tinyglobby "^0.2.14" + tinypool "^1.1.1" + tinyrainbow "^2.0.0" + vite "^5.0.0 || ^6.0.0 || ^7.0.0-0" + vite-node "3.2.4" + why-is-node-running "^2.3.0" + +vitest@^3.2.4: version "3.2.4" resolved "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz" integrity sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A== @@ -17890,7 +16941,7 @@ walker@^1.0.7, walker@^1.0.8: dependencies: makeerror "1.0.12" -warn-once@0.1.1, warn-once@^0.1.0, warn-once@^0.1.1: +warn-once@^0.1.0, warn-once@^0.1.1, warn-once@0.1.1: version "0.1.1" resolved "https://registry.npmjs.org/warn-once/-/warn-once-0.1.1.tgz" integrity sha512-VkQZJbO8zVImzYFteBXvBOZEl1qL175WH8VmZcxF2fZAoudNhNDvHi+doCaAEdU2l2vtcIwa2zn0QK5+I1HQ3Q== @@ -18166,12 +17217,22 @@ ws@^6.2.3: dependencies: async-limiter "~1.0.0" -ws@^7, ws@^7.5.10: +ws@^7: + version "7.5.10" + resolved "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz" + integrity sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ== + +ws@^7.5.10: version "7.5.10" resolved "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz" integrity sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ== -ws@^8.12.1, ws@^8.18.0: +ws@^8.12.1: + version "8.19.0" + resolved "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz" + integrity sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg== + +ws@^8.18.0: version "8.19.0" resolved "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz" integrity sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg== @@ -18201,14 +17262,6 @@ xml-name-validator@^5.0.0: resolved "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz" integrity sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg== -xml2js@0.6.0: - version "0.6.0" - resolved "https://registry.npmjs.org/xml2js/-/xml2js-0.6.0.tgz" - integrity sha512-eLTh0kA8uHceqesPqSE+VvO1CDDJWMwlQfB6LuN6T8w6MaDJ8Txm8P7s5cHD0miF0V+GGTZrDQfxPZQVsur33w== - dependencies: - sax ">=0.6.0" - xmlbuilder "~11.0.0" - "xml2js@^0.5.0 || ^0.6.2": version "0.6.2" resolved "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz" @@ -18217,6 +17270,14 @@ xml2js@0.6.0: sax ">=0.6.0" xmlbuilder "~11.0.0" +xml2js@0.6.0: + version "0.6.0" + resolved "https://registry.npmjs.org/xml2js/-/xml2js-0.6.0.tgz" + integrity sha512-eLTh0kA8uHceqesPqSE+VvO1CDDJWMwlQfB6LuN6T8w6MaDJ8Txm8P7s5cHD0miF0V+GGTZrDQfxPZQVsur33w== + dependencies: + sax ">=0.6.0" + xmlbuilder "~11.0.0" + xmlbuilder@^15.1.1: version "15.1.1" resolved "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz" @@ -18272,11 +17333,6 @@ yaml@^2.2.2, yaml@^2.4.2, yaml@^2.6.1, yaml@^2.8.1: resolved "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz" integrity sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A== -yargs-parser@21.1.1, yargs-parser@^21.1.1: - version "21.1.1" - resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz" - integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw== - yargs-parser@^18.1.2: version "18.1.3" resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz" @@ -18285,6 +17341,11 @@ yargs-parser@^18.1.2: camelcase "^5.0.0" decamelize "^1.2.0" +yargs-parser@^21.1.1, yargs-parser@21.1.1: + version "21.1.1" + resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz" + integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw== + yargs@^15.3.1: version "15.4.1" resolved "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz" @@ -18348,30 +17409,36 @@ yoga-layout@~3.2.1: resolved "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz" integrity sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ== -zip-stream@^6.0.1: - version "6.0.1" - resolved "https://registry.npmjs.org/zip-stream/-/zip-stream-6.0.1.tgz" - integrity sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA== - dependencies: - archiver-utils "^5.0.0" - compress-commons "^6.0.2" - readable-stream "^4.0.0" +zod-to-json-schema@^3.24.3: + version "3.25.1" + resolved "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz" + integrity sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA== -zod-to-json-schema@3.25.1, zod-to-json-schema@^3.24.3, zod-to-json-schema@^3.25.0: +zod-to-json-schema@^3.25.0: version "3.25.1" resolved "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz" integrity sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA== -zod@3.25.76: - version "3.25.76" - resolved "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz" - integrity sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ== +zod-to-json-schema@3.25.1: + version "3.25.1" + resolved "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz" + integrity sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA== + +"zod@^3.25 || ^4", "zod@^3.25 || ^4.0", "zod@^3.25.0 || ^4.0.0", "zod@^3.25.76 || ^4.1.8", zod@^4.0.0, zod@>=4.1.5, zod@4.3.6: + version "4.3.6" + resolved "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz" + integrity sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg== -zod@4.3.6, "zod@^3.25 || ^4.0", zod@^4.3.5: +zod@^4.3.5, zod@4.x.x: version "4.3.6" resolved "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz" integrity sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg== +zod@3.25.76: + version "3.25.76" + resolved "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz" + integrity sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ== + zustand@^5.0.6: version "5.0.10" resolved "https://registry.npmjs.org/zustand/-/zustand-5.0.10.tgz" From d74e2a3897b5f3b1f64e43777788da0d31d3a0ac Mon Sep 17 00:00:00 2001 From: Chris Denneen Date: Fri, 6 Mar 2026 13:17:11 -0500 Subject: [PATCH 03/81] Harden bridge runtime, relay validation, and diagnostics --- .../channelBindingStore.server.test.ts | 83 ++++++++++- .../channels/channelBindingStore.server.ts | 20 ++- .../channelBridgeAccountConfig.test.ts | 21 +++ .../channels/channelBridgeAccountConfig.ts | 13 +- .../cli/src/channels/channelBridgeServerKv.ts | 12 +- .../src/channels/core/channelBridgeWorker.ts | 4 +- .../channels/startChannelBridgeWorker.test.ts | 135 ++++++++++++++++++ .../src/channels/startChannelBridgeWorker.ts | 60 ++++++-- .../channels/telegram/telegramAdapter.test.ts | 3 + .../src/channels/telegram/telegramAdapter.ts | 1 + .../telegram/telegramWebhookRelay.test.ts | 21 +++ .../channels/telegram/telegramWebhookRelay.ts | 21 ++- apps/cli/src/cli/commands/bridge.test.ts | 72 +++++++++- apps/cli/src/cli/commands/bridge.ts | 41 +++--- .../src/cli/dispatch.tmuxDisallowed.test.ts | 11 ++ apps/cli/src/cli/dispatch.ts | 5 +- ...startDaemon.automation.integration.test.ts | 1 + apps/cli/src/daemon/startDaemon.ts | 10 +- apps/cli/src/persistence.ts | 9 +- apps/cli/src/ui/doctor.test.ts | 54 +++++++ apps/cli/src/ui/doctor.ts | 93 +++++++----- docs/README.md | 1 + docs/channel-bridge-uat.md | 4 +- docs/channel-bridge.md | 4 +- docs/telegram-channel-bridge.md | 4 +- 25 files changed, 607 insertions(+), 96 deletions(-) diff --git a/apps/cli/src/channels/channelBindingStore.server.test.ts b/apps/cli/src/channels/channelBindingStore.server.test.ts index a3ffc0dcbc..d356ed0402 100644 --- a/apps/cli/src/channels/channelBindingStore.server.test.ts +++ b/apps/cli/src/channels/channelBindingStore.server.test.ts @@ -479,7 +479,7 @@ describe('createServerBackedChannelBindingStore', () => { expect(conflict.mutateCallCount()).toBe(1); }); - it('surfaces malformed primary KV payload conflicts instead of clobbering remote state', async () => { + it('fails fast when malformed primary KV payload is encountered before cache warm-up', async () => { const kv = createMalformedPrimaryReadKvClient(); const store = createServerBackedChannelBindingStore({ kv, @@ -487,7 +487,7 @@ describe('createServerBackedChannelBindingStore', () => { maxWriteRetries: 3, }); - await expect(store.listBindings()).resolves.toEqual([]); + await expect(store.listBindings()).rejects.toThrow('Invalid channel bridge binding lastForwardedSeq at index 0'); await expect(store.upsertBinding({ providerId: 'telegram', @@ -495,15 +495,88 @@ describe('createServerBackedChannelBindingStore', () => { threadId: null, sessionId: 'sess-4', lastForwardedSeq: 1, - })).rejects.toThrow('Conflict payload decode failed'); + })).rejects.toThrow('Invalid channel bridge binding lastForwardedSeq at index 0'); - const binding = await store.getBinding({ + await expect(store.getBinding({ providerId: 'telegram', conversationId: '-100444', threadId: null, + })).rejects.toThrow('Invalid channel bridge binding lastForwardedSeq at index 0'); + }); + + it('returns last known-good bindings when a recoverable decode error occurs after cache warm-up', async () => { + const validValue = Buffer.from(JSON.stringify({ + schemaVersion: 1, + bindings: [ + { + providerId: 'telegram', + conversationId: '-100cache', + threadId: null, + sessionId: 'sess-cache', + lastForwardedSeq: 7, + createdAtMs: 10, + updatedAtMs: 11, + }, + ], + }), 'utf8').toString('base64'); + + const malformedValue = Buffer.from(JSON.stringify({ + schemaVersion: 1, + bindings: [ + { + providerId: 'telegram', + conversationId: '-100cache', + threadId: null, + sessionId: 'sess-cache', + lastForwardedSeq: 'oops', + }, + ], + }), 'utf8').toString('base64'); + + let getCalls = 0; + const kv: ChannelBridgeKvClient = { + get: async (key) => { + getCalls += 1; + return { + status: 200, + body: { + key, + version: 4, + value: getCalls === 1 ? validValue : malformedValue, + }, + }; + }, + mutate: async () => ({ + status: 200, + body: { + success: true, + results: [], + }, + }), + }; + + const store = createServerBackedChannelBindingStore({ + kv, + serverId: 'local-3005', + cacheTtlMs: 0, + maxWriteRetries: 3, }); - expect(binding).toBeNull(); + const first = await store.listBindings(); + expect(first).toEqual([ + { + providerId: 'telegram', + conversationId: '-100cache', + threadId: null, + sessionId: 'sess-cache', + lastForwardedSeq: 7, + createdAtMs: 10, + updatedAtMs: 11, + }, + ]); + + const second = await store.listBindings(); + expect(second).toEqual(first); }); it('uses conflict payload state for retry without extra primary read', async () => { diff --git a/apps/cli/src/channels/channelBindingStore.server.ts b/apps/cli/src/channels/channelBindingStore.server.ts index 2fbb9b73fb..607f0348ec 100644 --- a/apps/cli/src/channels/channelBindingStore.server.ts +++ b/apps/cli/src/channels/channelBindingStore.server.ts @@ -1,4 +1,12 @@ -import type { ChannelBindingStore, ChannelBridgeConversationRef, ChannelSessionBinding } from './core/channelBridgeWorker'; +/** + * Server-backed channel binding store. + * + * Responsibilities: + * - read/write channel binding state from server KV + * - keep optimistic concurrency behavior safe across concurrent machines + * - preserve local cache coherence and avoid silently clobbering remote data + */ +import type { ChannelBindingStore, ChannelBridgeConversationRef, ChannelSessionBinding } from '@/channels/core/channelBridgeWorker'; import { ChannelBridgeBadPayloadError, ChannelBridgeKvVersionMismatchError, @@ -7,7 +15,7 @@ import { type ChannelBridgeKvClient, type ChannelBridgeServerBindingsDocument, writeChannelBridgeBindingsToKv, -} from './channelBridgeServerKv'; +} from '@/channels/channelBridgeServerKv'; import { logger } from '@/ui/logger'; type BindingCache = Readonly<{ @@ -108,11 +116,13 @@ export function createServerBackedChannelBindingStore(params: Readonly<{ throw error; } - logger.warn('[channelBindingStore] Failed to decode primary KV payload; using empty bindings fallback', error); + logger.warn('[channelBindingStore] Failed to decode primary KV payload', error); + if (!cache) { + throw error; + } const next: BindingCache = { - version: cache?.version ?? -1, - bindings: [], + ...cache, fetchedAtMs: Date.now(), }; cache = next; diff --git a/apps/cli/src/channels/channelBridgeAccountConfig.test.ts b/apps/cli/src/channels/channelBridgeAccountConfig.test.ts index 9f68328e0d..c60941c4af 100644 --- a/apps/cli/src/channels/channelBridgeAccountConfig.test.ts +++ b/apps/cli/src/channels/channelBridgeAccountConfig.test.ts @@ -108,6 +108,27 @@ describe('channelBridgeAccountConfig', () => { expect(telegram?.allowedChatIds).toEqual(['-100111', '-100222']); }); + it('does not materialize providers.telegram for tick-only scoped updates', () => { + const next = upsertScopedTelegramBridgeConfig({ + settings: {}, + serverId: 'local-3005', + accountId: 'acct-1', + update: { + tickMs: 2_500, + }, + }); + + expect((next as any).channelBridge.byServerId['local-3005'].byAccountId['acct-1'].providers).toBeUndefined(); + + const telegram = readScopedTelegramBridgeConfig({ + settings: next, + serverId: 'local-3005', + accountId: 'acct-1', + }); + + expect(telegram).toBeNull(); + }); + it('reports no shared update when only secrets are provided', () => { const split = splitScopedTelegramBridgeUpdate({ update: { diff --git a/apps/cli/src/channels/channelBridgeAccountConfig.ts b/apps/cli/src/channels/channelBridgeAccountConfig.ts index d140c9aba1..1975eca457 100644 --- a/apps/cli/src/channels/channelBridgeAccountConfig.ts +++ b/apps/cli/src/channels/channelBridgeAccountConfig.ts @@ -236,13 +236,20 @@ export function upsertScopedTelegramBridgeConfig(params: Readonly<{ accountScope.tickMs = Math.trunc(params.update.tickMs); } - const providers = ensureRecord(accountScope, 'providers'); - const telegram = ensureRecord(providers, 'telegram'); + let cachedTelegramScope: RecordLike | null = null; + const ensureTelegramScope = (): RecordLike => { + if (cachedTelegramScope) return cachedTelegramScope; + const providers = ensureRecord(accountScope, 'providers'); + cachedTelegramScope = ensureRecord(providers, 'telegram'); + return cachedTelegramScope; + }; if (Array.isArray(params.update.allowedChatIds)) { + const telegram = ensureTelegramScope(); telegram.allowedChatIds = parseStringArray(params.update.allowedChatIds); } if (typeof params.update.requireTopics === 'boolean') { + const telegram = ensureTelegramScope(); telegram.requireTopics = params.update.requireTopics; } @@ -252,6 +259,7 @@ export function upsertScopedTelegramBridgeConfig(params: Readonly<{ || (typeof params.update.webhookPort === 'number' && Number.isFinite(params.update.webhookPort)); if (hasWebhookUpdate) { + const telegram = ensureTelegramScope(); const webhook = ensureRecord(telegram, 'webhook'); if (typeof params.update.webhookEnabled === 'boolean') { webhook.enabled = params.update.webhookEnabled; @@ -269,6 +277,7 @@ export function upsertScopedTelegramBridgeConfig(params: Readonly<{ || typeof params.update.webhookSecret === 'string'; if (hasSecretUpdate) { + const telegram = ensureTelegramScope(); const secrets = ensureRecord(telegram, 'secrets'); if (typeof params.update.botToken === 'string') { secrets.botToken = params.update.botToken; diff --git a/apps/cli/src/channels/channelBridgeServerKv.ts b/apps/cli/src/channels/channelBridgeServerKv.ts index fb6f889091..c35dbee84d 100644 --- a/apps/cli/src/channels/channelBridgeServerKv.ts +++ b/apps/cli/src/channels/channelBridgeServerKv.ts @@ -102,6 +102,12 @@ function asRecord(value: unknown): Record | null { return value as Record; } +function normalizeStringArray(values: readonly unknown[]): string[] { + return values + .map((value) => (typeof value === 'string' ? value.trim() : '')) + .filter((value) => value.length > 0); +} + function parseKvEntry(value: unknown): Readonly<{ valueBase64: string; version: number }> | null { const record = asRecord(value); if (!record) return null; @@ -182,9 +188,7 @@ function parseTelegramConfigRecord(value: unknown): ChannelBridgeServerTelegramC } if (Array.isArray(telegram.allowedChatIds)) { - out.telegram.allowedChatIds = telegram.allowedChatIds - .map((value) => (typeof value === 'string' ? value.trim() : '')) - .filter((value) => value.length > 0); + out.telegram.allowedChatIds = normalizeStringArray(telegram.allowedChatIds); } if (typeof telegram.requireTopics === 'boolean') { out.telegram.requireTopics = telegram.requireTopics; @@ -452,7 +456,7 @@ function applyTelegramConfigUpdate(current: ChannelBridgeServerTelegramConfigRec } if (Array.isArray(update.allowedChatIds)) { - next.telegram.allowedChatIds = [...update.allowedChatIds]; + next.telegram.allowedChatIds = normalizeStringArray(update.allowedChatIds); } if (typeof update.requireTopics === 'boolean') { next.telegram.requireTopics = update.requireTopics; diff --git a/apps/cli/src/channels/core/channelBridgeWorker.ts b/apps/cli/src/channels/core/channelBridgeWorker.ts index 9af51f9161..48b4cdb17f 100644 --- a/apps/cli/src/channels/core/channelBridgeWorker.ts +++ b/apps/cli/src/channels/core/channelBridgeWorker.ts @@ -198,8 +198,8 @@ type ChannelBridgeInboundDeduper = Readonly<{ */ export function createChannelBridgeInboundDeduper(now: () => number = () => Date.now()): ChannelBridgeInboundDeduper { const recent = new Map(); - const ttlMs = 5 * 60 * 1000; - const maxEntries = 20_000; + const ttlMs = 24 * 60 * 60 * 1000; + const maxEntries = 100_000; const minPruneIntervalMs = 1_000; let lastPrunedAtMs = 0; diff --git a/apps/cli/src/channels/startChannelBridgeWorker.test.ts b/apps/cli/src/channels/startChannelBridgeWorker.test.ts index 5fb7808b7a..ce58115349 100644 --- a/apps/cli/src/channels/startChannelBridgeWorker.test.ts +++ b/apps/cli/src/channels/startChannelBridgeWorker.test.ts @@ -256,6 +256,141 @@ describe('startChannelBridgeFromEnv', () => { await handle?.stop(); }); + it('bypasses decryptTranscriptRows for plain sessions when fetching agent output', async () => { + vi.resetModules(); + + const fetchSessionById = vi.fn(async () => ({ + id: 'sess-plain-1', + encryptionMode: 'plain', + metadata: null, + dataEncryptionKey: null, + })); + const fetchAfterSeq = vi.fn(async () => ([ + { + seq: 11, + createdAt: 1, + content: { + t: 'plain', + v: { + role: 'agent', + content: { + type: 'text', + text: 'plain assistant output', + }, + }, + }, + }, + { + seq: 12, + createdAt: 2, + content: { + t: 'plain', + v: { + role: 'user', + content: { + type: 'text', + text: 'user message', + }, + }, + }, + }, + ])); + const decryptRows = vi.fn(() => ([ + { + seq: 99, + createdAtMs: 1, + role: 'agent', + content: { type: 'text', text: 'should not be used' }, + }, + ])); + + let capturedDeps: { + fetchAgentMessagesAfterSeq: (params: { sessionId: string; afterSeq: number }) => Promise>; + } | null = null; + + vi.doMock('@/sessionControl/sessionsHttp', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchSessionById, + }; + }); + + vi.doMock('@/api/session/fetchEncryptedTranscriptWindow', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchEncryptedTranscriptPageAfterSeq: fetchAfterSeq, + }; + }); + + vi.doMock('@/session/replay/decryptTranscriptRows', () => ({ + decryptTranscriptRows: decryptRows, + })); + + vi.doMock('./core/channelBridgeWorker', () => ({ + createInMemoryChannelBindingStore: vi.fn(() => ({ + listBindings: async () => [], + getBinding: async () => null, + upsertBinding: async () => ({ + providerId: 'telegram', + conversationId: 'conv-1', + threadId: null, + sessionId: 'sess-1', + lastForwardedSeq: 0, + createdAtMs: Date.now(), + updatedAtMs: Date.now(), + }), + updateLastForwardedSeq: async () => undefined, + removeBinding: async () => false, + })), + startChannelBridgeWorker: vi.fn((params: { deps: typeof capturedDeps }) => { + capturedDeps = params.deps as typeof capturedDeps; + return { + trigger: vi.fn(), + stop: vi.fn(async () => undefined), + }; + }), + })); + + vi.doMock('./telegram/telegramAdapter', () => ({ + createTelegramChannelAdapter: vi.fn(() => ({ + providerId: 'telegram', + pullInboundMessages: async () => [], + sendMessage: async () => undefined, + enqueueWebhookUpdate: vi.fn(), + stop: async () => undefined, + })), + })); + + const { startChannelBridgeFromEnv } = await import('./startChannelBridgeWorker'); + + const handle = await startChannelBridgeFromEnv({ + credentials, + env: { + HAPPIER_TELEGRAM_BOT_TOKEN: 'bot-token', + } as NodeJS.ProcessEnv, + }); + + expect(handle).not.toBeNull(); + expect(capturedDeps).not.toBeNull(); + + const rows = await capturedDeps!.fetchAgentMessagesAfterSeq({ + sessionId: 'sess-plain-1', + afterSeq: 0, + }); + + expect(rows).toEqual([ + { + seq: 11, + text: 'plain assistant output', + }, + ]); + expect(decryptRows).not.toHaveBeenCalled(); + + await handle?.stop(); + }); + it('refreshes session runtime LRU order on cache hits', async () => { vi.resetModules(); diff --git a/apps/cli/src/channels/startChannelBridgeWorker.ts b/apps/cli/src/channels/startChannelBridgeWorker.ts index 866c8194b0..12c0a99324 100644 --- a/apps/cli/src/channels/startChannelBridgeWorker.ts +++ b/apps/cli/src/channels/startChannelBridgeWorker.ts @@ -75,6 +75,45 @@ function extractAssistantText(content: unknown): string | null { return null; } +type PlainTranscriptRowLike = Readonly<{ + seq: number; + createdAt: number; + content: unknown; +}>; + +function parsePlainTranscriptRows(rows: readonly PlainTranscriptRowLike[]): ReadonlyArray> { + const out: Array> = []; + + for (const row of rows) { + const content = asRecord(row.content); + if (!content || content.t !== 'plain') continue; + const value = asRecord(content.v); + if (!value) continue; + + const role = value.role; + if (role !== 'user' && role !== 'agent') continue; + + out.push({ + seq: row.seq, + createdAtMs: row.createdAt, + role, + content: value.content, + }); + } + + return out; +} + function createDefaultChannelBridgeDeps(credentials: Credentials): DefaultChannelBridgeDepsHandle { type CachedSessionRuntime = Readonly<{ ctx: SessionEncryptionContext; @@ -110,11 +149,6 @@ function createDefaultChannelBridgeDeps(credentials: Credentials): DefaultChanne return rememberSessionRuntime(sessionId, runtime); } - async function resolveSessionContext(sessionId: string): Promise { - const runtime = await resolveSessionRuntime(sessionId); - return runtime?.ctx ?? null; - } - return { deps: { listSessions: async () => { @@ -203,16 +237,24 @@ function createDefaultChannelBridgeDeps(credentials: Credentials): DefaultChanne return Math.max(0, Math.trunc(rows[0]!.seq)); }, fetchAgentMessagesAfterSeq: async ({ sessionId, afterSeq }) => { - const ctx = await resolveSessionContext(sessionId); - if (!ctx) return []; + const runtime = await resolveSessionRuntime(sessionId); + if (!runtime) return []; + const encrypted = await fetchEncryptedTranscriptPageAfterSeq({ token: credentials.token, sessionId, afterSeq, limit: 50, }); - const decrypted = decryptTranscriptRows({ ctx, rows: encrypted }); - return decrypted + + const transcriptRows = runtime.mode === 'plain' + ? parsePlainTranscriptRows(encrypted) + : decryptTranscriptRows({ + ctx: runtime.ctx, + rows: encrypted, + }); + + return transcriptRows .filter((row) => row.role === 'agent') .map((row) => ({ seq: row.seq, text: extractAssistantText(row.content) })) .filter((row) => typeof row.text === 'string' && row.text.trim().length > 0) diff --git a/apps/cli/src/channels/telegram/telegramAdapter.test.ts b/apps/cli/src/channels/telegram/telegramAdapter.test.ts index a8817709ad..b470770335 100644 --- a/apps/cli/src/channels/telegram/telegramAdapter.test.ts +++ b/apps/cli/src/channels/telegram/telegramAdapter.test.ts @@ -51,6 +51,7 @@ describe('createTelegramChannelAdapter', () => { providerId: 'telegram', conversationId: '-100555', threadId: '9001', + senderId: '42', text: 'hello from topic', messageId: '5001', }, @@ -111,6 +112,7 @@ describe('createTelegramChannelAdapter', () => { providerId: 'telegram', conversationId: '1234', threadId: null, + senderId: '456', text: '/sessions', messageId: '88', }, @@ -152,6 +154,7 @@ describe('createTelegramChannelAdapter', () => { providerId: 'telegram', conversationId: '1234', threadId: null, + senderId: '456', text: 'retry me', messageId: '9001', }, diff --git a/apps/cli/src/channels/telegram/telegramAdapter.ts b/apps/cli/src/channels/telegram/telegramAdapter.ts index 09a258fe0e..0866ed2cc9 100644 --- a/apps/cli/src/channels/telegram/telegramAdapter.ts +++ b/apps/cli/src/channels/telegram/telegramAdapter.ts @@ -132,6 +132,7 @@ function parseInboundFromUpdate(params: Readonly<{ providerId: 'telegram', conversationId, threadId, + senderId: senderId === null ? null : String(senderId), text, messageId, }; diff --git a/apps/cli/src/channels/telegram/telegramWebhookRelay.test.ts b/apps/cli/src/channels/telegram/telegramWebhookRelay.test.ts index 8f0049d849..058aebf25f 100644 --- a/apps/cli/src/channels/telegram/telegramWebhookRelay.test.ts +++ b/apps/cli/src/channels/telegram/telegramWebhookRelay.test.ts @@ -22,6 +22,7 @@ describe('startTelegramWebhookRelay', () => { port: 0, host: '127.0.0.1', secretPathToken: 'secret-123', + secretHeaderToken: 'secret-123', onUpdate: (update) => { received.push(update); }, @@ -93,6 +94,7 @@ describe('startTelegramWebhookRelay', () => { port: 0, host: '127.0.0.1', secretPathToken: 'secret-fail', + secretHeaderToken: 'secret-fail', onUpdate: async () => { throw new Error('failed to process update'); }, @@ -119,4 +121,23 @@ describe('startTelegramWebhookRelay', () => { await relay.stop(); } }); + + it('requires an explicit webhook header secret token', async () => { + await expect(startTelegramWebhookRelay({ + port: 0, + host: '127.0.0.1', + secretPathToken: 'secret-123', + onUpdate: () => undefined, + })).rejects.toThrow('Webhook header secret token is required'); + }); + + it('rejects non-loopback webhook hosts', async () => { + await expect(startTelegramWebhookRelay({ + port: 0, + host: '0.0.0.0', + secretPathToken: 'secret-123', + secretHeaderToken: 'secret-123', + onUpdate: () => undefined, + })).rejects.toThrow('Webhook host must be loopback-only'); + }); }); diff --git a/apps/cli/src/channels/telegram/telegramWebhookRelay.ts b/apps/cli/src/channels/telegram/telegramWebhookRelay.ts index 37f1cc4588..960f67b3cf 100644 --- a/apps/cli/src/channels/telegram/telegramWebhookRelay.ts +++ b/apps/cli/src/channels/telegram/telegramWebhookRelay.ts @@ -1,4 +1,5 @@ import { timingSafeEqual } from 'node:crypto'; +import { isIP } from 'node:net'; import fastify from 'fastify'; @@ -15,6 +16,21 @@ export type TelegramWebhookRelayHandle = Readonly<{ stop: () => Promise; }>; +function isLoopbackHost(host: string): boolean { + const normalized = host.trim().toLowerCase(); + if (normalized === 'localhost') return true; + + const ipVersion = isIP(normalized); + if (ipVersion === 4) { + return normalized.startsWith('127.'); + } + if (ipVersion === 6) { + return normalized === '::1'; + } + + return false; +} + export async function startTelegramWebhookRelay(params: Readonly<{ port: number; host?: string; @@ -30,12 +46,15 @@ export async function startTelegramWebhookRelay(params: Readonly<{ throw new Error('Webhook secret token must match [A-Za-z0-9_-]'); } - const secretHeaderToken = String(params.secretHeaderToken ?? secretPathToken).trim(); + const secretHeaderToken = String(params.secretHeaderToken ?? '').trim(); if (!secretHeaderToken) { throw new Error('Webhook header secret token is required'); } const host = String(params.host ?? '127.0.0.1').trim() || '127.0.0.1'; + if (!isLoopbackHost(host)) { + throw new Error('Webhook host must be loopback-only'); + } const requestedPort = Number.isFinite(params.port) ? Math.trunc(params.port) : 0; if (requestedPort < 0 || requestedPort > 65_535) { throw new Error('Webhook port must be between 0 and 65535'); diff --git a/apps/cli/src/cli/commands/bridge.test.ts b/apps/cli/src/cli/commands/bridge.test.ts index 7d7466c0db..85f2e536f6 100644 --- a/apps/cli/src/cli/commands/bridge.test.ts +++ b/apps/cli/src/cli/commands/bridge.test.ts @@ -98,10 +98,32 @@ describe('happier bridge command', () => { }, }); expect(updateSettingsMock).toHaveBeenCalledTimes(1); - expect(updateSettingsMock.mock.invocationCallOrder[0]).toBeLessThan(upsertKvConfigMock.mock.invocationCallOrder[0]); + expect(upsertKvConfigMock.mock.invocationCallOrder[0]).toBeLessThan(updateSettingsMock.mock.invocationCallOrder[0]); expect(process.exitCode).toBeUndefined(); }); + it('does not update local settings when shared KV write fails', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + upsertKvConfigMock.mockRejectedValueOnce(new Error('KV unavailable')); + + try { + const { handleBridgeCliCommand } = await import('./bridge'); + + await handleBridgeCliCommand({ + args: ['bridge', 'telegram', 'set', '--bot-token', 'bot-token-1', '--require-topics', 'true'], + rawArgv: [], + terminalRuntime: null, + }); + + expect(upsertKvConfigMock).toHaveBeenCalledTimes(1); + expect(updateSettingsMock).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + expect(errorSpy).toHaveBeenCalledWith(expect.anything(), expect.stringContaining('KV unavailable')); + } finally { + errorSpy.mockRestore(); + } + }); + it('does not write secrets-only updates to server KV', async () => { const { handleBridgeCliCommand } = await import('./bridge'); @@ -132,10 +154,32 @@ describe('happier bridge command', () => { serverId: configuration.activeServerId, }); expect(updateSettingsMock).toHaveBeenCalledTimes(1); - expect(updateSettingsMock.mock.invocationCallOrder[0]).toBeLessThan(clearKvConfigMock.mock.invocationCallOrder[0]); + expect(clearKvConfigMock.mock.invocationCallOrder[0]).toBeLessThan(updateSettingsMock.mock.invocationCallOrder[0]); expect(process.exitCode).toBeUndefined(); }); + it('does not update local settings when shared KV clear fails', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + clearKvConfigMock.mockRejectedValueOnce(new Error('KV clear unavailable')); + + try { + const { handleBridgeCliCommand } = await import('./bridge'); + + await handleBridgeCliCommand({ + args: ['bridge', 'telegram', 'clear'], + rawArgv: [], + terminalRuntime: null, + }); + + expect(clearKvConfigMock).toHaveBeenCalledTimes(1); + expect(updateSettingsMock).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + expect(errorSpy).toHaveBeenCalledWith(expect.anything(), expect.stringContaining('KV clear unavailable')); + } finally { + errorSpy.mockRestore(); + } + }); + it('rejects webhook secrets that do not match Telegram-safe token charset', async () => { const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); try { @@ -159,4 +203,28 @@ describe('happier bridge command', () => { errorSpy.mockRestore(); } }); + + it('rejects explicitly passed empty --bot-token values', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + try { + const { handleBridgeCliCommand } = await import('./bridge'); + + await handleBridgeCliCommand({ + args: ['bridge', 'telegram', 'set', '--bot-token'], + rawArgv: [], + terminalRuntime: null, + }); + + expect(updateSettingsMock).not.toHaveBeenCalled(); + expect(createKvClientMock).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + expect(errorSpy).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining('Invalid --bot-token value: cannot be empty'), + ); + } finally { + errorSpy.mockRestore(); + } + }); }); diff --git a/apps/cli/src/cli/commands/bridge.ts b/apps/cli/src/cli/commands/bridge.ts index a1a3bf4f2c..d9c4afa20a 100644 --- a/apps/cli/src/cli/commands/bridge.ts +++ b/apps/cli/src/cli/commands/bridge.ts @@ -197,7 +197,9 @@ async function cmdTelegramSet(args: string[]): Promise { const auth = await resolveActiveAuthContext(); const accountId = auth.accountId; - const botToken = argvValue(args, '--bot-token').trim(); + const rawBotToken = argvValue(args, '--bot-token'); + const hasBotTokenFlag = args.some((arg) => arg === '--bot-token' || arg.startsWith('--bot-token=')); + const botToken = rawBotToken.trim(); const allowedChatIdsRaw = argvValue(args, '--allowed-chat-ids').trim(); const allowAll = args.includes('--allow-all'); const requireTopicsRaw = argvValue(args, '--require-topics').trim(); @@ -222,7 +224,12 @@ async function cmdTelegramSet(args: string[]): Promise { webhookPort?: number; } = {}; - if (botToken) update.botToken = botToken; + if (hasBotTokenFlag) { + if (!botToken) { + throw new Error('Invalid --bot-token value: cannot be empty'); + } + update.botToken = botToken; + } if (allowAll) { update.allowedChatIds = []; } else if (allowedChatIdsRaw) { @@ -256,15 +263,6 @@ async function cmdTelegramSet(args: string[]): Promise { const split = splitScopedTelegramBridgeUpdate({ update }); - await updateSettings(async (current) => - upsertScopedTelegramBridgeConfig({ - settings: current, - serverId, - accountId, - update: split.localUpdate, - }), - ); - if (hasSharedTelegramBridgeUpdate({ update: split.sharedUpdate })) { const kv = createAxiosChannelBridgeKvClient({ token: auth.token }); await upsertChannelBridgeTelegramConfigInKv({ @@ -274,6 +272,15 @@ async function cmdTelegramSet(args: string[]): Promise { }); } + await updateSettings(async (current) => + upsertScopedTelegramBridgeConfig({ + settings: current, + serverId, + accountId, + update: split.localUpdate, + }), + ); + console.log(chalk.green('✓ Saved Telegram bridge config for active account scope')); console.log(` Server: ${serverId}`); console.log(` Account: ${accountId}`); @@ -294,6 +301,12 @@ async function cmdTelegramClear(): Promise { const auth = await resolveActiveAuthContext(); const accountId = auth.accountId; + const kv = createAxiosChannelBridgeKvClient({ token: auth.token }); + await clearChannelBridgeTelegramConfigInKv({ + kv, + serverId, + }); + await updateSettings(async (current) => removeScopedTelegramBridgeConfig({ settings: current, @@ -302,12 +315,6 @@ async function cmdTelegramClear(): Promise { }), ); - const kv = createAxiosChannelBridgeKvClient({ token: auth.token }); - await clearChannelBridgeTelegramConfigInKv({ - kv, - serverId, - }); - console.log(chalk.green('✓ Cleared Telegram bridge config for active account scope')); console.log(` Server: ${serverId}`); console.log(` Account: ${accountId}`); diff --git a/apps/cli/src/cli/dispatch.tmuxDisallowed.test.ts b/apps/cli/src/cli/dispatch.tmuxDisallowed.test.ts index bd16eccfbd..865459b0fa 100644 --- a/apps/cli/src/cli/dispatch.tmuxDisallowed.test.ts +++ b/apps/cli/src/cli/dispatch.tmuxDisallowed.test.ts @@ -55,4 +55,15 @@ describe('dispatchCli --tmux disallowed controller commands', () => { expect(exitSpy).toHaveBeenCalledWith(1); expect(startHappyHeadlessInTmux).not.toHaveBeenCalled(); }); + + it('rejects --tmux-first invocations for controller commands', async () => { + await expect( + dispatchCli({ + args: ['--tmux', 'bridge', 'list'], + rawArgv: ['happier', '--tmux', 'bridge', 'list'], + terminalRuntime: null, + }), + ).rejects.toThrow('process.exit(1)'); + expect(startHappyHeadlessInTmux).not.toHaveBeenCalled(); + }); }); diff --git a/apps/cli/src/cli/dispatch.ts b/apps/cli/src/cli/dispatch.ts index 1aa88ec5fe..4015d0e90d 100644 --- a/apps/cli/src/cli/dispatch.ts +++ b/apps/cli/src/cli/dispatch.ts @@ -62,12 +62,15 @@ export async function dispatchCli(params: Readonly<{ // Headless tmux launcher (CLI flow) if (args.includes('--tmux')) { + const argsWithoutTmux = args.filter((arg) => arg !== '--tmux'); + const tmuxSubcommand = argsWithoutTmux.find((arg) => !arg.startsWith('-')) ?? ''; + // If user is asking for help/version, don't start a session. if (args.includes('-h') || args.includes('--help') || args.includes('-v') || args.includes('--version')) { const idx = args.indexOf('--tmux'); if (idx !== -1) args.splice(idx, 1); } else { - if (subcommand && !isTmuxAllowedCommand(subcommand)) { + if (tmuxSubcommand && !isTmuxAllowedCommand(tmuxSubcommand)) { console.error(chalk.red('Error:'), '--tmux can only be used when starting a session.'); process.exit(1); return; diff --git a/apps/cli/src/daemon/startDaemon.automation.integration.test.ts b/apps/cli/src/daemon/startDaemon.automation.integration.test.ts index 1e2e965522..80c5e07d5d 100644 --- a/apps/cli/src/daemon/startDaemon.automation.integration.test.ts +++ b/apps/cli/src/daemon/startDaemon.automation.integration.test.ts @@ -327,6 +327,7 @@ vi.mock('./shutdownPolicy', () => ({ describe('startDaemon automation wiring (integration)', () => { afterEach(() => { + vi.clearAllMocks(); vi.restoreAllMocks(); harness.setAutoShutdownAfterAutomationStart(true); }); diff --git a/apps/cli/src/daemon/startDaemon.ts b/apps/cli/src/daemon/startDaemon.ts index 5de9032e2f..50fb7643e3 100644 --- a/apps/cli/src/daemon/startDaemon.ts +++ b/apps/cli/src/daemon/startDaemon.ts @@ -1891,11 +1891,6 @@ export async function startDaemon(): Promise { }), ]); - if (channelBridgeStopTimeoutHandle) { - clearTimeout(channelBridgeStopTimeoutHandle); - channelBridgeStopTimeoutHandle = null; - } - if (stopResult === 'timeout') { logger.warn( `[DAEMON RUN] Channel bridge worker stop timed out after ${channelBridgeStopTimeoutMs}ms; continuing shutdown`, @@ -1906,6 +1901,11 @@ export async function startDaemon(): Promise { '[DAEMON RUN] Failed to stop channel bridge worker during shutdown (best-effort)', serializeAxiosErrorForLog(error), ); + } finally { + if (channelBridgeStopTimeoutHandle) { + clearTimeout(channelBridgeStopTimeoutHandle); + channelBridgeStopTimeoutHandle = null; + } } } diff --git a/apps/cli/src/persistence.ts b/apps/cli/src/persistence.ts index 43a3885862..c5dc9b99b3 100644 --- a/apps/cli/src/persistence.ts +++ b/apps/cli/src/persistence.ts @@ -38,6 +38,13 @@ function bestEffortChmodSync(path: string, mode: number): void { // Incremented when Settings structure changes. export const SUPPORTED_SCHEMA_VERSION = 6; +export interface ChannelBridgeSettings { + tickMs?: number + providers?: Record + byServerId?: Record + [key: string]: unknown +} + export interface Settings { // Schema version for backwards compatibility schemaVersion: number @@ -112,7 +119,7 @@ export interface Settings { /** * Channel bridge configuration (account/server scoped + optional global defaults). */ - channelBridge?: unknown + channelBridge?: ChannelBridgeSettings } const defaultSettings: Settings = { diff --git a/apps/cli/src/ui/doctor.test.ts b/apps/cli/src/ui/doctor.test.ts index 1fa9a0cb06..9342771e04 100644 --- a/apps/cli/src/ui/doctor.test.ts +++ b/apps/cli/src/ui/doctor.test.ts @@ -3,6 +3,7 @@ import { collectMissingRequiredWebhookFields, resolveTelegramWebhookValidationInputs, isMissingRequiredTelegramWebhookSecret, + parseStrictWebhookPort, maskValue, redactSettingsForDisplay, redactDaemonStateForDisplay, @@ -104,6 +105,59 @@ describe('doctor redaction', () => { expect(telegram.webhook.host).toBe('127.0.0.1'); expect(telegram.webhook.port).toBe(8787); }); + + it('redacts global and server-scoped channel bridge provider secrets', () => { + const input = { + channelBridge: { + providers: { + telegram: { + botToken: 'global-bot-token', + webhook: { + secret: 'global-webhook-secret', + }, + secrets: { + botToken: 'global-bot-token', + }, + }, + }, + byServerId: { + 'local-3005': { + providers: { + telegram: { + botToken: 'server-bot-token', + webhook: { + secret: 'server-webhook-secret', + }, + secrets: { + webhookSecret: 'server-webhook-secret', + }, + }, + }, + }, + }, + }, + }; + + const redacted = redactSettingsForDisplay(input as never) as any; + expect(redacted.channelBridge.providers.telegram.botToken).toBe(''); + expect(redacted.channelBridge.providers.telegram.webhook.secret).toBe(''); + expect(redacted.channelBridge.providers.telegram.secrets.botToken).toBe(''); + expect(redacted.channelBridge.byServerId['local-3005'].providers.telegram.botToken).toBe(''); + expect(redacted.channelBridge.byServerId['local-3005'].providers.telegram.webhook.secret).toBe(''); + expect(redacted.channelBridge.byServerId['local-3005'].providers.telegram.secrets.webhookSecret).toBe(''); + }); +}); + +describe('parseStrictWebhookPort', () => { + it('rejects negative string values', () => { + expect(parseStrictWebhookPort('-1')).toBeNull(); + expect(parseStrictWebhookPort(' -8787 ')).toBeNull(); + }); + + it('accepts non-negative integer values', () => { + expect(parseStrictWebhookPort('0')).toBe(0); + expect(parseStrictWebhookPort('8787')).toBe(8787); + }); }); describe('doctor process inventory visibility', () => { diff --git a/apps/cli/src/ui/doctor.ts b/apps/cli/src/ui/doctor.ts index 579dfcc0ef..ed6a2ef154 100644 --- a/apps/cli/src/ui/doctor.ts +++ b/apps/cli/src/ui/doctor.ts @@ -85,19 +85,50 @@ function asRecord(value: unknown): Record | null { return value as Record; } -function parseStrictWebhookPort(value: unknown): number | null { +export function parseStrictWebhookPort(value: unknown): number | null { if (typeof value === 'number' && Number.isFinite(value)) { - return Math.trunc(value); + if (!Number.isInteger(value) || value < 0) return null; + return value; } if (typeof value === 'string') { const trimmed = value.trim(); - if (!/^[-]?\d+$/.test(trimmed)) return null; + if (!/^\d+$/.test(trimmed)) return null; const parsed = Number.parseInt(trimmed, 10); - return Number.isFinite(parsed) ? parsed : null; + if (!Number.isFinite(parsed) || parsed < 0) return null; + return parsed; } return null; } +function redactProviderSecrets(providerRecord: Record): void { + const secrets = asRecord(providerRecord.secrets); + if (secrets) { + for (const [key, value] of Object.entries(secrets)) { + if (typeof value === 'string' && value.trim().length > 0) { + secrets[key] = ''; + } + } + } + + if (typeof providerRecord.botToken === 'string' && providerRecord.botToken.trim().length > 0) { + providerRecord.botToken = ''; + } + + const webhook = asRecord(providerRecord.webhook); + if (webhook && typeof webhook.secret === 'string' && webhook.secret.trim().length > 0) { + webhook.secret = ''; + } +} + +function redactProvidersMap(providers: Record | null): void { + if (!providers) return; + for (const providerScope of Object.values(providers)) { + const providerRecord = asRecord(providerScope); + if (!providerRecord) continue; + redactProviderSecrets(providerRecord); + } +} + export function resolveTelegramWebhookValidationInputs(params: Readonly<{ runtimeWebhookHost: string; runtimeWebhookPort: number; @@ -132,41 +163,29 @@ export function redactSettingsForDisplay(settings: SettingsForDisplay): Settings } const channelBridge = asRecord(redactedRecord.channelBridge); - const byServerId = asRecord(channelBridge?.byServerId); - if (byServerId) { - for (const serverScope of Object.values(byServerId)) { - const serverRecord = asRecord(serverScope); - const byAccountId = asRecord(serverRecord?.byAccountId); - if (!byAccountId) continue; - - for (const accountScope of Object.values(byAccountId)) { - const accountRecord = asRecord(accountScope); - const providers = asRecord(accountRecord?.providers); - if (!providers) continue; - - for (const providerScope of Object.values(providers)) { - const providerRecord = asRecord(providerScope); - if (!providerRecord) continue; - - const secrets = asRecord(providerRecord.secrets); - if (secrets) { - for (const [key, value] of Object.entries(secrets)) { - if (typeof value === 'string' && value.trim().length > 0) { - secrets[key] = ''; - } - } - } + if (!channelBridge) { + return redacted; + } - if (typeof providerRecord.botToken === 'string' && providerRecord.botToken.trim().length > 0) { - providerRecord.botToken = ''; - } + redactProvidersMap(asRecord(channelBridge.providers)); - const webhook = asRecord(providerRecord.webhook); - if (webhook && typeof webhook.secret === 'string' && webhook.secret.trim().length > 0) { - webhook.secret = ''; - } - } - } + const byServerId = asRecord(channelBridge.byServerId); + if (!byServerId) { + return redacted; + } + + for (const serverScope of Object.values(byServerId)) { + const serverRecord = asRecord(serverScope); + if (!serverRecord) continue; + + redactProvidersMap(asRecord(serverRecord.providers)); + + const byAccountId = asRecord(serverRecord.byAccountId); + if (!byAccountId) continue; + + for (const accountScope of Object.values(byAccountId)) { + const accountRecord = asRecord(accountScope); + redactProvidersMap(asRecord(accountRecord?.providers)); } } diff --git a/docs/README.md b/docs/README.md index 8a227bebd6..aed1c226db 100644 --- a/docs/README.md +++ b/docs/README.md @@ -15,6 +15,7 @@ This folder documents how Happier works internally, with a focus on protocol, ba - pi-feature-matrix.md: Low-level PI implementation matrix and unified-architecture migration notes. - acp-provider-feature-matrix.md: Low-level ACP-provider matrix and catalog migration notes. - channel-bridge.md: Provider-agnostic bridge architecture, ingress mode matrix, and relay deployment model. +- channel-bridge-uat.md: End-to-end UAT checklist for bridge behavior across account/machine scopes. - telegram-channel-bridge.md: Telegram adapter setup, commands, and channel-specific bridge usage. - issue-triage.md: How the GitHub issue triage workflows are wired to maintainer tooling. diff --git a/docs/channel-bridge-uat.md b/docs/channel-bridge-uat.md index b5dcc613a0..830df50325 100644 --- a/docs/channel-bridge-uat.md +++ b/docs/channel-bridge-uat.md @@ -82,7 +82,9 @@ Expected: - Set `allowedChatIds` to a different chat and verify current chat is blocked. - Revert to `--allow-all` and verify chat works again. -- If using webhook mode, verify secret mismatch returns a non-200 response (typically 404 with path-token mismatch). +- If using webhook mode, verify secret mismatch returns a non-200 response: + - `404` when URL path token is wrong (route mismatch) + - `401` when `X-Telegram-Bot-Api-Secret-Token` is missing/invalid ## Notes diff --git a/docs/channel-bridge.md b/docs/channel-bridge.md index c7cce7e03c..fe302bc3a0 100644 --- a/docs/channel-bridge.md +++ b/docs/channel-bridge.md @@ -50,8 +50,8 @@ Adapters plug into the core using a small interface: This keeps command semantics, binding behavior, and session forwarding logic centralized in the core. -## Out of scope for this PR +## Scope notes This document covers core bridge runtime behavior only. -Provider-specific transport details, bridge runtime bootstrapping, scoped config sources, and server-relay deployment modes are covered in follow-up stacked PRs. +Provider-specific transport details and adapter-specific setup are documented in companion docs (for example, `docs/telegram-channel-bridge.md`). diff --git a/docs/telegram-channel-bridge.md b/docs/telegram-channel-bridge.md index 00616e37e9..4378edc23c 100644 --- a/docs/telegram-channel-bridge.md +++ b/docs/telegram-channel-bridge.md @@ -8,7 +8,7 @@ This repository now includes a **built-in channel bridge core** plus a **Telegra The design is intentionally modular so additional adapters (Discord, Slack, WhatsApp, etc.) can plug into the same core contract. -For core bridge architecture, ingress mode matrix, and relay deployment model, see `docs/channel-bridge.md`. +For core bridge architecture, ingress mode matrix, and relay deployment model, see [docs/channel-bridge.md](./channel-bridge.md). ## Current Behavior @@ -220,7 +220,7 @@ If you switch back to polling mode, clear webhook first: curl "https://api.telegram.org/bot/deleteWebhook" ``` -For server-relay and standalone-relay planning details, see `docs/channel-bridge.md`. +For server-relay and standalone-relay planning details, see [docs/channel-bridge.md](./channel-bridge.md). ## Telegram Commands (inside DM/topic) From c3426ef995f262f53017a2d07c49b8eccadd6b05 Mon Sep 17 00:00:00 2001 From: Chris Denneen Date: Fri, 6 Mar 2026 13:19:52 -0500 Subject: [PATCH 04/81] Decouple bridge bootstrap from machine registration --- apps/cli/src/daemon/startDaemon.ts | 78 ++++++++++++++++-------------- 1 file changed, 43 insertions(+), 35 deletions(-) diff --git a/apps/cli/src/daemon/startDaemon.ts b/apps/cli/src/daemon/startDaemon.ts index 50fb7643e3..bd22bb19da 100644 --- a/apps/cli/src/daemon/startDaemon.ts +++ b/apps/cli/src/daemon/startDaemon.ts @@ -1527,45 +1527,49 @@ export async function startDaemon(): Promise { // Do machine bootstrap in the background so shutdown requests are not blocked by /v1/machines latency. void (async () => { - const bridgeSettings = await readSettings().catch((error) => { - logger.warn( - '[DAEMON RUN] Failed to read settings for channel bridge startup; using env-only defaults', - error instanceof Error ? error.message : String(error), - ); - return null; - }); - - const tokenPayload = decodeJwtPayload(credentials.token); - const channelBridgeAccountId = - tokenPayload && typeof tokenPayload.sub === 'string' - ? tokenPayload.sub.trim() - : null; - const channelBridgeServerId = (configuration.activeServerId ?? '').trim() || null; - - let channelBridgeRuntimeSettings: unknown = bridgeSettings; - if (channelBridgeServerId && channelBridgeAccountId) { - try { - const kv = createAxiosChannelBridgeKvClient({ token: credentials.token }); - const fetched = await readChannelBridgeTelegramConfigFromKv({ - kv, - serverId: channelBridgeServerId, - allowUnsupportedSchema: true, - }); - channelBridgeRuntimeSettings = overlayServerKvTelegramConfigInSettings({ - settings: bridgeSettings, - serverId: channelBridgeServerId, - accountId: channelBridgeAccountId, - record: fetched.record, - }); - } catch (error) { + const startChannelBridgeWorkerBestEffort = async (): Promise => { + const bridgeSettings = await readSettings().catch((error) => { logger.warn( - '[DAEMON RUN] Failed to read channel bridge config from server KV; using local/env configuration', - serializeAxiosErrorForLog(error), + '[DAEMON RUN] Failed to read settings for channel bridge startup; using env-only defaults', + error instanceof Error ? error.message : String(error), ); + return null; + }); + + const tokenPayload = decodeJwtPayload(credentials.token); + const channelBridgeAccountId = + tokenPayload && typeof tokenPayload.sub === 'string' + ? tokenPayload.sub.trim() + : null; + const channelBridgeServerId = (configuration.activeServerId ?? '').trim() || null; + + let channelBridgeRuntimeSettings: unknown = bridgeSettings; + if (channelBridgeServerId && channelBridgeAccountId) { + try { + const kv = createAxiosChannelBridgeKvClient({ token: credentials.token }); + const fetched = await readChannelBridgeTelegramConfigFromKv({ + kv, + serverId: channelBridgeServerId, + allowUnsupportedSchema: true, + }); + channelBridgeRuntimeSettings = overlayServerKvTelegramConfigInSettings({ + settings: bridgeSettings, + serverId: channelBridgeServerId, + accountId: channelBridgeAccountId, + record: fetched.record, + }); + } catch (error) { + logger.warn( + '[DAEMON RUN] Failed to read channel bridge config from server KV; using local/env configuration', + serializeAxiosErrorForLog(error), + ); + } + } + + if (shutdownInitiated) { + return; } - } - if (!shutdownInitiated) { channelBridgeWorker = await startChannelBridgeFromEnv({ credentials, ...(channelBridgeRuntimeSettings ? { settings: channelBridgeRuntimeSettings } : {}), @@ -1579,6 +1583,10 @@ export async function startDaemon(): Promise { return null; }); } + }; + + void startChannelBridgeWorkerBestEffort(); + let attempts = 0; while (!shutdownInitiated) { try { From 7b9ef946b86db1e911d08451ec38201a3a450318 Mon Sep 17 00:00:00 2001 From: Chris Denneen Date: Fri, 6 Mar 2026 13:35:49 -0500 Subject: [PATCH 05/81] fix bridge hardening edge cases from PR review --- .../src/channels/core/channelBridgeWorker.ts | 6 +++++- .../channels/telegram/telegramAdapter.test.ts | 18 +++++++++++++++++- .../src/channels/telegram/telegramAdapter.ts | 10 ++++++++++ .../channels/telegram/telegramWebhookRelay.ts | 10 ++++++++-- 4 files changed, 40 insertions(+), 4 deletions(-) diff --git a/apps/cli/src/channels/core/channelBridgeWorker.ts b/apps/cli/src/channels/core/channelBridgeWorker.ts index 48b4cdb17f..d1df45107d 100644 --- a/apps/cli/src/channels/core/channelBridgeWorker.ts +++ b/apps/cli/src/channels/core/channelBridgeWorker.ts @@ -155,8 +155,12 @@ function resolveExternalIoTimeoutMs(): number { return DEFAULT_EXTERNAL_IO_TIMEOUT_MS; } + if (!/^\d+$/.test(raw)) { + return DEFAULT_EXTERNAL_IO_TIMEOUT_MS; + } + const parsed = Number.parseInt(raw, 10); - if (!Number.isFinite(parsed) || parsed <= 0) { + if (!Number.isSafeInteger(parsed) || parsed <= 0) { return DEFAULT_EXTERNAL_IO_TIMEOUT_MS; } diff --git a/apps/cli/src/channels/telegram/telegramAdapter.test.ts b/apps/cli/src/channels/telegram/telegramAdapter.test.ts index b470770335..253f99cb4f 100644 --- a/apps/cli/src/channels/telegram/telegramAdapter.test.ts +++ b/apps/cli/src/channels/telegram/telegramAdapter.test.ts @@ -1,4 +1,14 @@ -import { describe, expect, it, vi } from 'vitest'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const { loggerWarn } = vi.hoisted(() => ({ + loggerWarn: vi.fn(), +})); + +vi.mock('@/ui/logger', () => ({ + logger: { + warn: loggerWarn, + }, +})); import { createTelegramChannelAdapter } from './telegramAdapter'; @@ -13,6 +23,10 @@ function createDeferredPromise() { } describe('createTelegramChannelAdapter', () => { + beforeEach(() => { + loggerWarn.mockReset(); + }); + it('parses inbound topic updates and ignores self-authored bot messages', async () => { const api = { getMe: vi.fn(async () => ({ id: 777, username: 'happier_bot' })), @@ -190,6 +204,8 @@ describe('createTelegramChannelAdapter', () => { expect(inbound).toHaveLength(2_000); expect(inbound[0]?.messageId).toBe('100'); expect(inbound[1_999]?.messageId).toBe('2099'); + expect(loggerWarn).toHaveBeenCalledTimes(1); + expect(loggerWarn.mock.calls[0]?.[0]).toContain('dropped 100 oldest update(s)'); }); it('does not drop updates enqueued while webhook parsing is in flight', async () => { diff --git a/apps/cli/src/channels/telegram/telegramAdapter.ts b/apps/cli/src/channels/telegram/telegramAdapter.ts index 0866ed2cc9..07e3c23d6f 100644 --- a/apps/cli/src/channels/telegram/telegramAdapter.ts +++ b/apps/cli/src/channels/telegram/telegramAdapter.ts @@ -1,6 +1,7 @@ import axios from 'axios'; import type { ChannelBridgeAdapter, ChannelBridgeInboundMessage } from '@/channels/core/channelBridgeWorker'; +import { logger } from '@/ui/logger'; type TelegramSelfUser = Readonly<{ id: number; username: string | null }>; @@ -186,6 +187,7 @@ export function createTelegramChannelAdapter(params: Readonly<{ let updateOffset: number | null = null; const queuedWebhookUpdates: QueuedWebhookUpdate[] = []; let nextQueuedWebhookId = 1; + let droppedWebhookUpdates = 0; async function ensureSelfIdentity(): Promise { if (selfBotId !== null) return; @@ -213,6 +215,7 @@ export function createTelegramChannelAdapter(params: Readonly<{ enqueueWebhookUpdate: (update: unknown) => { if (queuedWebhookUpdates.length >= MAX_WEBHOOK_QUEUE_SIZE) { queuedWebhookUpdates.shift(); + droppedWebhookUpdates += 1; } queuedWebhookUpdates.push({ id: nextQueuedWebhookId, @@ -222,6 +225,13 @@ export function createTelegramChannelAdapter(params: Readonly<{ }, pullInboundMessages: async () => { if (webhookMode) { + if (droppedWebhookUpdates > 0) { + logger.warn( + `[channelBridge] Telegram webhook queue overflow: dropped ${droppedWebhookUpdates} oldest update(s)`, + ); + droppedWebhookUpdates = 0; + } + const snapshot = queuedWebhookUpdates.slice(); const parsed = await parseUpdates(snapshot.map((row) => row.update)); const consumedIds = new Set(snapshot.map((row) => row.id)); diff --git a/apps/cli/src/channels/telegram/telegramWebhookRelay.ts b/apps/cli/src/channels/telegram/telegramWebhookRelay.ts index 960f67b3cf..e14e96e0cc 100644 --- a/apps/cli/src/channels/telegram/telegramWebhookRelay.ts +++ b/apps/cli/src/channels/telegram/telegramWebhookRelay.ts @@ -6,8 +6,14 @@ import fastify from 'fastify'; function secureCompareToken(providedToken: string, expectedToken: string): boolean { const providedBytes = Buffer.from(providedToken, 'utf8'); const expectedBytes = Buffer.from(expectedToken, 'utf8'); - if (providedBytes.length !== expectedBytes.length) return false; - return timingSafeEqual(providedBytes, expectedBytes); + const length = Math.max(1, providedBytes.length, expectedBytes.length); + const providedPadded = Buffer.alloc(length); + const expectedPadded = Buffer.alloc(length); + providedBytes.copy(providedPadded); + expectedBytes.copy(expectedPadded); + + const matches = timingSafeEqual(providedPadded, expectedPadded); + return matches && providedBytes.length === expectedBytes.length; } export type TelegramWebhookRelayHandle = Readonly<{ From 9e46e587ab96c1e0bbdac4e0892ef1fc05c19769 Mon Sep 17 00:00:00 2001 From: Chris Denneen Date: Fri, 6 Mar 2026 14:09:25 -0500 Subject: [PATCH 06/81] address latest PR115 bot findings across bridge runtime --- .../src/channels/channelBridgeConfig.test.ts | 36 +++++ apps/cli/src/channels/channelBridgeConfig.ts | 17 +- .../channels/channelBridgeServerKv.test.ts | 59 +++++++ .../cli/src/channels/channelBridgeServerKv.ts | 51 +++--- .../channels/core/channelBridgeWorker.test.ts | 42 +++++ .../src/channels/core/channelBridgeWorker.ts | 29 ++++ .../channels/telegram/telegramAdapter.test.ts | 35 +++++ .../src/channels/telegram/telegramAdapter.ts | 147 ++++++++++++++++-- .../channels/telegram/telegramWebhookRelay.ts | 2 +- apps/cli/src/cli/commands/bridge.test.ts | 48 ++++++ apps/cli/src/cli/commands/bridge.ts | 10 +- .../src/cli/dispatch.tmuxDisallowed.test.ts | 22 +++ ...startDaemon.automation.integration.test.ts | 34 ++++ apps/cli/src/daemon/startDaemon.ts | 19 ++- docs/channel-bridge-uat.md | 7 + 15 files changed, 516 insertions(+), 42 deletions(-) diff --git a/apps/cli/src/channels/channelBridgeConfig.test.ts b/apps/cli/src/channels/channelBridgeConfig.test.ts index 5e3e13902c..7f350aa90c 100644 --- a/apps/cli/src/channels/channelBridgeConfig.test.ts +++ b/apps/cli/src/channels/channelBridgeConfig.test.ts @@ -228,4 +228,40 @@ describe('resolveChannelBridgeRuntimeConfig', () => { expect(config.telegram.allowedChatIds).toEqual([]); expect(config.telegram.requireTopics).toBe(true); }); + + it('keeps settings allowedChatIds when env override is empty', () => { + const config = resolveChannelBridgeRuntimeConfig({ + env: { + HAPPIER_TELEGRAM_ALLOWED_CHAT_IDS: ' ', + }, + settings: { + channelBridge: { + providers: { + telegram: { + allowedChatIds: ['-100111'], + }, + }, + }, + }, + }); + + expect(config.telegram.allowedChatIds).toEqual(['-100111']); + }); + + it('accepts numeric allowedChatIds from settings arrays', () => { + const config = resolveChannelBridgeRuntimeConfig({ + env: {}, + settings: { + channelBridge: { + providers: { + telegram: { + allowedChatIds: [-1001234567890], + }, + }, + }, + }, + }); + + expect(config.telegram.allowedChatIds).toEqual(['-1001234567890']); + }); }); diff --git a/apps/cli/src/channels/channelBridgeConfig.ts b/apps/cli/src/channels/channelBridgeConfig.ts index 671e52d235..d7f5348ee5 100644 --- a/apps/cli/src/channels/channelBridgeConfig.ts +++ b/apps/cli/src/channels/channelBridgeConfig.ts @@ -46,8 +46,15 @@ function parseStringArray(value: unknown): string[] | null { if (typeof value === 'string') return parseCsv(value); if (!Array.isArray(value)) return null; const out = value - .map((entry) => (typeof entry === 'string' ? entry.trim() : '')) + .map((entry) => { + if (typeof entry === 'string') return entry.trim(); + if (typeof entry === 'number' && Number.isFinite(entry)) return String(Math.trunc(entry)); + return ''; + }) .filter((entry) => entry.length > 0); + if (out.length === 0 && value.length > 0) { + return null; + } return out; } @@ -146,9 +153,13 @@ export function resolveChannelBridgeRuntimeConfig(params: Readonly<{ : settingsBotToken; const settingsAllowedChatIds = parseStringArray(telegram?.allowedChatIds) ?? []; - const allowedChatIds = + const envAllowedChatIdsRaw = typeof env.HAPPIER_TELEGRAM_ALLOWED_CHAT_IDS === 'string' - ? parseCsv(env.HAPPIER_TELEGRAM_ALLOWED_CHAT_IDS) + ? env.HAPPIER_TELEGRAM_ALLOWED_CHAT_IDS.trim() + : null; + const allowedChatIds = + envAllowedChatIdsRaw && envAllowedChatIdsRaw.length > 0 + ? parseCsv(envAllowedChatIdsRaw) : settingsAllowedChatIds; const settingsRequireTopics = parseBoolean(telegram?.requireTopics) ?? false; diff --git a/apps/cli/src/channels/channelBridgeServerKv.test.ts b/apps/cli/src/channels/channelBridgeServerKv.test.ts index 9fe16832ec..a3db9f9699 100644 --- a/apps/cli/src/channels/channelBridgeServerKv.test.ts +++ b/apps/cli/src/channels/channelBridgeServerKv.test.ts @@ -529,6 +529,38 @@ describe('channelBridgeServerKv', () => { })).rejects.toThrow('Invalid channel bridge binding lastForwardedSeq at index 0'); }); + it('throws when bindings payload omits required persisted cursor/timestamp fields', async () => { + const malformedValueBase64 = Buffer.from(JSON.stringify({ + schemaVersion: 1, + bindings: [ + { + providerId: 'telegram', + conversationId: '-100111', + sessionId: 'sess-1', + lastForwardedSeq: 3, + createdAtMs: 111, + }, + ], + }), 'utf8').toString('base64'); + + const kv: ChannelBridgeKvClient = { + get: async () => ({ + status: 200, + body: { + key: 'k', + value: malformedValueBase64, + version: 5, + }, + }), + mutate: async () => ({ status: 200, body: { success: true, results: [] } }), + }; + + await expect(readChannelBridgeBindingsFromKv({ + kv, + serverId: 'local-3005', + })).rejects.toThrow('Invalid channel bridge binding updatedAtMs at index 0'); + }); + it('throws when bindings payload includes malformed thread ids', async () => { const malformedValueBase64 = Buffer.from(JSON.stringify({ schemaVersion: 1, @@ -537,6 +569,9 @@ describe('channelBridgeServerKv', () => { providerId: 'telegram', conversationId: '-100111', sessionId: 'sess-1', + lastForwardedSeq: 1, + createdAtMs: 100, + updatedAtMs: 101, threadId: 123, }, ], @@ -618,4 +653,28 @@ describe('channelBridgeServerKv', () => { serverId: 'local-3005', })).rejects.toThrow('Invalid or unsupported Telegram config schema'); }); + + it('treats malformed telegram config payload as recoverable when allowUnsupportedSchema=true', async () => { + const malformedValueBase64 = Buffer.from('not-json', 'utf8').toString('base64'); + const kv: ChannelBridgeKvClient = { + get: async () => ({ + status: 200, + body: { + key: 'k', + value: malformedValueBase64, + version: 3, + }, + }), + mutate: async () => ({ status: 200, body: { success: true, results: [] } }), + }; + + await expect(readChannelBridgeTelegramConfigFromKv({ + kv, + serverId: 'local-3005', + allowUnsupportedSchema: true, + })).resolves.toEqual({ + record: null, + version: 3, + }); + }); }); diff --git a/apps/cli/src/channels/channelBridgeServerKv.ts b/apps/cli/src/channels/channelBridgeServerKv.ts index c35dbee84d..05397d76ec 100644 --- a/apps/cli/src/channels/channelBridgeServerKv.ts +++ b/apps/cli/src/channels/channelBridgeServerKv.ts @@ -93,8 +93,16 @@ function encodeJsonToBase64(value: unknown): string { } function decodeBase64ToJson(valueBase64: string): unknown { - const raw = Buffer.from(valueBase64, 'base64').toString('utf8'); - return JSON.parse(raw); + try { + const raw = Buffer.from(valueBase64, 'base64').toString('utf8'); + return JSON.parse(raw); + } catch (error) { + throw new ChannelBridgeBadPayloadError( + error instanceof Error + ? `Invalid channel bridge KV payload: ${error.message}` + : 'Invalid channel bridge KV payload', + ); + } } function asRecord(value: unknown): Record | null { @@ -227,21 +235,16 @@ function parseBindingsDocument(value: unknown): ChannelBridgeServerBindingsDocum } if ( - item.lastForwardedSeq !== undefined - && (typeof item.lastForwardedSeq !== 'number' || !Number.isFinite(item.lastForwardedSeq)) + typeof item.lastForwardedSeq !== 'number' + || !Number.isFinite(item.lastForwardedSeq) + || item.lastForwardedSeq < 0 ) { throw new ChannelBridgeBadPayloadError(`Invalid channel bridge binding lastForwardedSeq at index ${index}`); } - if ( - item.createdAtMs !== undefined - && (typeof item.createdAtMs !== 'number' || !Number.isFinite(item.createdAtMs)) - ) { + if (typeof item.createdAtMs !== 'number' || !Number.isFinite(item.createdAtMs)) { throw new ChannelBridgeBadPayloadError(`Invalid channel bridge binding createdAtMs at index ${index}`); } - if ( - item.updatedAtMs !== undefined - && (typeof item.updatedAtMs !== 'number' || !Number.isFinite(item.updatedAtMs)) - ) { + if (typeof item.updatedAtMs !== 'number' || !Number.isFinite(item.updatedAtMs)) { throw new ChannelBridgeBadPayloadError(`Invalid channel bridge binding updatedAtMs at index ${index}`); } @@ -255,15 +258,9 @@ function parseBindingsDocument(value: unknown): ChannelBridgeServerBindingsDocum const threadIdRaw = typeof item.threadId === 'string' ? item.threadId.trim() : ''; const threadId = threadIdRaw.length > 0 ? threadIdRaw : null; - const lastForwardedSeq = typeof item.lastForwardedSeq === 'number' && Number.isFinite(item.lastForwardedSeq) - ? Math.max(0, Math.trunc(item.lastForwardedSeq)) - : 0; - const createdAtMs = typeof item.createdAtMs === 'number' && Number.isFinite(item.createdAtMs) - ? Math.trunc(item.createdAtMs) - : Date.now(); - const updatedAtMs = typeof item.updatedAtMs === 'number' && Number.isFinite(item.updatedAtMs) - ? Math.trunc(item.updatedAtMs) - : Date.now(); + const lastForwardedSeq = Math.trunc(item.lastForwardedSeq); + const createdAtMs = Math.trunc(item.createdAtMs); + const updatedAtMs = Math.trunc(item.updatedAtMs); bindings.push({ providerId, conversationId, @@ -412,7 +409,17 @@ export async function readChannelBridgeTelegramConfigFromKv(params: Readonly<{ return { record: null, version: row.version }; } - const parsed = parseTelegramConfigRecord(decodeBase64ToJson(row.valueBase64)); + let decoded: unknown; + try { + decoded = decodeBase64ToJson(row.valueBase64); + } catch (error) { + if (params.allowUnsupportedSchema && error instanceof ChannelBridgeBadPayloadError) { + return { record: null, version: row.version }; + } + throw error; + } + + const parsed = parseTelegramConfigRecord(decoded); if (!parsed) { if (params.allowUnsupportedSchema) { return { record: null, version: row.version }; diff --git a/apps/cli/src/channels/core/channelBridgeWorker.test.ts b/apps/cli/src/channels/core/channelBridgeWorker.test.ts index f110557f5b..85fd91d3e8 100644 --- a/apps/cli/src/channels/core/channelBridgeWorker.test.ts +++ b/apps/cli/src/channels/core/channelBridgeWorker.test.ts @@ -1721,6 +1721,48 @@ describe('executeChannelBridgeTick', () => { expect(warnings.some((row) => row.message.includes('Failed to list bindings for outbound forwarding'))).toBe(true); }); + + it('acknowledges handled inbound messages when adapter exposes ack hook', async () => { + const store = createInMemoryChannelBindingStore(); + await store.upsertBinding({ + providerId: 'telegram', + conversationId: 'room-ack', + threadId: null, + sessionId: 'sess-ack', + lastForwardedSeq: 0, + }); + + const acknowledged: ChannelBridgeInboundMessage[][] = []; + const adapter: ChannelBridgeAdapter = { + providerId: 'telegram', + pullInboundMessages: async () => [ + { + providerId: 'telegram', + conversationId: 'room-ack', + threadId: null, + senderId: 'user-1', + text: 'hello from telegram', + messageId: 'm-ack-1', + }, + ], + ackInboundMessages: async (messages) => { + acknowledged.push(messages.map((message) => ({ ...message }))); + }, + sendMessage: async () => {}, + }; + const depsHarness = createDepsHarness(); + + await executeChannelBridgeTick({ + store, + adapters: [adapter], + deps: depsHarness.deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(depsHarness.sentToSession).toHaveLength(1); + expect(acknowledged).toHaveLength(1); + expect(acknowledged[0]?.map((message) => message.messageId)).toEqual(['m-ack-1']); + }); }); describe('startChannelBridgeWorker', () => { it('runs the first tick on startup', async () => { diff --git a/apps/cli/src/channels/core/channelBridgeWorker.ts b/apps/cli/src/channels/core/channelBridgeWorker.ts index d1df45107d..bd34b7f3cc 100644 --- a/apps/cli/src/channels/core/channelBridgeWorker.ts +++ b/apps/cli/src/channels/core/channelBridgeWorker.ts @@ -51,6 +51,9 @@ export type ChannelBridgeActorContext = Readonly<{ * Expectations: * - `pullInboundMessages` should return available inbound items without throwing for * normal empty states (return `[]` instead). + * - `ackInboundMessages` is optional and, when implemented, will be called after + * the worker has fully handled a batch item (including command replies / forward attempts). + * Adapters can use this to implement deferred acknowledgment semantics. * - `sendMessage` should deliver text into a target conversation/thread. * - `sendMessage` should tolerate at-least-once delivery attempts. Timeout races may * trigger retries, so provider adapters should be idempotent when possible. @@ -59,6 +62,7 @@ export type ChannelBridgeActorContext = Readonly<{ export type ChannelBridgeAdapter = Readonly<{ providerId: string; pullInboundMessages: () => Promise; + ackInboundMessages?: (messages: readonly ChannelBridgeInboundMessage[]) => void | Promise; sendMessage: (params: Readonly<{ conversationId: string; threadId: string | null; text: string }>) => Promise; stop?: () => void | Promise; }>; @@ -627,6 +631,8 @@ export async function executeChannelBridgeTick(params: Readonly<{ continue; } + const ackableInbound: ChannelBridgeInboundMessage[] = []; + for (const rawEvent of inbound) { const event: ChannelBridgeInboundMessage = rawEvent.providerId === adapter.providerId @@ -643,9 +649,12 @@ export async function executeChannelBridgeTick(params: Readonly<{ } if (deduper.isDuplicate(event)) { + ackableInbound.push(event); continue; } + let processedSuccessfully = false; + try { const command = parseSlashCommand(event.text); if (command) { @@ -656,6 +665,7 @@ export async function executeChannelBridgeTick(params: Readonly<{ store: params.store, deps: params.deps, }); + processedSuccessfully = true; continue; } @@ -664,6 +674,7 @@ export async function executeChannelBridgeTick(params: Readonly<{ conversationId: event.conversationId, threadId: event.threadId, }, 'Unknown command. Use /help for supported commands.'); + processedSuccessfully = true; continue; } @@ -694,6 +705,7 @@ export async function executeChannelBridgeTick(params: Readonly<{ ref, 'No session is attached here. Use /attach first.', ); + processedSuccessfully = true; continue; } @@ -721,9 +733,26 @@ export async function executeChannelBridgeTick(params: Readonly<{ `Failed to send message to session ${binding.sessionId}.`, ); } + processedSuccessfully = true; } catch (error) { params.deps.onWarning?.(`Failed to process inbound message for adapter ${adapter.providerId}`, error); } + + if (processedSuccessfully) { + ackableInbound.push(event); + } + } + + if (adapter.ackInboundMessages && ackableInbound.length > 0) { + try { + await withTimeout( + Promise.resolve(adapter.ackInboundMessages(ackableInbound)), + EXTERNAL_IO_TIMEOUT_MS, + `ackInboundMessages(${adapter.providerId})`, + ); + } catch (error) { + params.deps.onWarning?.(`Failed to acknowledge inbound messages for adapter ${adapter.providerId}`, error); + } } } diff --git a/apps/cli/src/channels/telegram/telegramAdapter.test.ts b/apps/cli/src/channels/telegram/telegramAdapter.test.ts index 253f99cb4f..53d11cd432 100644 --- a/apps/cli/src/channels/telegram/telegramAdapter.test.ts +++ b/apps/cli/src/channels/telegram/telegramAdapter.test.ts @@ -132,6 +132,12 @@ describe('createTelegramChannelAdapter', () => { }, ]); expect(api.getUpdates).not.toHaveBeenCalled(); + + const pendingReplay = await adapter.pullInboundMessages(); + expect(pendingReplay).toHaveLength(1); + + await adapter.ackInboundMessages?.(inbound); + await expect(adapter.pullInboundMessages()).resolves.toEqual([]); }); it('keeps webhook updates queued when parsing fails and retries later', async () => { @@ -253,11 +259,40 @@ describe('createTelegramChannelAdapter', () => { expect(firstInbound).toHaveLength(1); expect(firstInbound[0]?.messageId).toBe('901'); + await adapter.ackInboundMessages?.(firstInbound); + const secondInbound = await adapter.pullInboundMessages(); expect(secondInbound).toHaveLength(1); expect(secondInbound[0]?.messageId).toBe('902'); }); + it('truncates outbound telegram messages above provider limit', async () => { + const api = { + getMe: vi.fn(async () => ({ id: 9, username: 'happier_bot' })), + getUpdates: vi.fn(async () => []), + sendMessage: vi.fn(async () => undefined), + }; + + const adapter = createTelegramChannelAdapter({ + botToken: 'test-token', + api, + }); + + const oversized = 'x'.repeat(4_500); + await adapter.sendMessage({ + conversationId: '1234', + threadId: null, + text: oversized, + }); + + expect(api.sendMessage).toHaveBeenCalledTimes(1); + const call = api.sendMessage.mock.calls[0]?.[0]; + expect(call?.text).toHaveLength(4_096); + expect(loggerWarn).toHaveBeenCalledWith( + expect.stringContaining('Truncated Telegram outbound message for conversation 1234 to 4096 characters'), + ); + }); + it('advances polling offset only after successful parsing', async () => { const getMe = vi .fn() diff --git a/apps/cli/src/channels/telegram/telegramAdapter.ts b/apps/cli/src/channels/telegram/telegramAdapter.ts index 07e3c23d6f..43665c2f31 100644 --- a/apps/cli/src/channels/telegram/telegramAdapter.ts +++ b/apps/cli/src/channels/telegram/telegramAdapter.ts @@ -13,6 +13,7 @@ type TelegramApiClient = Readonly<{ const TELEGRAM_GET_UPDATES_LONG_POLL_TIMEOUT_SECONDS = 25; const TELEGRAM_GET_UPDATES_HTTP_TIMEOUT_MS = 30_000; +const TELEGRAM_MAX_SEND_MESSAGE_TEXT_LENGTH = 4_096; function telegramApiUrl(botToken: string, method: string): string { return `https://api.telegram.org/bot${botToken}/${method}`; @@ -142,16 +143,30 @@ function parseInboundFromUpdate(params: Readonly<{ function parseHighestUpdateOffset(updates: readonly unknown[]): number | null { let max: number | null = null; for (const item of updates) { - const record = asRecord(item); - if (!record) continue; - const rawUpdateId = record.update_id; - if (typeof rawUpdateId !== 'number' || !Number.isFinite(rawUpdateId)) continue; - const current = Math.trunc(rawUpdateId); + const current = parseUpdateId(item); + if (current === null) continue; if (max === null || current > max) max = current; } return max; } +function parseUpdateId(update: unknown): number | null { + const record = asRecord(update); + if (!record) return null; + const rawUpdateId = record.update_id; + if (typeof rawUpdateId !== 'number' || !Number.isFinite(rawUpdateId)) return null; + return Math.trunc(rawUpdateId); +} + +function inboundMessageKey(message: Readonly<{ + providerId: string; + conversationId: string; + threadId: string | null; + messageId: string; +}>): string { + return JSON.stringify([message.providerId, message.conversationId, message.threadId, message.messageId]); +} + export function createTelegramChannelAdapter(params: Readonly<{ botToken: string; api?: TelegramApiClient; @@ -175,6 +190,11 @@ export function createTelegramChannelAdapter(params: Readonly<{ update: unknown; }; + type PendingWebhookAck = { + queueIds: Set; + maxUpdateId: number | null; + }; + let selfBotId: number | null = null; /** * Telegram polling cursor (`getUpdates` offset). @@ -188,6 +208,31 @@ export function createTelegramChannelAdapter(params: Readonly<{ const queuedWebhookUpdates: QueuedWebhookUpdate[] = []; let nextQueuedWebhookId = 1; let droppedWebhookUpdates = 0; + const pendingWebhookAcksByMessageKey = new Map(); + + function dropPendingWebhookAckIds(ids: ReadonlySet): void { + if (ids.size === 0) return; + + for (const [key, pending] of pendingWebhookAcksByMessageKey) { + for (const id of ids) { + pending.queueIds.delete(id); + } + if (pending.queueIds.size === 0) { + pendingWebhookAcksByMessageKey.delete(key); + } + } + } + + function removeQueuedWebhookUpdatesById(ids: ReadonlySet): void { + if (ids.size === 0) return; + + for (let index = queuedWebhookUpdates.length - 1; index >= 0; index -= 1) { + if (ids.has(queuedWebhookUpdates[index]!.id)) { + queuedWebhookUpdates.splice(index, 1); + } + } + dropPendingWebhookAckIds(ids); + } async function ensureSelfIdentity(): Promise { if (selfBotId !== null) return; @@ -214,8 +259,11 @@ export function createTelegramChannelAdapter(params: Readonly<{ providerId: 'telegram', enqueueWebhookUpdate: (update: unknown) => { if (queuedWebhookUpdates.length >= MAX_WEBHOOK_QUEUE_SIZE) { - queuedWebhookUpdates.shift(); - droppedWebhookUpdates += 1; + const dropped = queuedWebhookUpdates.shift(); + if (dropped) { + droppedWebhookUpdates += 1; + dropPendingWebhookAckIds(new Set([dropped.id])); + } } queuedWebhookUpdates.push({ id: nextQueuedWebhookId, @@ -233,13 +281,36 @@ export function createTelegramChannelAdapter(params: Readonly<{ } const snapshot = queuedWebhookUpdates.slice(); - const parsed = await parseUpdates(snapshot.map((row) => row.update)); - const consumedIds = new Set(snapshot.map((row) => row.id)); - for (let index = queuedWebhookUpdates.length - 1; index >= 0; index -= 1) { - if (consumedIds.has(queuedWebhookUpdates[index]!.id)) { - queuedWebhookUpdates.splice(index, 1); + await ensureSelfIdentity(); + + const parsed: ChannelBridgeInboundMessage[] = []; + const consumedWithoutAck = new Set(); + for (const row of snapshot) { + const message = parseInboundFromUpdate({ + update: row.update, + selfBotId, + allowedChatIds, + requireTopics, + }); + if (!message) { + consumedWithoutAck.add(row.id); + continue; + } + + parsed.push(message); + const key = inboundMessageKey(message); + const pending = pendingWebhookAcksByMessageKey.get(key) ?? { queueIds: new Set(), maxUpdateId: null }; + pending.queueIds.add(row.id); + + const updateId = parseUpdateId(row.update); + if (updateId !== null) { + pending.maxUpdateId = pending.maxUpdateId === null ? updateId : Math.max(pending.maxUpdateId, updateId); } + + pendingWebhookAcksByMessageKey.set(key, pending); } + + removeQueuedWebhookUpdatesById(consumedWithoutAck); return parsed; } @@ -255,11 +326,61 @@ export function createTelegramChannelAdapter(params: Readonly<{ } return parsed; }, + ackInboundMessages: async (messages) => { + if (!webhookMode || messages.length === 0) { + return; + } + + const consumedIds = new Set(); + let maxAckedUpdateId: number | null = null; + + for (const message of messages) { + const key = inboundMessageKey(message); + const pending = pendingWebhookAcksByMessageKey.get(key); + if (!pending) { + continue; + } + + for (const queueId of pending.queueIds) { + consumedIds.add(queueId); + } + + if (pending.maxUpdateId !== null) { + maxAckedUpdateId = maxAckedUpdateId === null + ? pending.maxUpdateId + : Math.max(maxAckedUpdateId, pending.maxUpdateId); + } + + pendingWebhookAcksByMessageKey.delete(key); + } + + removeQueuedWebhookUpdatesById(consumedIds); + + if (maxAckedUpdateId !== null) { + const nextOffset = maxAckedUpdateId + 1; + updateOffset = updateOffset === null ? nextOffset : Math.max(updateOffset, nextOffset); + } + }, sendMessage: async (message) => { + const normalizedText = String(message.text).trim(); + const text = normalizedText.length > TELEGRAM_MAX_SEND_MESSAGE_TEXT_LENGTH + ? normalizedText.slice(0, TELEGRAM_MAX_SEND_MESSAGE_TEXT_LENGTH) + : normalizedText; + + if (!text) { + return; + } + + if (text.length !== normalizedText.length) { + logger.warn( + `[channelBridge] Truncated Telegram outbound message for conversation ${message.conversationId} to ${TELEGRAM_MAX_SEND_MESSAGE_TEXT_LENGTH} characters`, + ); + } + await api.sendMessage({ chatId: message.conversationId, threadId: message.threadId, - text: message.text, + text, }); }, }; diff --git a/apps/cli/src/channels/telegram/telegramWebhookRelay.ts b/apps/cli/src/channels/telegram/telegramWebhookRelay.ts index e14e96e0cc..b9d6912218 100644 --- a/apps/cli/src/channels/telegram/telegramWebhookRelay.ts +++ b/apps/cli/src/channels/telegram/telegramWebhookRelay.ts @@ -22,7 +22,7 @@ export type TelegramWebhookRelayHandle = Readonly<{ stop: () => Promise; }>; -function isLoopbackHost(host: string): boolean { +export function isLoopbackHost(host: string): boolean { const normalized = host.trim().toLowerCase(); if (normalized === 'localhost') return true; diff --git a/apps/cli/src/cli/commands/bridge.test.ts b/apps/cli/src/cli/commands/bridge.test.ts index 85f2e536f6..1dcf4b4ca1 100644 --- a/apps/cli/src/cli/commands/bridge.test.ts +++ b/apps/cli/src/cli/commands/bridge.test.ts @@ -227,4 +227,52 @@ describe('happier bridge command', () => { errorSpy.mockRestore(); } }); + + it('rejects malformed --allowed-chat-ids that parse to an empty list', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + try { + const { handleBridgeCliCommand } = await import('./bridge'); + + await handleBridgeCliCommand({ + args: ['bridge', 'telegram', 'set', '--allowed-chat-ids', ',,,'], + rawArgv: [], + terminalRuntime: null, + }); + + expect(updateSettingsMock).not.toHaveBeenCalled(); + expect(createKvClientMock).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + expect(errorSpy).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining('Invalid --allowed-chat-ids value'), + ); + } finally { + errorSpy.mockRestore(); + } + }); + + it('rejects non-loopback --webhook-host values', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + + try { + const { handleBridgeCliCommand } = await import('./bridge'); + + await handleBridgeCliCommand({ + args: ['bridge', 'telegram', 'set', '--webhook-host', '0.0.0.0'], + rawArgv: [], + terminalRuntime: null, + }); + + expect(updateSettingsMock).not.toHaveBeenCalled(); + expect(createKvClientMock).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + expect(errorSpy).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining('Invalid --webhook-host value'), + ); + } finally { + errorSpy.mockRestore(); + } + }); }); diff --git a/apps/cli/src/cli/commands/bridge.ts b/apps/cli/src/cli/commands/bridge.ts index d9c4afa20a..dca2a80eaf 100644 --- a/apps/cli/src/cli/commands/bridge.ts +++ b/apps/cli/src/cli/commands/bridge.ts @@ -19,6 +19,7 @@ import { upsertChannelBridgeTelegramConfigInKv, } from '@/channels/channelBridgeServerKv'; import { overlayServerKvTelegramConfigInSettings } from '@/channels/channelBridgeServerConfigOverlay'; +import { isLoopbackHost } from '@/channels/telegram/telegramWebhookRelay'; import { readCredentials, readSettings, updateSettings } from '@/persistence'; import { argvValue } from '@/cli/commands/server/commandUtilities'; @@ -233,7 +234,11 @@ async function cmdTelegramSet(args: string[]): Promise { if (allowAll) { update.allowedChatIds = []; } else if (allowedChatIdsRaw) { - update.allowedChatIds = parseCsvList(allowedChatIdsRaw); + const parsedAllowedChatIds = parseCsvList(allowedChatIdsRaw); + if (parsedAllowedChatIds.length === 0) { + throw new Error('Invalid --allowed-chat-ids value: provide at least one chat id or use --allow-all'); + } + update.allowedChatIds = parsedAllowedChatIds; } if (requireTopicsRaw) { update.requireTopics = parseBooleanInput(requireTopicsRaw, '--require-topics'); @@ -249,6 +254,9 @@ async function cmdTelegramSet(args: string[]): Promise { update.webhookSecret = webhookSecret; } if (webhookHost) { + if (!isLoopbackHost(webhookHost)) { + throw new Error('Invalid --webhook-host value: must be a loopback address (127.0.0.1, ::1, or localhost)'); + } update.webhookHost = webhookHost; } if (webhookPortRaw) { diff --git a/apps/cli/src/cli/dispatch.tmuxDisallowed.test.ts b/apps/cli/src/cli/dispatch.tmuxDisallowed.test.ts index 865459b0fa..b960945ef4 100644 --- a/apps/cli/src/cli/dispatch.tmuxDisallowed.test.ts +++ b/apps/cli/src/cli/dispatch.tmuxDisallowed.test.ts @@ -66,4 +66,26 @@ describe('dispatchCli --tmux disallowed controller commands', () => { ).rejects.toThrow('process.exit(1)'); expect(startHappyHeadlessInTmux).not.toHaveBeenCalled(); }); + + it('rejects --tmux for bug-report controller command', async () => { + await expect( + dispatchCli({ + args: ['--tmux', 'bug-report'], + rawArgv: ['happier', '--tmux', 'bug-report'], + terminalRuntime: null, + }), + ).rejects.toThrow('process.exit(1)'); + expect(startHappyHeadlessInTmux).not.toHaveBeenCalled(); + }); + + it('rejects --tmux for self-update controller command', async () => { + await expect( + dispatchCli({ + args: ['self-update', '--tmux'], + rawArgv: ['happier', 'self-update', '--tmux'], + terminalRuntime: null, + }), + ).rejects.toThrow('process.exit(1)'); + expect(startHappyHeadlessInTmux).not.toHaveBeenCalled(); + }); }); diff --git a/apps/cli/src/daemon/startDaemon.automation.integration.test.ts b/apps/cli/src/daemon/startDaemon.automation.integration.test.ts index 80c5e07d5d..2410b2d69d 100644 --- a/apps/cli/src/daemon/startDaemon.automation.integration.test.ts +++ b/apps/cli/src/daemon/startDaemon.automation.integration.test.ts @@ -485,6 +485,40 @@ describe('startDaemon automation wiring (integration)', () => { } }); + it('stops a bridge worker that resolves after shutdown has already started', async () => { + vi.useRealTimers(); + + const delayedWorkerStop = vi.fn(async () => {}); + let resolveBridgeStartup: (() => void) | null = null; + harness.startChannelBridgeFromEnv.mockImplementationOnce(async () => { + await new Promise((resolve) => { + resolveBridgeStartup = resolve; + }); + return { + stop: delayedWorkerStop, + trigger: vi.fn(), + }; + }); + + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never); + + try { + const { startDaemon } = await import('./startDaemon'); + const run = startDaemon(); + + await new Promise((resolve) => setTimeout(resolve, 0)); + harness.requestShutdown('happier-cli'); + resolveBridgeStartup?.(); + await run; + + expect(harness.startChannelBridgeFromEnv).toHaveBeenCalledTimes(1); + expect(delayedWorkerStop).toHaveBeenCalledTimes(1); + expect(exitSpy).toHaveBeenCalledWith(0); + } finally { + exitSpy.mockRestore(); + } + }); + it('does not leak bearer tokens when machine registration fails', async () => { vi.useRealTimers(); diff --git a/apps/cli/src/daemon/startDaemon.ts b/apps/cli/src/daemon/startDaemon.ts index bd22bb19da..1c149a933b 100644 --- a/apps/cli/src/daemon/startDaemon.ts +++ b/apps/cli/src/daemon/startDaemon.ts @@ -1570,7 +1570,7 @@ export async function startDaemon(): Promise { return; } - channelBridgeWorker = await startChannelBridgeFromEnv({ + const worker = await startChannelBridgeFromEnv({ credentials, ...(channelBridgeRuntimeSettings ? { settings: channelBridgeRuntimeSettings } : {}), ...(channelBridgeServerId ? { serverId: channelBridgeServerId } : {}), @@ -1582,7 +1582,22 @@ export async function startDaemon(): Promise { ); return null; }); - } + + if (!worker) { + return; + } + + if (shutdownInitiated) { + await worker.stop().catch((error) => { + logger.warn( + '[DAEMON RUN] Failed to stop channel bridge worker started during shutdown', + serializeAxiosErrorForLog(error), + ); + }); + return; + } + + channelBridgeWorker = worker; }; void startChannelBridgeWorkerBestEffort(); diff --git a/docs/channel-bridge-uat.md b/docs/channel-bridge-uat.md index 830df50325..19a8f57a8b 100644 --- a/docs/channel-bridge-uat.md +++ b/docs/channel-bridge-uat.md @@ -4,7 +4,14 @@ This checklist validates that one Happier account can use multiple machines + Te ## 1) Start from clean local state (test env) +- Use a fresh test home per run: + +```bash +export HAPPIER_HOME_DIR="$(mktemp -d /tmp/happier-uat-XXXXXX)" +``` + - Stop daemon: `happier daemon stop` +- Remove stale local settings if present: `rm -f "$HAPPIER_HOME_DIR/settings.json"` - Confirm active server: `happier server list` - Start daemon: `happier daemon start` From 33c9c746384dbc0fcd0d0bcfb6bdb036e6b57da4 Mon Sep 17 00:00:00 2001 From: Chris Denneen Date: Fri, 6 Mar 2026 14:37:03 -0500 Subject: [PATCH 07/81] harden webhook diagnostics and loopback validation --- .../channels/channelBridgeServerKv.test.ts | 17 +++++++++++++ .../cli/src/channels/channelBridgeServerKv.ts | 6 +---- apps/cli/src/ui/doctor.test.ts | 24 ++++++++++++++++--- apps/cli/src/ui/doctor.ts | 12 +++++++--- 4 files changed, 48 insertions(+), 11 deletions(-) diff --git a/apps/cli/src/channels/channelBridgeServerKv.test.ts b/apps/cli/src/channels/channelBridgeServerKv.test.ts index a3db9f9699..51a9d4cc1c 100644 --- a/apps/cli/src/channels/channelBridgeServerKv.test.ts +++ b/apps/cli/src/channels/channelBridgeServerKv.test.ts @@ -101,6 +101,23 @@ describe('channelBridgeServerKv', () => { } }); + it('allows full 127.0.0.0/8 loopback range for local development', () => { + const prevServerUrl = process.env.HAPPIER_SERVER_URL; + try { + process.env.HAPPIER_SERVER_URL = 'http://127.0.0.2:3005'; + reloadConfiguration(); + + expect(() => createAxiosChannelBridgeKvClient({ token: 'token-1' })).not.toThrow(); + } finally { + if (prevServerUrl === undefined) { + delete process.env.HAPPIER_SERVER_URL; + } else { + process.env.HAPPIER_SERVER_URL = prevServerUrl; + } + reloadConfiguration(); + } + }); + it('upserts and reads scoped telegram non-secret config from KV', async () => { const kv = createInMemoryKvClient(); diff --git a/apps/cli/src/channels/channelBridgeServerKv.ts b/apps/cli/src/channels/channelBridgeServerKv.ts index 05397d76ec..9fa5e912c9 100644 --- a/apps/cli/src/channels/channelBridgeServerKv.ts +++ b/apps/cli/src/channels/channelBridgeServerKv.ts @@ -10,6 +10,7 @@ import axios from 'axios'; import { resolveServerHttpBaseUrl } from '@/sessionControl/serverHttpBaseUrl'; +import { isLoopbackHost } from '@/channels/telegram/telegramWebhookRelay'; import type { ScopedTelegramBridgeUpdate } from './channelBridgeAccountConfig'; @@ -142,11 +143,6 @@ function parseVersionMismatchError(value: unknown): Readonly<{ currentVersion: n }; } -function isLoopbackHost(hostname: string): boolean { - const normalized = hostname.trim().toLowerCase(); - return normalized === 'localhost' || normalized === '127.0.0.1' || normalized === '::1'; -} - function assertSecureChannelBridgeKvBaseUrl(baseUrl: string): void { let parsed: URL; try { diff --git a/apps/cli/src/ui/doctor.test.ts b/apps/cli/src/ui/doctor.test.ts index 9342771e04..31ceb886fe 100644 --- a/apps/cli/src/ui/doctor.test.ts +++ b/apps/cli/src/ui/doctor.test.ts @@ -149,14 +149,19 @@ describe('doctor redaction', () => { }); describe('parseStrictWebhookPort', () => { - it('rejects negative string values', () => { + it('rejects negative and out-of-range values', () => { expect(parseStrictWebhookPort('-1')).toBeNull(); expect(parseStrictWebhookPort(' -8787 ')).toBeNull(); + expect(parseStrictWebhookPort('0')).toBeNull(); + expect(parseStrictWebhookPort(0)).toBeNull(); + expect(parseStrictWebhookPort('65536')).toBeNull(); + expect(parseStrictWebhookPort(70_000)).toBeNull(); }); - it('accepts non-negative integer values', () => { - expect(parseStrictWebhookPort('0')).toBe(0); + it('accepts valid positive integer values', () => { expect(parseStrictWebhookPort('8787')).toBe(8787); + expect(parseStrictWebhookPort(8787)).toBe(8787); + expect(parseStrictWebhookPort('65535')).toBe(65_535); }); }); @@ -206,6 +211,19 @@ describe('generic webhook field requirements', () => { ]); }); + it('flags non-loopback webhook hosts when enabled', () => { + expect( + collectMissingRequiredWebhookFields({ + webhookEnabled: true, + webhookSecret: 'secret-1', + webhookHost: '0.0.0.0', + webhookPort: 8787, + }), + ).toEqual([ + "webhook.host: '0.0.0.0' is not loopback-only (required when webhook.enabled=true)", + ]); + }); + it('accepts valid webhook secret, host, and port', () => { expect( collectMissingRequiredWebhookFields({ diff --git a/apps/cli/src/ui/doctor.ts b/apps/cli/src/ui/doctor.ts index ed6a2ef154..ad8baf396e 100644 --- a/apps/cli/src/ui/doctor.ts +++ b/apps/cli/src/ui/doctor.ts @@ -15,6 +15,7 @@ import { readDaemonState, type DaemonLocallyPersistedState } from '@/persistence import { resolveChannelBridgeRuntimeConfig } from '@/channels/channelBridgeConfig'; import { createAxiosChannelBridgeKvClient, readChannelBridgeTelegramConfigFromKv } from '@/channels/channelBridgeServerKv'; import { overlayServerKvTelegramConfigInSettings } from '@/channels/channelBridgeServerConfigOverlay'; +import { isLoopbackHost } from '@/channels/telegram/telegramWebhookRelay'; import { existsSync, readdirSync, statSync } from 'node:fs' import { readFile } from 'node:fs/promises' import { join } from 'node:path' @@ -65,8 +66,13 @@ export function collectMissingRequiredWebhookFields(params: Readonly<{ if (params.webhookSecret.trim().length === 0) { issues.push('webhook.secret: (required when webhook.enabled=true)'); } - if (params.webhookHost.trim().length === 0) { + const normalizedWebhookHost = params.webhookHost.trim(); + if (normalizedWebhookHost.length === 0) { issues.push('webhook.host: (required when webhook.enabled=true)'); + } else if (!isLoopbackHost(normalizedWebhookHost)) { + issues.push( + `webhook.host: '${normalizedWebhookHost}' is not loopback-only (required when webhook.enabled=true)`, + ); } if ( !Number.isFinite(params.webhookPort) @@ -87,14 +93,14 @@ function asRecord(value: unknown): Record | null { export function parseStrictWebhookPort(value: unknown): number | null { if (typeof value === 'number' && Number.isFinite(value)) { - if (!Number.isInteger(value) || value < 0) return null; + if (!Number.isInteger(value) || value <= 0 || value > 65_535) return null; return value; } if (typeof value === 'string') { const trimmed = value.trim(); if (!/^\d+$/.test(trimmed)) return null; const parsed = Number.parseInt(trimmed, 10); - if (!Number.isFinite(parsed) || parsed < 0) return null; + if (!Number.isFinite(parsed) || parsed <= 0 || parsed > 65_535) return null; return parsed; } return null; From 6445031d0a870c7b6ed7b9e4fbe30eb0e1a17bc6 Mon Sep 17 00:00:00 2001 From: Chris Denneen Date: Fri, 6 Mar 2026 14:46:53 -0500 Subject: [PATCH 08/81] scope bridge KV by account and harden webhook relay --- .../channelBindingStore.server.test.ts | 9 ++++ .../channels/channelBindingStore.server.ts | 3 ++ .../channels/channelBridgeServerKv.test.ts | 54 +++++++++++++++++-- .../cli/src/channels/channelBridgeServerKv.ts | 31 +++++++---- .../src/channels/startChannelBridgeWorker.ts | 4 +- .../telegram/telegramWebhookRelay.test.ts | 22 +++++++- .../channels/telegram/telegramWebhookRelay.ts | 20 +++++-- apps/cli/src/cli/commands/bridge.test.ts | 2 + apps/cli/src/cli/commands/bridge.ts | 3 ++ apps/cli/src/daemon/startDaemon.ts | 1 + apps/cli/src/ui/doctor.ts | 3 +- docs/telegram-channel-bridge.md | 10 +++- 12 files changed, 142 insertions(+), 20 deletions(-) diff --git a/apps/cli/src/channels/channelBindingStore.server.test.ts b/apps/cli/src/channels/channelBindingStore.server.test.ts index d356ed0402..ea33934708 100644 --- a/apps/cli/src/channels/channelBindingStore.server.test.ts +++ b/apps/cli/src/channels/channelBindingStore.server.test.ts @@ -303,11 +303,13 @@ describe('createServerBackedChannelBindingStore', () => { const storeA = createServerBackedChannelBindingStore({ kv, serverId: 'local-3005', + accountId: 'acct-1', }); const storeB = createServerBackedChannelBindingStore({ kv, serverId: 'local-3005', + accountId: 'acct-1', }); await storeA.upsertBinding({ @@ -333,6 +335,7 @@ describe('createServerBackedChannelBindingStore', () => { const store = createServerBackedChannelBindingStore({ kv, serverId: 'local-3005', + accountId: 'acct-1', }); await store.upsertBinding({ @@ -376,6 +379,7 @@ describe('createServerBackedChannelBindingStore', () => { const store = createServerBackedChannelBindingStore({ kv: counting.kv, serverId: 'local-3005', + accountId: 'acct-1', }); await store.upsertBinding({ @@ -412,6 +416,7 @@ describe('createServerBackedChannelBindingStore', () => { const store = createServerBackedChannelBindingStore({ kv: counting.kv, serverId: 'local-3005', + accountId: 'acct-1', }); await store.upsertBinding({ @@ -458,6 +463,7 @@ describe('createServerBackedChannelBindingStore', () => { const store = createServerBackedChannelBindingStore({ kv: conflict.kv, serverId: 'local-3005', + accountId: 'acct-1', maxWriteRetries: 3, }); @@ -484,6 +490,7 @@ describe('createServerBackedChannelBindingStore', () => { const store = createServerBackedChannelBindingStore({ kv, serverId: 'local-3005', + accountId: 'acct-1', maxWriteRetries: 3, }); @@ -558,6 +565,7 @@ describe('createServerBackedChannelBindingStore', () => { const store = createServerBackedChannelBindingStore({ kv, serverId: 'local-3005', + accountId: 'acct-1', cacheTtlMs: 0, maxWriteRetries: 3, }); @@ -584,6 +592,7 @@ describe('createServerBackedChannelBindingStore', () => { const store = createServerBackedChannelBindingStore({ kv: conflict.kv, serverId: 'local-3005', + accountId: 'acct-1', maxWriteRetries: 3, }); diff --git a/apps/cli/src/channels/channelBindingStore.server.ts b/apps/cli/src/channels/channelBindingStore.server.ts index 607f0348ec..bf899f7b1a 100644 --- a/apps/cli/src/channels/channelBindingStore.server.ts +++ b/apps/cli/src/channels/channelBindingStore.server.ts @@ -86,6 +86,7 @@ function isRecoverableBindingsReadError(error: unknown): boolean { export function createServerBackedChannelBindingStore(params: Readonly<{ kv: ChannelBridgeKvClient; serverId: string; + accountId: string; cacheTtlMs?: number; maxWriteRetries?: number; }>): ChannelBindingStore { @@ -103,6 +104,7 @@ export function createServerBackedChannelBindingStore(params: Readonly<{ const fetched = await readChannelBridgeBindingsFromKv({ kv: params.kv, serverId: params.serverId, + accountId: params.accountId, }); const next: BindingCache = { version: fetched.version, @@ -159,6 +161,7 @@ export function createServerBackedChannelBindingStore(params: Readonly<{ const version = await writeChannelBridgeBindingsToKv({ kv: params.kv, serverId: params.serverId, + accountId: params.accountId, expectedVersion: current.version, doc: toServerDocument(op.nextBindings), }); diff --git a/apps/cli/src/channels/channelBridgeServerKv.test.ts b/apps/cli/src/channels/channelBridgeServerKv.test.ts index 51a9d4cc1c..a9f05242e5 100644 --- a/apps/cli/src/channels/channelBridgeServerKv.test.ts +++ b/apps/cli/src/channels/channelBridgeServerKv.test.ts @@ -124,6 +124,7 @@ describe('channelBridgeServerKv', () => { await upsertChannelBridgeTelegramConfigInKv({ kv, serverId: 'local-3005', + accountId: 'acct-1', update: { tickMs: 2400, botToken: 'bot-token-1', @@ -136,6 +137,7 @@ describe('channelBridgeServerKv', () => { const config = await readChannelBridgeTelegramConfigFromKv({ kv, serverId: 'local-3005', + accountId: 'acct-1', }); expect(config.record).toMatchObject({ @@ -156,6 +158,7 @@ describe('channelBridgeServerKv', () => { await upsertChannelBridgeTelegramConfigInKv({ kv, serverId: 'local-3005', + accountId: 'acct-1', update: { botToken: 'bot-token-1', webhookSecret: 'secret-1', @@ -165,6 +168,7 @@ describe('channelBridgeServerKv', () => { const config = await readChannelBridgeTelegramConfigFromKv({ kv, serverId: 'local-3005', + accountId: 'acct-1', }); expect(config.record).toBeNull(); @@ -177,6 +181,7 @@ describe('channelBridgeServerKv', () => { await upsertChannelBridgeTelegramConfigInKv({ kv, serverId: 'local-3005', + accountId: 'acct-1', update: { allowedChatIds: ['-100111'], }, @@ -185,11 +190,13 @@ describe('channelBridgeServerKv', () => { await clearChannelBridgeTelegramConfigInKv({ kv, serverId: 'local-3005', + accountId: 'acct-1', }); const config = await readChannelBridgeTelegramConfigFromKv({ kv, serverId: 'local-3005', + accountId: 'acct-1', }); expect(config.record).toBeNull(); @@ -204,7 +211,7 @@ describe('channelBridgeServerKv', () => { updatedAtMs: 111, }), 'utf8').toString('base64'); - const key = 'happier:channel-bridge:v1:server:local-3005:telegram-config'; + const key = 'happier:channel-bridge:v1:server:local-3005:account:acct-1:telegram-config'; const byKey = new Map(); byKey.set(key, { value: unknownSchemaValueBase64, @@ -263,6 +270,7 @@ describe('channelBridgeServerKv', () => { await expect(upsertChannelBridgeTelegramConfigInKv({ kv, serverId: 'local-3005', + accountId: 'acct-1', update: { allowedChatIds: ['-100111'], }, @@ -271,6 +279,7 @@ describe('channelBridgeServerKv', () => { const config = await readChannelBridgeTelegramConfigFromKv({ kv, serverId: 'local-3005', + accountId: 'acct-1', }); expect(config.record).toMatchObject({ @@ -283,7 +292,7 @@ describe('channelBridgeServerKv', () => { }); it('retries upsert after version mismatch using conflict payload without extra read', async () => { - const key = 'happier:channel-bridge:v1:server:local-3005:telegram-config'; + const key = 'happier:channel-bridge:v1:server:local-3005:account:acct-1:telegram-config'; const initialValueBase64 = Buffer.from(JSON.stringify({ schemaVersion: 1, telegram: { @@ -357,6 +366,7 @@ describe('channelBridgeServerKv', () => { await upsertChannelBridgeTelegramConfigInKv({ kv, serverId: 'local-3005', + accountId: 'acct-1', update: { requireTopics: true, }, @@ -371,7 +381,7 @@ describe('channelBridgeServerKv', () => { }); it('retries clear after version mismatch without extra reread', async () => { - const key = 'happier:channel-bridge:v1:server:local-3005:telegram-config'; + const key = 'happier:channel-bridge:v1:server:local-3005:account:acct-1:telegram-config'; const initialValueBase64 = Buffer.from(JSON.stringify({ schemaVersion: 1, telegram: { @@ -447,6 +457,7 @@ describe('channelBridgeServerKv', () => { await clearChannelBridgeTelegramConfigInKv({ kv, serverId: 'local-3005', + accountId: 'acct-1', }); expect(getCalls).toBe(1); @@ -460,6 +471,7 @@ describe('channelBridgeServerKv', () => { const first = await readChannelBridgeBindingsFromKv({ kv, serverId: 'local-3005', + accountId: 'acct-1', }); expect(first.version).toBe(-1); expect(first.doc.bindings).toEqual([]); @@ -467,6 +479,7 @@ describe('channelBridgeServerKv', () => { const writtenVersion = await writeChannelBridgeBindingsToKv({ kv, serverId: 'local-3005', + accountId: 'acct-1', expectedVersion: first.version, doc: { schemaVersion: 1, @@ -489,6 +502,7 @@ describe('channelBridgeServerKv', () => { const second = await readChannelBridgeBindingsFromKv({ kv, serverId: 'local-3005', + accountId: 'acct-1', }); expect(second.version).toBe(0); expect(second.doc.bindings).toHaveLength(1); @@ -512,6 +526,7 @@ describe('channelBridgeServerKv', () => { await expect(readChannelBridgeBindingsFromKv({ kv, serverId: 'local-3005', + accountId: 'acct-1', })).rejects.toThrow('Invalid or unsupported channel bridge bindings payload in KV'); }); @@ -543,6 +558,7 @@ describe('channelBridgeServerKv', () => { await expect(readChannelBridgeBindingsFromKv({ kv, serverId: 'local-3005', + accountId: 'acct-1', })).rejects.toThrow('Invalid channel bridge binding lastForwardedSeq at index 0'); }); @@ -575,6 +591,7 @@ describe('channelBridgeServerKv', () => { await expect(readChannelBridgeBindingsFromKv({ kv, serverId: 'local-3005', + accountId: 'acct-1', })).rejects.toThrow('Invalid channel bridge binding updatedAtMs at index 0'); }); @@ -609,6 +626,7 @@ describe('channelBridgeServerKv', () => { await expect(readChannelBridgeBindingsFromKv({ kv, serverId: 'local-3005', + accountId: 'acct-1', })).rejects.toThrow('Invalid channel bridge binding threadId at index 0'); }); @@ -648,6 +666,7 @@ describe('channelBridgeServerKv', () => { await expect(readChannelBridgeBindingsFromKv({ kv, serverId: 'local-3005', + accountId: 'acct-1', })).rejects.toThrow('Invalid channel bridge binding identity fields at index 1'); }); @@ -668,9 +687,37 @@ describe('channelBridgeServerKv', () => { await expect(readChannelBridgeTelegramConfigFromKv({ kv, serverId: 'local-3005', + accountId: 'acct-1', })).rejects.toThrow('Invalid or unsupported Telegram config schema'); }); + it('throws when telegram config payload contains malformed non-empty allowedChatIds', async () => { + const malformedAllowedChatIdsValueBase64 = Buffer.from(JSON.stringify({ + schemaVersion: 1, + telegram: { + allowedChatIds: [' ', 123, null], + }, + updatedAtMs: 123, + }), 'utf8').toString('base64'); + const kv: ChannelBridgeKvClient = { + get: async () => ({ + status: 200, + body: { + key: 'k', + value: malformedAllowedChatIdsValueBase64, + version: 3, + }, + }), + mutate: async () => ({ status: 200, body: { success: true, results: [] } }), + }; + + await expect(readChannelBridgeTelegramConfigFromKv({ + kv, + serverId: 'local-3005', + accountId: 'acct-1', + })).rejects.toThrow('Invalid telegram.allowedChatIds payload'); + }); + it('treats malformed telegram config payload as recoverable when allowUnsupportedSchema=true', async () => { const malformedValueBase64 = Buffer.from('not-json', 'utf8').toString('base64'); const kv: ChannelBridgeKvClient = { @@ -688,6 +735,7 @@ describe('channelBridgeServerKv', () => { await expect(readChannelBridgeTelegramConfigFromKv({ kv, serverId: 'local-3005', + accountId: 'acct-1', allowUnsupportedSchema: true, })).resolves.toEqual({ record: null, diff --git a/apps/cli/src/channels/channelBridgeServerKv.ts b/apps/cli/src/channels/channelBridgeServerKv.ts index 9fa5e912c9..2a53707612 100644 --- a/apps/cli/src/channels/channelBridgeServerKv.ts +++ b/apps/cli/src/channels/channelBridgeServerKv.ts @@ -192,7 +192,11 @@ function parseTelegramConfigRecord(value: unknown): ChannelBridgeServerTelegramC } if (Array.isArray(telegram.allowedChatIds)) { - out.telegram.allowedChatIds = normalizeStringArray(telegram.allowedChatIds); + const normalizedAllowedChatIds = normalizeStringArray(telegram.allowedChatIds); + if (telegram.allowedChatIds.length > 0 && normalizedAllowedChatIds.length === 0) { + throw new ChannelBridgeBadPayloadError('Invalid telegram.allowedChatIds payload'); + } + out.telegram.allowedChatIds = normalizedAllowedChatIds; } if (typeof telegram.requireTopics === 'boolean') { out.telegram.requireTopics = telegram.requireTopics; @@ -274,12 +278,12 @@ function parseBindingsDocument(value: unknown): ChannelBridgeServerBindingsDocum }; } -function telegramConfigKvKey(serverId: string): string { - return `${CHANNEL_BRIDGE_KV_PREFIX}:server:${serverId}:telegram-config`; +function telegramConfigKvKey(serverId: string, accountId: string): string { + return `${CHANNEL_BRIDGE_KV_PREFIX}:server:${serverId}:account:${accountId}:telegram-config`; } -function bindingsKvKey(serverId: string): string { - return `${CHANNEL_BRIDGE_KV_PREFIX}:server:${serverId}:bindings`; +function bindingsKvKey(serverId: string, accountId: string): string { + return `${CHANNEL_BRIDGE_KV_PREFIX}:server:${serverId}:account:${accountId}:bindings`; } async function readJsonValue(params: Readonly<{ @@ -397,9 +401,10 @@ export function createAxiosChannelBridgeKvClient(params: Readonly<{ token: strin export async function readChannelBridgeTelegramConfigFromKv(params: Readonly<{ kv: ChannelBridgeKvClient; serverId: string; + accountId: string; allowUnsupportedSchema?: boolean; }>): Promise> { - const key = telegramConfigKvKey(params.serverId); + const key = telegramConfigKvKey(params.serverId, params.accountId); const row = await readJsonValue({ kv: params.kv, key }); if (!row.valueBase64) { return { record: null, version: row.version }; @@ -503,13 +508,14 @@ function hasNonSecretTelegramConfigUpdate(update: ScopedTelegramBridgeUpdate): b export async function upsertChannelBridgeTelegramConfigInKv(params: Readonly<{ kv: ChannelBridgeKvClient; serverId: string; + accountId: string; update: ScopedTelegramBridgeUpdate; }>): Promise { if (!hasNonSecretTelegramConfigUpdate(params.update)) { return; } - const key = telegramConfigKvKey(params.serverId); + const key = telegramConfigKvKey(params.serverId, params.accountId); let current: Readonly<{ record: ChannelBridgeServerTelegramConfigRecord | null; version: number; @@ -519,6 +525,7 @@ export async function upsertChannelBridgeTelegramConfigInKv(params: Readonly<{ current = await readChannelBridgeTelegramConfigFromKv({ kv: params.kv, serverId: params.serverId, + accountId: params.accountId, allowUnsupportedSchema: true, }); } @@ -558,11 +565,13 @@ export async function upsertChannelBridgeTelegramConfigInKv(params: Readonly<{ export async function clearChannelBridgeTelegramConfigInKv(params: Readonly<{ kv: ChannelBridgeKvClient; serverId: string; + accountId: string; }>): Promise { - const key = telegramConfigKvKey(params.serverId); + const key = telegramConfigKvKey(params.serverId, params.accountId); const current = await readChannelBridgeTelegramConfigFromKv({ kv: params.kv, serverId: params.serverId, + accountId: params.accountId, allowUnsupportedSchema: true, }); let expectedVersion = current.version; @@ -595,8 +604,9 @@ export async function clearChannelBridgeTelegramConfigInKv(params: Readonly<{ export async function readChannelBridgeBindingsFromKv(params: Readonly<{ kv: ChannelBridgeKvClient; serverId: string; + accountId: string; }>): Promise> { - const key = bindingsKvKey(params.serverId); + const key = bindingsKvKey(params.serverId, params.accountId); const row = await readJsonValue({ kv: params.kv, key }); return { doc: decodeChannelBridgeBindingsDocFromBase64(row.valueBase64), @@ -607,10 +617,11 @@ export async function readChannelBridgeBindingsFromKv(params: Readonly<{ export async function writeChannelBridgeBindingsToKv(params: Readonly<{ kv: ChannelBridgeKvClient; serverId: string; + accountId: string; expectedVersion: number; doc: ChannelBridgeServerBindingsDocument; }>): Promise { - const key = bindingsKvKey(params.serverId); + const key = bindingsKvKey(params.serverId, params.accountId); const valueBase64 = encodeJsonToBase64(params.doc); return await writeJsonValue({ kv: params.kv, diff --git a/apps/cli/src/channels/startChannelBridgeWorker.ts b/apps/cli/src/channels/startChannelBridgeWorker.ts index 12c0a99324..f1aa88aa88 100644 --- a/apps/cli/src/channels/startChannelBridgeWorker.ts +++ b/apps/cli/src/channels/startChannelBridgeWorker.ts @@ -367,11 +367,13 @@ export async function startChannelBridgeFromEnv(params: Readonly<{ let store = params.store ?? null; if (!store) { const serverId = typeof params.serverId === 'string' ? params.serverId.trim() : ''; - if (serverId) { + const accountId = typeof params.accountId === 'string' ? params.accountId.trim() : ''; + if (serverId && accountId) { const kv = createAxiosChannelBridgeKvClient({ token: params.credentials.token }); store = createServerBackedChannelBindingStore({ kv, serverId, + accountId, }); } } diff --git a/apps/cli/src/channels/telegram/telegramWebhookRelay.test.ts b/apps/cli/src/channels/telegram/telegramWebhookRelay.test.ts index 058aebf25f..e18d3c3fb9 100644 --- a/apps/cli/src/channels/telegram/telegramWebhookRelay.test.ts +++ b/apps/cli/src/channels/telegram/telegramWebhookRelay.test.ts @@ -1,6 +1,7 @@ import axios from 'axios'; -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; +import { logger } from '@/ui/logger'; import { startTelegramWebhookRelay } from './telegramWebhookRelay'; describe('startTelegramWebhookRelay', () => { @@ -90,6 +91,7 @@ describe('startTelegramWebhookRelay', () => { }); it('does not acknowledge webhook updates when onUpdate fails', async () => { + const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => undefined); const relay = await startTelegramWebhookRelay({ port: 0, host: '127.0.0.1', @@ -117,7 +119,15 @@ describe('startTelegramWebhookRelay', () => { expect(response.status).toBeGreaterThanOrEqual(500); expect(response.status).toBeLessThan(600); + expect(warnSpy).toHaveBeenCalledWith( + '[TELEGRAM_WEBHOOK_RELAY] Failed to process inbound webhook update', + expect.objectContaining({ + path: relay.path, + tokenLength: 'secret-fail'.length, + }), + ); } finally { + warnSpy.mockRestore(); await relay.stop(); } }); @@ -140,4 +150,14 @@ describe('startTelegramWebhookRelay', () => { onUpdate: () => undefined, })).rejects.toThrow('Webhook host must be loopback-only'); }); + + it('rejects non-finite webhook ports', async () => { + await expect(startTelegramWebhookRelay({ + port: Number.NaN, + host: '127.0.0.1', + secretPathToken: 'secret-123', + secretHeaderToken: 'secret-123', + onUpdate: () => undefined, + })).rejects.toThrow('Webhook port must be a finite number'); + }); }); diff --git a/apps/cli/src/channels/telegram/telegramWebhookRelay.ts b/apps/cli/src/channels/telegram/telegramWebhookRelay.ts index b9d6912218..b4eef04b67 100644 --- a/apps/cli/src/channels/telegram/telegramWebhookRelay.ts +++ b/apps/cli/src/channels/telegram/telegramWebhookRelay.ts @@ -3,6 +3,8 @@ import { isIP } from 'node:net'; import fastify from 'fastify'; +import { logger } from '@/ui/logger'; + function secureCompareToken(providedToken: string, expectedToken: string): boolean { const providedBytes = Buffer.from(providedToken, 'utf8'); const expectedBytes = Buffer.from(expectedToken, 'utf8'); @@ -61,7 +63,10 @@ export async function startTelegramWebhookRelay(params: Readonly<{ if (!isLoopbackHost(host)) { throw new Error('Webhook host must be loopback-only'); } - const requestedPort = Number.isFinite(params.port) ? Math.trunc(params.port) : 0; + if (params.port !== undefined && params.port !== null && !Number.isFinite(params.port)) { + throw new Error('Webhook port must be a finite number'); + } + const requestedPort = params.port === undefined || params.port === null ? 0 : Math.trunc(params.port); if (requestedPort < 0 || requestedPort > 65_535) { throw new Error('Webhook port must be between 0 and 65535'); } @@ -80,8 +85,17 @@ export async function startTelegramWebhookRelay(params: Readonly<{ return reply.status(401).send({ ok: false, error: 'Unauthorized' }); } - await params.onUpdate(request.body); - return reply.send({ ok: true }); + try { + await params.onUpdate(request.body); + return reply.send({ ok: true }); + } catch (error) { + logger.warn('[TELEGRAM_WEBHOOK_RELAY] Failed to process inbound webhook update', { + path, + tokenLength: providedToken.trim().length, + error: error instanceof Error ? error.message : String(error), + }); + return reply.status(500).send({ ok: false, error: 'Internal Server Error' }); + } }); await app.listen({ port: requestedPort, host }); diff --git a/apps/cli/src/cli/commands/bridge.test.ts b/apps/cli/src/cli/commands/bridge.test.ts index 1dcf4b4ca1..59249ccd3d 100644 --- a/apps/cli/src/cli/commands/bridge.test.ts +++ b/apps/cli/src/cli/commands/bridge.test.ts @@ -92,6 +92,7 @@ describe('happier bridge command', () => { expect(upsertKvConfigMock).toHaveBeenCalledWith({ kv: expect.any(Object), serverId: configuration.activeServerId, + accountId: 'acct_123', update: { allowedChatIds: [], requireTopics: true, @@ -152,6 +153,7 @@ describe('happier bridge command', () => { expect(clearKvConfigMock).toHaveBeenCalledWith({ kv: expect.any(Object), serverId: configuration.activeServerId, + accountId: 'acct_123', }); expect(updateSettingsMock).toHaveBeenCalledTimes(1); expect(clearKvConfigMock.mock.invocationCallOrder[0]).toBeLessThan(updateSettingsMock.mock.invocationCallOrder[0]); diff --git a/apps/cli/src/cli/commands/bridge.ts b/apps/cli/src/cli/commands/bridge.ts index dca2a80eaf..9d61dfc13b 100644 --- a/apps/cli/src/cli/commands/bridge.ts +++ b/apps/cli/src/cli/commands/bridge.ts @@ -109,6 +109,7 @@ async function cmdList(): Promise { const fetched = await readChannelBridgeTelegramConfigFromKv({ kv, serverId, + accountId, allowUnsupportedSchema: true, }); serverKvRecord = fetched.record; @@ -276,6 +277,7 @@ async function cmdTelegramSet(args: string[]): Promise { await upsertChannelBridgeTelegramConfigInKv({ kv, serverId, + accountId, update: split.sharedUpdate, }); } @@ -313,6 +315,7 @@ async function cmdTelegramClear(): Promise { await clearChannelBridgeTelegramConfigInKv({ kv, serverId, + accountId, }); await updateSettings(async (current) => diff --git a/apps/cli/src/daemon/startDaemon.ts b/apps/cli/src/daemon/startDaemon.ts index 1c149a933b..3dc2287cef 100644 --- a/apps/cli/src/daemon/startDaemon.ts +++ b/apps/cli/src/daemon/startDaemon.ts @@ -1550,6 +1550,7 @@ export async function startDaemon(): Promise { const fetched = await readChannelBridgeTelegramConfigFromKv({ kv, serverId: channelBridgeServerId, + accountId: channelBridgeAccountId, allowUnsupportedSchema: true, }); channelBridgeRuntimeSettings = overlayServerKvTelegramConfigInSettings({ diff --git a/apps/cli/src/ui/doctor.ts b/apps/cli/src/ui/doctor.ts index ad8baf396e..cf0e78b36f 100644 --- a/apps/cli/src/ui/doctor.ts +++ b/apps/cli/src/ui/doctor.ts @@ -397,12 +397,13 @@ export async function runDoctorCommand(filter?: 'all' | 'daemon'): Promise let runtimeConfigSource = 'local settings'; let serverKvReadFailure: string | null = null; - if (serverId.length > 0 && authCredentials?.token) { + if (serverId.length > 0 && accountId.length > 0 && authCredentials?.token) { try { const kv = createAxiosChannelBridgeKvClient({ token: authCredentials.token }); const serverRecord = await readChannelBridgeTelegramConfigFromKv({ kv, serverId, + accountId, allowUnsupportedSchema: true, }); runtimeSettings = overlayServerKvTelegramConfigInSettings({ diff --git a/docs/telegram-channel-bridge.md b/docs/telegram-channel-bridge.md index 4378edc23c..fe91ff5a05 100644 --- a/docs/telegram-channel-bridge.md +++ b/docs/telegram-channel-bridge.md @@ -187,6 +187,13 @@ HAPPIER_TELEGRAM_WEBHOOK_HOST=127.0.0.1 HAPPIER_TELEGRAM_WEBHOOK_PORT=8787 ``` +`HAPPIER_TELEGRAM_WEBHOOK_SECRET` is currently used for both: + +- webhook URL path token (`/telegram/webhook/`) +- Telegram `secret_token` header validation + +This is an implementation limitation today (single configured secret) and is weaker than using separate secrets for path and header checks. + Important: - `HAPPIER_SERVER_URL` is the Telegram callback target only when server-relay mode exists and is enabled (planned, not currently implemented). @@ -212,7 +219,8 @@ Set Telegram webhook: curl "https://api.telegram.org/bot/setWebhook?url=https://your-public-host/telegram/webhook/&secret_token=" ``` -Use the same `` value for both webhook URL path token and `secret_token` so relay can validate the `X-Telegram-Bot-Api-Secret-Token` header. +Current implementation requires the same `` value for both webhook URL path token and `secret_token`. +Use a high-entropy random value and avoid sharing/logging webhook URLs. If you switch back to polling mode, clear webhook first: From d993368053495d9dc6b9fb74034573d04b9c7d44 Mon Sep 17 00:00:00 2001 From: Chris Denneen Date: Fri, 6 Mar 2026 15:39:14 -0500 Subject: [PATCH 09/81] harden bridge config persistence and diagnostics --- .../channelBindingStore.server.test.ts | 40 ++++- .../channels/channelBindingStore.server.ts | 23 ++- .../channels/channelBridgeServerKv.test.ts | 110 ++++++++++++ .../cli/src/channels/channelBridgeServerKv.ts | 13 +- .../src/channels/startChannelBridgeWorker.ts | 7 +- .../channels/telegram/telegramAdapter.test.ts | 33 +++- .../src/channels/telegram/telegramAdapter.ts | 14 +- apps/cli/src/cli/commands/bridge.test.ts | 79 ++++++++- apps/cli/src/cli/commands/bridge.ts | 163 ++++++++++++++++-- ...startDaemon.automation.integration.test.ts | 5 +- apps/cli/src/ui/doctor.ts | 30 +++- 11 files changed, 467 insertions(+), 50 deletions(-) diff --git a/apps/cli/src/channels/channelBindingStore.server.test.ts b/apps/cli/src/channels/channelBindingStore.server.test.ts index ea33934708..230fd3bafa 100644 --- a/apps/cli/src/channels/channelBindingStore.server.test.ts +++ b/apps/cli/src/channels/channelBindingStore.server.test.ts @@ -411,7 +411,7 @@ describe('createServerBackedChannelBindingStore', () => { expect(counting.mutateCallCount()).toBe(initialMutations); }); - it('normalizes non-finite seq values and skips invalid seq writes', async () => { + it('rejects invalid identity/cursor values and skips invalid seq writes', async () => { const counting = createCountingKvClient(); const store = createServerBackedChannelBindingStore({ kv: counting.kv, @@ -419,33 +419,57 @@ describe('createServerBackedChannelBindingStore', () => { accountId: 'acct-1', }); - await store.upsertBinding({ + await expect(store.upsertBinding({ providerId: 'telegram', conversationId: '-100nf', threadId: null, sessionId: 'sess-nf', lastForwardedSeq: Number.NaN, + })).rejects.toThrow('Invalid channel binding input'); + + await expect(store.upsertBinding({ + providerId: ' ', + conversationId: '-100nf', + threadId: null, + sessionId: 'sess-nf', + lastForwardedSeq: 1, + })).rejects.toThrow('Invalid channel binding input'); + + expect(counting.mutateCallCount()).toBe(0); + + await store.upsertBinding({ + providerId: ' telegram ', + conversationId: ' -100nf ', + threadId: ' 11 ', + sessionId: ' sess-nf ', + lastForwardedSeq: 2, }); const saved = await store.getBinding({ providerId: 'telegram', conversationId: '-100nf', - threadId: null, + threadId: '11', + }); + expect(saved).toMatchObject({ + providerId: 'telegram', + conversationId: '-100nf', + threadId: '11', + sessionId: 'sess-nf', + lastForwardedSeq: 2, }); - expect(saved?.lastForwardedSeq).toBe(0); const initialMutations = counting.mutateCallCount(); await store.updateLastForwardedSeq({ providerId: 'telegram', conversationId: '-100nf', - threadId: null, + threadId: '11', }, Number.POSITIVE_INFINITY); await store.updateLastForwardedSeq({ providerId: 'telegram', conversationId: '-100nf', - threadId: null, + threadId: '11', }, Number.NaN); expect(counting.mutateCallCount()).toBe(initialMutations); @@ -453,9 +477,9 @@ describe('createServerBackedChannelBindingStore', () => { const afterInvalidUpdates = await store.getBinding({ providerId: 'telegram', conversationId: '-100nf', - threadId: null, + threadId: '11', }); - expect(afterInvalidUpdates?.lastForwardedSeq).toBe(0); + expect(afterInvalidUpdates?.lastForwardedSeq).toBe(2); }); it('fails fast when conflict payload cannot be decoded', async () => { diff --git a/apps/cli/src/channels/channelBindingStore.server.ts b/apps/cli/src/channels/channelBindingStore.server.ts index bf899f7b1a..2615bca342 100644 --- a/apps/cli/src/channels/channelBindingStore.server.ts +++ b/apps/cli/src/channels/channelBindingStore.server.ts @@ -209,15 +209,24 @@ export function createServerBackedChannelBindingStore(params: Readonly<{ upsertBinding: async (binding) => { return await withOptimisticWrite((currentBindings) => { const nowMs = Date.now(); - const key = bindingKey(binding); - const existing = currentBindings.find((row) => bindingKey(row) === key) ?? null; + const providerId = binding.providerId.trim(); + const conversationId = binding.conversationId.trim(); + const sessionId = binding.sessionId.trim(); + const threadIdRaw = typeof binding.threadId === 'string' ? binding.threadId.trim() : ''; + const threadId = threadIdRaw.length > 0 ? threadIdRaw : null; + const normalizedLastForwardedSeq = toNonNegativeInt(binding.lastForwardedSeq); + + if (!providerId || !conversationId || !sessionId || normalizedLastForwardedSeq === null) { + throw new Error('Invalid channel binding input'); + } - const normalizedLastForwardedSeq = toNonNegativeInt(binding.lastForwardedSeq) ?? 0; + const key = bindingKey({ providerId, conversationId, threadId }); + const existing = currentBindings.find((row) => bindingKey(row) === key) ?? null; const nextBinding: ChannelSessionBinding = { - providerId: binding.providerId, - conversationId: binding.conversationId, - threadId: binding.threadId, - sessionId: binding.sessionId, + providerId, + conversationId, + threadId, + sessionId, lastForwardedSeq: normalizedLastForwardedSeq, createdAtMs: existing?.createdAtMs ?? nowMs, updatedAtMs: nowMs, diff --git a/apps/cli/src/channels/channelBridgeServerKv.test.ts b/apps/cli/src/channels/channelBridgeServerKv.test.ts index a9f05242e5..08da5043fa 100644 --- a/apps/cli/src/channels/channelBridgeServerKv.test.ts +++ b/apps/cli/src/channels/channelBridgeServerKv.test.ts @@ -152,6 +152,44 @@ describe('channelBridgeServerKv', () => { expect(config.version).toBe(0); }); + it('isolates telegram config by account scope on the same server', async () => { + const kv = createInMemoryKvClient(); + + await upsertChannelBridgeTelegramConfigInKv({ + kv, + serverId: 'local-3005', + accountId: 'acct-1', + update: { + requireTopics: true, + }, + }); + + await upsertChannelBridgeTelegramConfigInKv({ + kv, + serverId: 'local-3005', + accountId: 'acct-2', + update: { + allowedChatIds: ['-100222'], + }, + }); + + const acct1 = await readChannelBridgeTelegramConfigFromKv({ + kv, + serverId: 'local-3005', + accountId: 'acct-1', + }); + const acct2 = await readChannelBridgeTelegramConfigFromKv({ + kv, + serverId: 'local-3005', + accountId: 'acct-2', + }); + + expect(acct1.record?.telegram.requireTopics).toBe(true); + expect(acct1.record?.telegram.allowedChatIds).toBeUndefined(); + expect(acct2.record?.telegram.allowedChatIds).toEqual(['-100222']); + expect(acct2.record?.telegram.requireTopics).toBeUndefined(); + }); + it('ignores secret-only updates and leaves KV untouched', async () => { const kv = createInMemoryKvClient(); @@ -509,6 +547,47 @@ describe('channelBridgeServerKv', () => { expect(second.doc.bindings[0]?.sessionId).toBe('sess-1'); }); + it('isolates bindings documents by account scope on the same server', async () => { + const kv = createInMemoryKvClient(); + + await writeChannelBridgeBindingsToKv({ + kv, + serverId: 'local-3005', + accountId: 'acct-1', + expectedVersion: -1, + doc: { + schemaVersion: 1, + bindings: [ + { + providerId: 'telegram', + conversationId: '-100111', + threadId: null, + sessionId: 'sess-a', + lastForwardedSeq: 1, + createdAtMs: 1000, + updatedAtMs: 1000, + }, + ], + }, + }); + + const acct1 = await readChannelBridgeBindingsFromKv({ + kv, + serverId: 'local-3005', + accountId: 'acct-1', + }); + const acct2 = await readChannelBridgeBindingsFromKv({ + kv, + serverId: 'local-3005', + accountId: 'acct-2', + }); + + expect(acct1.doc.bindings).toHaveLength(1); + expect(acct1.doc.bindings[0]?.sessionId).toBe('sess-a'); + expect(acct2.doc.bindings).toEqual([]); + expect(acct2.version).toBe(-1); + }); + it('throws when bindings payload is invalid/corrupt in KV', async () => { const invalidValueBase64 = Buffer.from(JSON.stringify({ schemaVersion: 999, bindings: [] }), 'utf8').toString('base64'); const kv: ChannelBridgeKvClient = { @@ -718,6 +797,37 @@ describe('channelBridgeServerKv', () => { })).rejects.toThrow('Invalid telegram.allowedChatIds payload'); }); + it('throws when telegram config payload contains invalid webhook.port', async () => { + const invalidWebhookPortValueBase64 = Buffer.from(JSON.stringify({ + schemaVersion: 1, + telegram: { + webhook: { + enabled: true, + host: '127.0.0.1', + port: 70_000, + }, + }, + updatedAtMs: 123, + }), 'utf8').toString('base64'); + const kv: ChannelBridgeKvClient = { + get: async () => ({ + status: 200, + body: { + key: 'k', + value: invalidWebhookPortValueBase64, + version: 3, + }, + }), + mutate: async () => ({ status: 200, body: { success: true, results: [] } }), + }; + + await expect(readChannelBridgeTelegramConfigFromKv({ + kv, + serverId: 'local-3005', + accountId: 'acct-1', + })).rejects.toThrow('Invalid telegram.webhook.port payload'); + }); + it('treats malformed telegram config payload as recoverable when allowUnsupportedSchema=true', async () => { const malformedValueBase64 = Buffer.from('not-json', 'utf8').toString('base64'); const kv: ChannelBridgeKvClient = { diff --git a/apps/cli/src/channels/channelBridgeServerKv.ts b/apps/cli/src/channels/channelBridgeServerKv.ts index 2a53707612..2922b459d2 100644 --- a/apps/cli/src/channels/channelBridgeServerKv.ts +++ b/apps/cli/src/channels/channelBridgeServerKv.ts @@ -207,7 +207,18 @@ function parseTelegramConfigRecord(value: unknown): ChannelBridgeServerTelegramC const outWebhook: { enabled?: boolean; host?: string; port?: number } = {}; if (typeof webhook.enabled === 'boolean') outWebhook.enabled = webhook.enabled; if (typeof webhook.host === 'string') outWebhook.host = webhook.host; - if (typeof webhook.port === 'number' && Number.isFinite(webhook.port)) outWebhook.port = Math.trunc(webhook.port); + if (webhook.port !== undefined) { + if ( + typeof webhook.port !== 'number' + || !Number.isFinite(webhook.port) + || !Number.isInteger(webhook.port) + || webhook.port < 1 + || webhook.port > 65_535 + ) { + throw new ChannelBridgeBadPayloadError('Invalid telegram.webhook.port payload'); + } + outWebhook.port = webhook.port; + } out.telegram.webhook = outWebhook; } diff --git a/apps/cli/src/channels/startChannelBridgeWorker.ts b/apps/cli/src/channels/startChannelBridgeWorker.ts index f1aa88aa88..1f492b15b1 100644 --- a/apps/cli/src/channels/startChannelBridgeWorker.ts +++ b/apps/cli/src/channels/startChannelBridgeWorker.ts @@ -152,6 +152,7 @@ function createDefaultChannelBridgeDeps(credentials: Credentials): DefaultChanne return { deps: { listSessions: async () => { + const displayLimit = 20; const pageSize = 100; const maxPages = 50; const sessions: RawSessionListRow[] = []; @@ -165,6 +166,10 @@ function createDefaultChannelBridgeDeps(credentials: Credentials): DefaultChanne cursor, }); sessions.push(...page.sessions); + if (sessions.length >= displayLimit) { + cursor = undefined; + break; + } if (!page.hasNext || !page.nextCursor) { cursor = undefined; break; @@ -176,7 +181,7 @@ function createDefaultChannelBridgeDeps(credentials: Credentials): DefaultChanne logger.warn(`[channelBridge] Session list pagination stopped after ${maxPages} pages; truncating /sessions results`); } - return sessions.map((row) => { + return sessions.slice(0, displayLimit).map((row) => { const meta = tryDecryptSessionMetadata({ credentials, rawSession: row }); const tagRaw = meta?.tag; const tag = typeof tagRaw === 'string' ? tagRaw.trim() : ''; diff --git a/apps/cli/src/channels/telegram/telegramAdapter.test.ts b/apps/cli/src/channels/telegram/telegramAdapter.test.ts index 53d11cd432..663c26890b 100644 --- a/apps/cli/src/channels/telegram/telegramAdapter.test.ts +++ b/apps/cli/src/channels/telegram/telegramAdapter.test.ts @@ -1,3 +1,4 @@ +import axios from 'axios'; import { beforeEach, describe, expect, it, vi } from 'vitest'; const { loggerWarn } = vi.hoisted(() => ({ @@ -267,10 +268,12 @@ describe('createTelegramChannelAdapter', () => { }); it('truncates outbound telegram messages above provider limit', async () => { + type SendMessageParams = Readonly<{ chatId: string; threadId: string | null; text: string }>; + const api = { getMe: vi.fn(async () => ({ id: 9, username: 'happier_bot' })), getUpdates: vi.fn(async () => []), - sendMessage: vi.fn(async () => undefined), + sendMessage: vi.fn(async (_params: SendMessageParams) => undefined), }; const adapter = createTelegramChannelAdapter({ @@ -286,8 +289,8 @@ describe('createTelegramChannelAdapter', () => { }); expect(api.sendMessage).toHaveBeenCalledTimes(1); - const call = api.sendMessage.mock.calls[0]?.[0]; - expect(call?.text).toHaveLength(4_096); + const calls = api.sendMessage.mock.calls as SendMessageParams[][]; + expect(calls[0]?.[0]?.text).toHaveLength(4_096); expect(loggerWarn).toHaveBeenCalledWith( expect.stringContaining('Truncated Telegram outbound message for conversation 1234 to 4096 characters'), ); @@ -346,4 +349,28 @@ describe('createTelegramChannelAdapter', () => { await adapter.pullInboundMessages(); expect(getUpdates).toHaveBeenNthCalledWith(3, { offset: 12, limit: 100 }); }); + + it('includes Telegram API error descriptions when available', async () => { + const postSpy = vi.spyOn(axios, 'post').mockResolvedValue({ + status: 400, + data: { + ok: false, + description: 'Bad Request: chat not found', + }, + } as never); + + try { + const adapter = createTelegramChannelAdapter({ + botToken: 'test-token', + }); + + await expect(adapter.sendMessage({ + conversationId: '-100999', + threadId: null, + text: 'hello', + })).rejects.toThrow('Bad Request: chat not found'); + } finally { + postSpy.mockRestore(); + } + }); }); diff --git a/apps/cli/src/channels/telegram/telegramAdapter.ts b/apps/cli/src/channels/telegram/telegramAdapter.ts index 43665c2f31..0f802dd141 100644 --- a/apps/cli/src/channels/telegram/telegramAdapter.ts +++ b/apps/cli/src/channels/telegram/telegramAdapter.ts @@ -19,6 +19,14 @@ function telegramApiUrl(botToken: string, method: string): string { return `https://api.telegram.org/bot${botToken}/${method}`; } +function formatTelegramApiFailure(method: 'getMe' | 'getUpdates' | 'sendMessage', status: number, data: unknown): string { + const record = asRecord(data); + const description = record && typeof record.description === 'string' ? record.description.trim() : ''; + return description.length > 0 + ? `Telegram ${method} failed (${status}): ${description}` + : `Telegram ${method} failed (${status})`; +} + function createDefaultTelegramApiClient(botToken: string): TelegramApiClient { return { getMe: async () => { @@ -27,7 +35,7 @@ function createDefaultTelegramApiClient(botToken: string): TelegramApiClient { validateStatus: () => true, }); if (response.status !== 200 || !response.data || response.data.ok !== true || !response.data.result) { - throw new Error(`Telegram getMe failed (${response.status})`); + throw new Error(formatTelegramApiFailure('getMe', response.status, response.data)); } const user = response.data.result; return { @@ -46,7 +54,7 @@ function createDefaultTelegramApiClient(botToken: string): TelegramApiClient { validateStatus: () => true, }); if (response.status !== 200 || !response.data || response.data.ok !== true || !Array.isArray(response.data.result)) { - throw new Error(`Telegram getUpdates failed (${response.status})`); + throw new Error(formatTelegramApiFailure('getUpdates', response.status, response.data)); } return response.data.result; }, @@ -64,7 +72,7 @@ function createDefaultTelegramApiClient(botToken: string): TelegramApiClient { validateStatus: () => true, }); if (response.status !== 200 || !response.data || response.data.ok !== true) { - throw new Error(`Telegram sendMessage failed (${response.status})`); + throw new Error(formatTelegramApiFailure('sendMessage', response.status, response.data)); } }, }; diff --git a/apps/cli/src/cli/commands/bridge.test.ts b/apps/cli/src/cli/commands/bridge.test.ts index 59249ccd3d..9318d271be 100644 --- a/apps/cli/src/cli/commands/bridge.test.ts +++ b/apps/cli/src/cli/commands/bridge.test.ts @@ -13,6 +13,7 @@ const decodeJwtPayloadMock = vi.fn(); const checkDaemonMock = vi.fn(); const createKvClientMock = vi.fn(); +const readKvConfigMock = vi.fn(); const upsertKvConfigMock = vi.fn(); const clearKvConfigMock = vi.fn(); @@ -34,7 +35,7 @@ vi.mock('@/channels/channelBridgeServerKv', () => ({ createAxiosChannelBridgeKvClient: createKvClientMock, upsertChannelBridgeTelegramConfigInKv: upsertKvConfigMock, clearChannelBridgeTelegramConfigInKv: clearKvConfigMock, - readChannelBridgeTelegramConfigFromKv: vi.fn(), + readChannelBridgeTelegramConfigFromKv: readKvConfigMock, })); describe('happier bridge command', () => { @@ -57,6 +58,7 @@ describe('happier bridge command', () => { }); createKvClientMock.mockReturnValue({ get: vi.fn(), mutate: vi.fn() }); + readKvConfigMock.mockResolvedValue({ record: null, version: -1 }); upsertKvConfigMock.mockResolvedValue(undefined); clearKvConfigMock.mockResolvedValue(undefined); @@ -277,4 +279,79 @@ describe('happier bridge command', () => { errorSpy.mockRestore(); } }); + + it('rolls back shared KV update when local scoped settings write fails', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + updateSettingsMock.mockRejectedValueOnce(new Error('settings unavailable')); + + try { + const { handleBridgeCliCommand } = await import('./bridge'); + + await handleBridgeCliCommand({ + args: ['bridge', 'telegram', 'set', '--bot-token', 'bot-token-1', '--require-topics', 'true'], + rawArgv: [], + terminalRuntime: null, + }); + + expect(upsertKvConfigMock).toHaveBeenCalledTimes(1); + expect(clearKvConfigMock).toHaveBeenCalledWith({ + kv: expect.any(Object), + serverId: configuration.activeServerId, + accountId: 'acct_123', + }); + expect(process.exitCode).toBe(1); + expect(errorSpy).toHaveBeenCalledWith(expect.anything(), expect.stringContaining('settings unavailable')); + } finally { + errorSpy.mockRestore(); + } + }); + + it('restores previous shared KV config when local clear fails', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + updateSettingsMock.mockRejectedValueOnce(new Error('settings clear unavailable')); + readKvConfigMock.mockResolvedValueOnce({ + record: { + schemaVersion: 1, + updatedAtMs: 123, + telegram: { + allowedChatIds: ['-100111'], + requireTopics: true, + webhook: { + enabled: true, + host: '127.0.0.1', + port: 8787, + }, + }, + }, + version: 4, + }); + + try { + const { handleBridgeCliCommand } = await import('./bridge'); + + await handleBridgeCliCommand({ + args: ['bridge', 'telegram', 'clear'], + rawArgv: [], + terminalRuntime: null, + }); + + expect(clearKvConfigMock).toHaveBeenCalledTimes(1); + expect(upsertKvConfigMock).toHaveBeenCalledWith({ + kv: expect.any(Object), + serverId: configuration.activeServerId, + accountId: 'acct_123', + update: { + allowedChatIds: ['-100111'], + requireTopics: true, + webhookEnabled: true, + webhookHost: '127.0.0.1', + webhookPort: 8787, + }, + }); + expect(process.exitCode).toBe(1); + expect(errorSpy).toHaveBeenCalledWith(expect.anything(), expect.stringContaining('settings clear unavailable')); + } finally { + errorSpy.mockRestore(); + } + }); }); diff --git a/apps/cli/src/cli/commands/bridge.ts b/apps/cli/src/cli/commands/bridge.ts index 9d61dfc13b..6d78de90ce 100644 --- a/apps/cli/src/cli/commands/bridge.ts +++ b/apps/cli/src/cli/commands/bridge.ts @@ -14,6 +14,7 @@ import { import { resolveChannelBridgeRuntimeConfig } from '@/channels/channelBridgeConfig'; import { clearChannelBridgeTelegramConfigInKv, + type ChannelBridgeServerTelegramConfigRecord, createAxiosChannelBridgeKvClient, readChannelBridgeTelegramConfigFromKv, upsertChannelBridgeTelegramConfigInKv, @@ -76,6 +77,75 @@ async function resolveActiveAuthContext(): Promise | null { + if (!record) return null; + + const restored: { + tickMs?: number; + allowedChatIds?: string[]; + requireTopics?: boolean; + webhookEnabled?: boolean; + webhookHost?: string; + webhookPort?: number; + } = {}; + + if (typeof record.tickMs === 'number' && Number.isFinite(record.tickMs)) { + restored.tickMs = Math.trunc(record.tickMs); + } + if (Array.isArray(record.telegram.allowedChatIds)) { + restored.allowedChatIds = [...record.telegram.allowedChatIds]; + } + if (typeof record.telegram.requireTopics === 'boolean') { + restored.requireTopics = record.telegram.requireTopics; + } + + const webhook = record.telegram.webhook; + if (typeof webhook?.enabled === 'boolean') { + restored.webhookEnabled = webhook.enabled; + } + if (typeof webhook?.host === 'string') { + restored.webhookHost = webhook.host; + } + if (typeof webhook?.port === 'number' && Number.isFinite(webhook.port)) { + restored.webhookPort = Math.trunc(webhook.port); + } + + return restored; +} + +async function rollbackServerKvAfterLocalSettingsFailure(params: Readonly<{ + kv: ReturnType; + serverId: string; + accountId: string; + previousRecord: ChannelBridgeServerTelegramConfigRecord | null; +}>): Promise { + const rollbackUpdate = restoreSharedTelegramUpdateFromKvRecord(params.previousRecord); + if (!rollbackUpdate || Object.keys(rollbackUpdate).length === 0) { + await clearChannelBridgeTelegramConfigInKv({ + kv: params.kv, + serverId: params.serverId, + accountId: params.accountId, + }); + return; + } + + await upsertChannelBridgeTelegramConfigInKv({ + kv: params.kv, + serverId: params.serverId, + accountId: params.accountId, + update: rollbackUpdate, + }); +} + function showBridgeHelp(): void { console.log(` ${chalk.bold('happier bridge')} - Channel bridge configuration (account-scoped) @@ -271,9 +341,21 @@ async function cmdTelegramSet(args: string[]): Promise { } const split = splitScopedTelegramBridgeUpdate({ update }); + const shouldWriteSharedKv = hasSharedTelegramBridgeUpdate({ update: split.sharedUpdate }); + + let kv: ReturnType | null = null; + let previousServerKvRecord: ChannelBridgeServerTelegramConfigRecord | null = null; + + if (shouldWriteSharedKv) { + kv = createAxiosChannelBridgeKvClient({ token: auth.token }); + const previous = await readChannelBridgeTelegramConfigFromKv({ + kv, + serverId, + accountId, + allowUnsupportedSchema: true, + }); + previousServerKvRecord = previous.record; - if (hasSharedTelegramBridgeUpdate({ update: split.sharedUpdate })) { - const kv = createAxiosChannelBridgeKvClient({ token: auth.token }); await upsertChannelBridgeTelegramConfigInKv({ kv, serverId, @@ -282,20 +364,40 @@ async function cmdTelegramSet(args: string[]): Promise { }); } - await updateSettings(async (current) => - upsertScopedTelegramBridgeConfig({ - settings: current, - serverId, - accountId, - update: split.localUpdate, - }), - ); + try { + await updateSettings(async (current) => + upsertScopedTelegramBridgeConfig({ + settings: current, + serverId, + accountId, + update: split.localUpdate, + }), + ); + } catch (error) { + if (shouldWriteSharedKv && kv) { + try { + await rollbackServerKvAfterLocalSettingsFailure({ + kv, + serverId, + accountId, + previousRecord: previousServerKvRecord, + }); + } catch (rollbackError) { + const primaryMessage = error instanceof Error ? error.message : String(error); + const rollbackMessage = rollbackError instanceof Error ? rollbackError.message : String(rollbackError); + throw new Error( + `Local settings update failed and server KV rollback failed (${primaryMessage}; rollback: ${rollbackMessage})`, + ); + } + } + throw error; + } console.log(chalk.green('✓ Saved Telegram bridge config for active account scope')); console.log(` Server: ${serverId}`); console.log(` Account: ${accountId}`); console.log( - hasSharedTelegramBridgeUpdate({ update: split.sharedUpdate }) + shouldWriteSharedKv ? ' Persisted: non-secret fields -> server KV, full config -> scoped settings.json' : ' Persisted: secrets-only update -> scoped settings.json (server KV unchanged)', ); @@ -312,19 +414,44 @@ async function cmdTelegramClear(): Promise { const accountId = auth.accountId; const kv = createAxiosChannelBridgeKvClient({ token: auth.token }); + const previous = await readChannelBridgeTelegramConfigFromKv({ + kv, + serverId, + accountId, + allowUnsupportedSchema: true, + }); + await clearChannelBridgeTelegramConfigInKv({ kv, serverId, accountId, }); - await updateSettings(async (current) => - removeScopedTelegramBridgeConfig({ - settings: current, - serverId, - accountId, - }), - ); + try { + await updateSettings(async (current) => + removeScopedTelegramBridgeConfig({ + settings: current, + serverId, + accountId, + }), + ); + } catch (error) { + try { + await rollbackServerKvAfterLocalSettingsFailure({ + kv, + serverId, + accountId, + previousRecord: previous.record, + }); + } catch (rollbackError) { + const primaryMessage = error instanceof Error ? error.message : String(error); + const rollbackMessage = rollbackError instanceof Error ? rollbackError.message : String(rollbackError); + throw new Error( + `Scoped settings clear failed and server KV rollback failed (${primaryMessage}; rollback: ${rollbackMessage})`, + ); + } + throw error; + } console.log(chalk.green('✓ Cleared Telegram bridge config for active account scope')); console.log(` Server: ${serverId}`); diff --git a/apps/cli/src/daemon/startDaemon.automation.integration.test.ts b/apps/cli/src/daemon/startDaemon.automation.integration.test.ts index 2410b2d69d..21f921a1be 100644 --- a/apps/cli/src/daemon/startDaemon.automation.integration.test.ts +++ b/apps/cli/src/daemon/startDaemon.automation.integration.test.ts @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; type ShutdownSource = 'happier-app' | 'happier-cli' | 'os-signal' | 'exception'; type BuildHappyCliSubprocessLaunchSpec = typeof import('@/utils/spawnHappyCLI').buildHappyCliSubprocessLaunchSpec; +type BridgeWorkerHandle = Readonly<{ stop: () => Promise; trigger: () => void }>; const harness = vi.hoisted(() => { let resolveShutdown: ((value: { source: ShutdownSource; errorMessage?: string }) => void) | null = null; @@ -13,7 +14,7 @@ const harness = vi.hoisted(() => { const automationWorkerRefreshAssignments = vi.fn(async () => {}); const automationWorkerPause = vi.fn(); const automationWorkerResume = vi.fn(); - const startChannelBridgeFromEnv = vi.fn(async () => null); + const startChannelBridgeFromEnv = vi.fn<() => Promise>(async () => null); const startAutomationWorker = vi.fn(() => { if (autoShutdownAfterAutomationStart && requestShutdownRef) { setTimeout(() => requestShutdownRef?.('happier-cli'), 0); @@ -508,7 +509,7 @@ describe('startDaemon automation wiring (integration)', () => { await new Promise((resolve) => setTimeout(resolve, 0)); harness.requestShutdown('happier-cli'); - resolveBridgeStartup?.(); + if (resolveBridgeStartup) resolveBridgeStartup(); await run; expect(harness.startChannelBridgeFromEnv).toHaveBeenCalledTimes(1); diff --git a/apps/cli/src/ui/doctor.ts b/apps/cli/src/ui/doctor.ts index cf0e78b36f..0b3e64fc18 100644 --- a/apps/cli/src/ui/doctor.ts +++ b/apps/cli/src/ui/doctor.ts @@ -358,7 +358,7 @@ export async function runDoctorCommand(filter?: 'all' | 'daemon'): Promise } // Settings - let settingsSnapshot: unknown = null; + let settingsSnapshot: Awaited> | null = null; try { settingsSnapshot = await readSettings(); console.log(chalk.bold('\n📄 Settings (settings.json):')); @@ -427,11 +427,29 @@ export async function runDoctorCommand(filter?: 'all' | 'daemon'): Promise const serverScope = serverId ? asRecord(byServerId?.[serverId]) : null; const byAccountId = asRecord(serverScope?.byAccountId); const accountScope = accountId ? asRecord(byAccountId?.[accountId]) : null; - const providers = asRecord(accountScope?.providers); - const telegram = asRecord(providers?.telegram); - const providerEntries = providers - ? Object.entries(providers) - : []; + const globalProviders = asRecord(channelBridgeRoot?.providers); + const serverProviders = asRecord(serverScope?.providers); + const accountProviders = asRecord(accountScope?.providers); + const telegram = + asRecord(accountProviders?.telegram) + ?? asRecord(serverProviders?.telegram) + ?? asRecord(globalProviders?.telegram); + const providerMap = new Map>(); + + const mergeProviders = (providersRecord: Record | null): void => { + if (!providersRecord) return; + for (const [providerId, providerConfig] of Object.entries(providersRecord)) { + const providerEntry = asRecord(providerConfig); + if (!providerEntry) continue; + providerMap.set(providerId, providerEntry); + } + }; + + mergeProviders(globalProviders); + mergeProviders(serverProviders); + mergeProviders(accountProviders); + + const providerEntries = [...providerMap.entries()]; const runtimeBridge = resolveChannelBridgeRuntimeConfig({ env: process.env, From 82b6ab3b70d09b7001869ba5b8bdf624b96890e1 Mon Sep 17 00:00:00 2001 From: Chris Denneen Date: Fri, 6 Mar 2026 15:42:08 -0500 Subject: [PATCH 10/81] optimize session listing and tighten bridge docs/tests --- .../channels/startChannelBridgeWorker.test.ts | 4 ++-- .../src/channels/startChannelBridgeWorker.ts | 4 ++-- .../telegram/telegramWebhookRelay.test.ts | 22 ++++++++++--------- docs/telegram-channel-bridge.md | 2 +- 4 files changed, 17 insertions(+), 15 deletions(-) diff --git a/apps/cli/src/channels/startChannelBridgeWorker.test.ts b/apps/cli/src/channels/startChannelBridgeWorker.test.ts index ce58115349..e44eca6096 100644 --- a/apps/cli/src/channels/startChannelBridgeWorker.test.ts +++ b/apps/cli/src/channels/startChannelBridgeWorker.test.ts @@ -131,13 +131,13 @@ describe('startChannelBridgeFromEnv', () => { expect(fetchSessionsPage).toHaveBeenNthCalledWith(1, { token: credentials.token, activeOnly: false, - limit: 100, + limit: 20, cursor: undefined, }); expect(fetchSessionsPage).toHaveBeenNthCalledWith(2, { token: credentials.token, activeOnly: false, - limit: 100, + limit: 20, cursor: 'cursor-1', }); expect(sessions.map((row) => row.sessionId)).toEqual(['sess-1', 'sess-2']); diff --git a/apps/cli/src/channels/startChannelBridgeWorker.ts b/apps/cli/src/channels/startChannelBridgeWorker.ts index 1f492b15b1..ad54aaaebe 100644 --- a/apps/cli/src/channels/startChannelBridgeWorker.ts +++ b/apps/cli/src/channels/startChannelBridgeWorker.ts @@ -153,8 +153,8 @@ function createDefaultChannelBridgeDeps(credentials: Credentials): DefaultChanne deps: { listSessions: async () => { const displayLimit = 20; - const pageSize = 100; - const maxPages = 50; + const pageSize = displayLimit; + const maxPages = displayLimit; const sessions: RawSessionListRow[] = []; let cursor: string | undefined; diff --git a/apps/cli/src/channels/telegram/telegramWebhookRelay.test.ts b/apps/cli/src/channels/telegram/telegramWebhookRelay.test.ts index e18d3c3fb9..5864adf633 100644 --- a/apps/cli/src/channels/telegram/telegramWebhookRelay.test.ts +++ b/apps/cli/src/channels/telegram/telegramWebhookRelay.test.ts @@ -92,17 +92,19 @@ describe('startTelegramWebhookRelay', () => { it('does not acknowledge webhook updates when onUpdate fails', async () => { const warnSpy = vi.spyOn(logger, 'warn').mockImplementation(() => undefined); - const relay = await startTelegramWebhookRelay({ - port: 0, - host: '127.0.0.1', - secretPathToken: 'secret-fail', - secretHeaderToken: 'secret-fail', - onUpdate: async () => { - throw new Error('failed to process update'); - }, - }); + let relay: Awaited> | null = null; try { + relay = await startTelegramWebhookRelay({ + port: 0, + host: '127.0.0.1', + secretPathToken: 'secret-fail', + secretHeaderToken: 'secret-fail', + onUpdate: async () => { + throw new Error('failed to process update'); + }, + }); + const response = await axios.post( `http://127.0.0.1:${relay.port}${relay.path}`, { @@ -128,7 +130,7 @@ describe('startTelegramWebhookRelay', () => { ); } finally { warnSpy.mockRestore(); - await relay.stop(); + await relay?.stop(); } }); diff --git a/docs/telegram-channel-bridge.md b/docs/telegram-channel-bridge.md index fe91ff5a05..0dc75ba2ef 100644 --- a/docs/telegram-channel-bridge.md +++ b/docs/telegram-channel-bridge.md @@ -167,7 +167,7 @@ happier daemon stop && happier daemon start Telegram bridge example: - if Telegram bridge is configured but bot token is missing, doctor prints a red bridge error and the final line is `❌` -- if `webhook.enabled=true` and `webhook.secret` is empty, doctor treats it as critical and final line is `❌` +- if `webhook.enabled=true` and `secrets.webhookSecret` (or `HAPPIER_TELEGRAM_WEBHOOK_SECRET`) is empty, doctor treats it as critical and final line is `❌` - if `webhook.enabled=true` and `webhook.host` or `webhook.port` is missing/invalid, doctor treats it as critical and final line is `❌` Generic adapter behavior: From a30a9edfb767d1459eaa7f8483be29c6666592d1 Mon Sep 17 00:00:00 2001 From: Chris Denneen Date: Fri, 6 Mar 2026 15:44:01 -0500 Subject: [PATCH 11/81] stabilize cache-fallback regression test --- apps/cli/src/channels/channelBindingStore.server.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/apps/cli/src/channels/channelBindingStore.server.test.ts b/apps/cli/src/channels/channelBindingStore.server.test.ts index 230fd3bafa..447e212c50 100644 --- a/apps/cli/src/channels/channelBindingStore.server.test.ts +++ b/apps/cli/src/channels/channelBindingStore.server.test.ts @@ -607,8 +607,10 @@ describe('createServerBackedChannelBindingStore', () => { }, ]); + await new Promise((resolve) => setTimeout(resolve, 1)); const second = await store.listBindings(); expect(second).toEqual(first); + expect(getCalls).toBeGreaterThanOrEqual(2); }); it('uses conflict payload state for retry without extra primary read', async () => { From e9d41ff6df78666f2e6262637832e3c9f0573540 Mon Sep 17 00:00:00 2001 From: Chris Denneen Date: Fri, 6 Mar 2026 16:07:08 -0500 Subject: [PATCH 12/81] clarify ack-on-failure bridge warning --- apps/cli/src/channels/core/channelBridgeWorker.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/cli/src/channels/core/channelBridgeWorker.ts b/apps/cli/src/channels/core/channelBridgeWorker.ts index bd34b7f3cc..6075e6ca26 100644 --- a/apps/cli/src/channels/core/channelBridgeWorker.ts +++ b/apps/cli/src/channels/core/channelBridgeWorker.ts @@ -724,7 +724,7 @@ export async function executeChannelBridgeTick(params: Readonly<{ ); } catch (error) { params.deps.onWarning?.( - `Failed to forward channel message into session ${binding.sessionId} (provider=${adapter.providerId} conversation=${event.conversationId} thread=${event.threadId ?? 'null'} messageId=${event.messageId})`, + `Failed to forward channel message into session ${binding.sessionId} (provider=${adapter.providerId} conversation=${event.conversationId} thread=${event.threadId ?? 'null'} messageId=${event.messageId}); message will not be retried because the user is notified in-channel`, error, ); await replyToConversation( From 22479d6d628d1b5fc854b441ea87b41296e4a106 Mon Sep 17 00:00:00 2001 From: Chris Denneen Date: Fri, 6 Mar 2026 16:41:09 -0500 Subject: [PATCH 13/81] Harden telegram bridge config, worker timeouts, and KV rollback --- .../src/channels/channelBridgeConfig.test.ts | 19 +++ apps/cli/src/channels/channelBridgeConfig.ts | 6 +- .../channels/channelBridgeServerKv.test.ts | 63 ++++++++++ .../cli/src/channels/channelBridgeServerKv.ts | 66 +++++++++-- .../channels/core/channelBridgeWorker.test.ts | 4 +- .../src/channels/core/channelBridgeWorker.ts | 109 ++++++++++++++---- apps/cli/src/cli/commands/bridge.test.ts | 64 ++++++++-- apps/cli/src/cli/commands/bridge.ts | 74 ++++-------- 8 files changed, 310 insertions(+), 95 deletions(-) diff --git a/apps/cli/src/channels/channelBridgeConfig.test.ts b/apps/cli/src/channels/channelBridgeConfig.test.ts index 7f350aa90c..184eaace36 100644 --- a/apps/cli/src/channels/channelBridgeConfig.test.ts +++ b/apps/cli/src/channels/channelBridgeConfig.test.ts @@ -129,6 +129,25 @@ describe('resolveChannelBridgeRuntimeConfig', () => { expect(config.telegram.webhookPort).toBe(8_877); }); + it('falls back to settings allowedChatIds when env CSV is effectively empty', () => { + const config = resolveChannelBridgeRuntimeConfig({ + env: { + HAPPIER_TELEGRAM_ALLOWED_CHAT_IDS: ', ,', + }, + settings: { + channelBridge: { + providers: { + telegram: { + allowedChatIds: ['-100settings'], + }, + }, + }, + }, + }); + + expect(config.telegram.allowedChatIds).toEqual(['-100settings']); + }); + it('rejects webhook port zero and falls back to default', () => { const config = resolveChannelBridgeRuntimeConfig({ env: { diff --git a/apps/cli/src/channels/channelBridgeConfig.ts b/apps/cli/src/channels/channelBridgeConfig.ts index d7f5348ee5..823e1c56f3 100644 --- a/apps/cli/src/channels/channelBridgeConfig.ts +++ b/apps/cli/src/channels/channelBridgeConfig.ts @@ -157,9 +157,13 @@ export function resolveChannelBridgeRuntimeConfig(params: Readonly<{ typeof env.HAPPIER_TELEGRAM_ALLOWED_CHAT_IDS === 'string' ? env.HAPPIER_TELEGRAM_ALLOWED_CHAT_IDS.trim() : null; - const allowedChatIds = + const parsedEnvAllowedChatIds = envAllowedChatIdsRaw && envAllowedChatIdsRaw.length > 0 ? parseCsv(envAllowedChatIdsRaw) + : null; + const allowedChatIds = + parsedEnvAllowedChatIds && parsedEnvAllowedChatIds.length > 0 + ? parsedEnvAllowedChatIds : settingsAllowedChatIds; const settingsRequireTopics = parseBoolean(telegram?.requireTopics) ?? false; diff --git a/apps/cli/src/channels/channelBridgeServerKv.test.ts b/apps/cli/src/channels/channelBridgeServerKv.test.ts index 08da5043fa..3a419cc3a0 100644 --- a/apps/cli/src/channels/channelBridgeServerKv.test.ts +++ b/apps/cli/src/channels/channelBridgeServerKv.test.ts @@ -5,6 +5,7 @@ import { type ChannelBridgeKvClient, createAxiosChannelBridgeKvClient, clearChannelBridgeTelegramConfigInKv, + replaceChannelBridgeTelegramConfigRawInKv, readChannelBridgeBindingsFromKv, readChannelBridgeTelegramConfigFromKv, upsertChannelBridgeTelegramConfigInKv, @@ -240,6 +241,67 @@ describe('channelBridgeServerKv', () => { expect(config.record).toBeNull(); }); + it('rejects malformed non-empty allowedChatIds updates', async () => { + const kv = createInMemoryKvClient(); + + await expect(upsertChannelBridgeTelegramConfigInKv({ + kv, + serverId: 'local-3005', + accountId: 'acct-1', + update: { + allowedChatIds: [' '], + }, + })).rejects.toThrow('Invalid telegram.allowedChatIds update payload'); + }); + + it('rejects invalid webhook port updates', async () => { + const kv = createInMemoryKvClient(); + + await expect(upsertChannelBridgeTelegramConfigInKv({ + kv, + serverId: 'local-3005', + accountId: 'acct-1', + update: { + webhookPort: 0, + }, + })).rejects.toThrow('Invalid telegram.webhook.port update payload'); + }); + + it('replaces raw telegram config bytes in KV without schema parsing', async () => { + const kv = createInMemoryKvClient(); + + await upsertChannelBridgeTelegramConfigInKv({ + kv, + serverId: 'local-3005', + accountId: 'acct-1', + update: { + requireTopics: true, + }, + }); + + const rawUnsupported = Buffer.from(JSON.stringify({ + schemaVersion: 999, + unsupported: true, + }), 'utf8').toString('base64'); + + await replaceChannelBridgeTelegramConfigRawInKv({ + kv, + serverId: 'local-3005', + accountId: 'acct-1', + valueBase64: rawUnsupported, + }); + + const readWithUnsupported = await readChannelBridgeTelegramConfigFromKv({ + kv, + serverId: 'local-3005', + accountId: 'acct-1', + allowUnsupportedSchema: true, + }); + + expect(readWithUnsupported.record).toBeNull(); + expect(readWithUnsupported.rawValueBase64).toBe(rawUnsupported); + }); + it('upsert replaces unsupported telegram config schema instead of failing', async () => { const unknownSchemaValueBase64 = Buffer.from(JSON.stringify({ schemaVersion: 999, @@ -850,6 +912,7 @@ describe('channelBridgeServerKv', () => { })).resolves.toEqual({ record: null, version: 3, + rawValueBase64: malformedValueBase64, }); }); }); diff --git a/apps/cli/src/channels/channelBridgeServerKv.ts b/apps/cli/src/channels/channelBridgeServerKv.ts index 2922b459d2..1a2be0b13c 100644 --- a/apps/cli/src/channels/channelBridgeServerKv.ts +++ b/apps/cli/src/channels/channelBridgeServerKv.ts @@ -414,11 +414,11 @@ export async function readChannelBridgeTelegramConfigFromKv(params: Readonly<{ serverId: string; accountId: string; allowUnsupportedSchema?: boolean; -}>): Promise> { +}>): Promise> { const key = telegramConfigKvKey(params.serverId, params.accountId); const row = await readJsonValue({ kv: params.kv, key }); if (!row.valueBase64) { - return { record: null, version: row.version }; + return { record: null, version: row.version, rawValueBase64: null }; } let decoded: unknown; @@ -426,7 +426,7 @@ export async function readChannelBridgeTelegramConfigFromKv(params: Readonly<{ decoded = decodeBase64ToJson(row.valueBase64); } catch (error) { if (params.allowUnsupportedSchema && error instanceof ChannelBridgeBadPayloadError) { - return { record: null, version: row.version }; + return { record: null, version: row.version, rawValueBase64: row.valueBase64 }; } throw error; } @@ -434,7 +434,7 @@ export async function readChannelBridgeTelegramConfigFromKv(params: Readonly<{ const parsed = parseTelegramConfigRecord(decoded); if (!parsed) { if (params.allowUnsupportedSchema) { - return { record: null, version: row.version }; + return { record: null, version: row.version, rawValueBase64: row.valueBase64 }; } throw new Error(`Invalid or unsupported Telegram config schema for key ${key}`); } @@ -442,9 +442,43 @@ export async function readChannelBridgeTelegramConfigFromKv(params: Readonly<{ return { record: parsed, version: row.version, + rawValueBase64: row.valueBase64, }; } +export async function replaceChannelBridgeTelegramConfigRawInKv(params: Readonly<{ + kv: ChannelBridgeKvClient; + serverId: string; + accountId: string; + valueBase64: string | null; +}>): Promise { + const key = telegramConfigKvKey(params.serverId, params.accountId); + let current = await readJsonValue({ kv: params.kv, key }); + + for (let attempt = 0; attempt < 4; attempt += 1) { + try { + await writeJsonValue({ + kv: params.kv, + key, + valueBase64: params.valueBase64, + expectedVersion: current.version, + }); + return; + } catch (error) { + if (error instanceof ChannelBridgeKvVersionMismatchError) { + current = { + valueBase64: error.currentValueBase64, + version: error.currentVersion, + }; + continue; + } + throw error; + } + } + + throw new Error(`Unable to replace Telegram bridge KV value after retries for key ${key}`); +} + function applyTelegramConfigUpdate(current: ChannelBridgeServerTelegramConfigRecord | null, update: ScopedTelegramBridgeUpdate): ChannelBridgeServerTelegramConfigRecord { const next: { schemaVersion: 1; @@ -475,7 +509,11 @@ function applyTelegramConfigUpdate(current: ChannelBridgeServerTelegramConfigRec } if (Array.isArray(update.allowedChatIds)) { - next.telegram.allowedChatIds = normalizeStringArray(update.allowedChatIds); + const normalizedAllowedChatIds = normalizeStringArray(update.allowedChatIds); + if (update.allowedChatIds.length > 0 && normalizedAllowedChatIds.length === 0) { + throw new ChannelBridgeBadPayloadError('Invalid telegram.allowedChatIds update payload'); + } + next.telegram.allowedChatIds = normalizedAllowedChatIds; } if (typeof update.requireTopics === 'boolean') { next.telegram.requireTopics = update.requireTopics; @@ -494,10 +532,22 @@ function applyTelegramConfigUpdate(current: ChannelBridgeServerTelegramConfigRec nextWebhook.enabled = update.webhookEnabled; } if (typeof update.webhookHost === 'string') { - nextWebhook.host = update.webhookHost; + const normalizedHost = update.webhookHost.trim(); + if (normalizedHost.length === 0) { + throw new ChannelBridgeBadPayloadError('Invalid telegram.webhook.host update payload'); + } + nextWebhook.host = normalizedHost; } - if (typeof update.webhookPort === 'number' && Number.isFinite(update.webhookPort)) { - nextWebhook.port = Math.trunc(update.webhookPort); + if (typeof update.webhookPort === 'number') { + if ( + !Number.isFinite(update.webhookPort) + || !Number.isInteger(update.webhookPort) + || update.webhookPort < 1 + || update.webhookPort > 65_535 + ) { + throw new ChannelBridgeBadPayloadError('Invalid telegram.webhook.port update payload'); + } + nextWebhook.port = update.webhookPort; } next.telegram.webhook = nextWebhook; } diff --git a/apps/cli/src/channels/core/channelBridgeWorker.test.ts b/apps/cli/src/channels/core/channelBridgeWorker.test.ts index 85fd91d3e8..f7e3e2fcf0 100644 --- a/apps/cli/src/channels/core/channelBridgeWorker.test.ts +++ b/apps/cli/src/channels/core/channelBridgeWorker.test.ts @@ -370,7 +370,7 @@ describe('executeChannelBridgeTick', () => { inboundDeduper: createChannelBridgeInboundDeduper(), }); - expect(harness.sent.some((row) => row.text.includes('Active sessions'))).toBe(true); + expect(harness.sent.some((row) => row.text.includes('Recent sessions'))).toBe(true); expect(harness.sent.some((row) => row.text.includes('Detached'))).toBe(true); const remaining = await store.listBindings(); @@ -1789,7 +1789,7 @@ describe('startChannelBridgeWorker', () => { try { await waitFor(() => harness.sent.length > 0); - expect(harness.sent.some((row) => row.text.includes('Active sessions'))).toBe(true); + expect(harness.sent.some((row) => row.text.includes('Recent sessions'))).toBe(true); } finally { await worker.stop(); } diff --git a/apps/cli/src/channels/core/channelBridgeWorker.ts b/apps/cli/src/channels/core/channelBridgeWorker.ts index 6075e6ca26..419dc91a7d 100644 --- a/apps/cli/src/channels/core/channelBridgeWorker.ts +++ b/apps/cli/src/channels/core/channelBridgeWorker.ts @@ -195,6 +195,22 @@ async function withTimeout(promise: Promise, timeoutMs: number, label: str } } +function parseTelegramSendMessageFailureStatus(error: unknown): number | null { + if (!(error instanceof Error)) return null; + const match = error.message.match(/Telegram sendMessage failed \((\d{3})\)/); + if (!match) return null; + const parsed = Number.parseInt(match[1] ?? '', 10); + return Number.isInteger(parsed) ? parsed : null; +} + +function isTelegramPermanentDeliveryFailure(error: unknown): boolean { + if (!(error instanceof Error)) return false; + const status = parseTelegramSendMessageFailureStatus(error); + if (status === 403) return true; + if (status === 400 && /chat not found/i.test(error.message)) return true; + return false; +} + type ChannelBridgeInboundDeduper = Readonly<{ isDuplicate: (message: ChannelBridgeInboundMessage) => boolean; }>; @@ -315,7 +331,7 @@ function formatSessionsMessage(rows: Array `• ${row.sessionId}${row.label ? ` (${row.label})` : ''}`) .join('\n'); const suffix = truncated ? `\n…and ${rows.length - limit} more.` : ''; - return `Active sessions:\n${body}${suffix}`; + return `Recent sessions:\n${body}${suffix}`; } async function replyToConversation( @@ -427,7 +443,11 @@ async function handleCommand(params: Readonly<{ if (command.name === 'sessions') { let sessions: Array>; try { - sessions = await deps.listSessions(); + sessions = await withTimeout( + deps.listSessions(), + EXTERNAL_IO_TIMEOUT_MS, + 'listSessions()', + ); } catch (error) { deps.onWarning?.('Failed to list sessions for /sessions command', error); await replyToConversation(adapter, ref, 'Failed to retrieve sessions. Please try again later.'); @@ -440,7 +460,11 @@ async function handleCommand(params: Readonly<{ if (command.name === 'session') { let existing: ChannelSessionBinding | null; try { - existing = await store.getBinding(ref); + existing = await withTimeout( + store.getBinding(ref), + EXTERNAL_IO_TIMEOUT_MS, + `store.getBinding(${ref.providerId}:${ref.conversationId}:${ref.threadId ?? 'null'})`, + ); } catch (error) { deps.onWarning?.('Failed to read binding for /session command', error); await replyToConversation(adapter, ref, 'Failed to read current session binding. Please try again later.'); @@ -464,7 +488,11 @@ async function handleCommand(params: Readonly<{ let resolved: ResolveSessionIdResult; try { - resolved = await deps.resolveSessionIdOrPrefix(idOrPrefix); + resolved = await withTimeout( + deps.resolveSessionIdOrPrefix(idOrPrefix), + EXTERNAL_IO_TIMEOUT_MS, + `resolveSessionIdOrPrefix(${idOrPrefix})`, + ); } catch (error) { deps.onWarning?.('Failed to resolve session by id/prefix for attach', error); await replyToConversation( @@ -500,7 +528,11 @@ async function handleCommand(params: Readonly<{ let latestSeq: number; try { - const resolvedSeq = toNonNegativeInt(await deps.resolveLatestSessionSeq(resolved.sessionId)); + const resolvedSeq = toNonNegativeInt(await withTimeout( + deps.resolveLatestSessionSeq(resolved.sessionId), + EXTERNAL_IO_TIMEOUT_MS, + `resolveLatestSessionSeq(${resolved.sessionId})`, + )); if (resolvedSeq === null) { deps.onWarning?.( `resolveLatestSessionSeq returned an invalid value for session ${resolved.sessionId}; expected a non-negative integer`, @@ -525,7 +557,11 @@ async function handleCommand(params: Readonly<{ let previousBinding: ChannelSessionBinding | null; try { - previousBinding = await store.getBinding(ref); + previousBinding = await withTimeout( + store.getBinding(ref), + EXTERNAL_IO_TIMEOUT_MS, + `store.getBinding(${ref.providerId}:${ref.conversationId}:${ref.threadId ?? 'null'})`, + ); } catch (error) { deps.onWarning?.('Failed to read existing binding during /attach', error); await replyToConversation(adapter, ref, 'Failed to read current binding before attach. Please try again later.'); @@ -534,13 +570,17 @@ async function handleCommand(params: Readonly<{ const previousSessionId = previousBinding?.sessionId ?? null; try { - await store.upsertBinding({ + await withTimeout( + store.upsertBinding({ providerId: ref.providerId, conversationId: ref.conversationId, threadId: ref.threadId, sessionId: resolved.sessionId, lastForwardedSeq: latestSeq, - }); + }), + EXTERNAL_IO_TIMEOUT_MS, + `store.upsertBinding(${ref.providerId}:${ref.conversationId}:${ref.threadId ?? 'null'})`, + ); } catch (error) { deps.onWarning?.('Failed to persist binding during /attach', error); await replyToConversation(adapter, ref, `Failed to attach to session ${resolved.sessionId}: unable to persist binding.`); @@ -558,7 +598,11 @@ async function handleCommand(params: Readonly<{ if (command.name === 'detach') { let removed = false; try { - removed = await store.removeBinding(ref); + removed = await withTimeout( + store.removeBinding(ref), + EXTERNAL_IO_TIMEOUT_MS, + `store.removeBinding(${ref.providerId}:${ref.conversationId}:${ref.threadId ?? 'null'})`, + ); } catch (error) { deps.onWarning?.('Failed to remove binding for /detach command', error); await replyToConversation(adapter, ref, 'Failed to detach current session binding. Please try again later.'); @@ -685,7 +729,11 @@ export async function executeChannelBridgeTick(params: Readonly<{ }; let binding: ChannelSessionBinding | null; try { - binding = await params.store.getBinding(ref); + binding = await withTimeout( + params.store.getBinding(ref), + EXTERNAL_IO_TIMEOUT_MS, + `store.getBinding(${ref.providerId}:${ref.conversationId}:${ref.threadId ?? 'null'})`, + ); } catch (error) { params.deps.onWarning?.( `Failed to read binding for inbound message forwarding (provider=${adapter.providerId} conversation=${event.conversationId} thread=${event.threadId ?? 'null'})`, @@ -758,7 +806,11 @@ export async function executeChannelBridgeTick(params: Readonly<{ let bindings: ChannelSessionBinding[]; try { - bindings = await params.store.listBindings(); + bindings = await withTimeout( + params.store.listBindings(), + EXTERNAL_IO_TIMEOUT_MS, + 'store.listBindings()', + ); } catch (error) { params.deps.onWarning?.('Failed to list bindings for outbound forwarding', error); return; @@ -820,7 +872,11 @@ export async function executeChannelBridgeTick(params: Readonly<{ const persistCursor = async (nextSeq: number): Promise => { try { maxSeq = nextSeq; - await params.store.updateLastForwardedSeq(binding, maxSeq); + await withTimeout( + params.store.updateLastForwardedSeq(binding, maxSeq), + EXTERNAL_IO_TIMEOUT_MS, + `store.updateLastForwardedSeq(${binding.providerId}:${binding.conversationId}:${binding.threadId ?? 'null'}:${binding.sessionId}:${maxSeq})`, + ); return true; } catch (error) { params.deps.onWarning?.( @@ -846,15 +902,26 @@ export async function executeChannelBridgeTick(params: Readonly<{ } continue; } - await withTimeout( - adapter.sendMessage({ - conversationId: binding.conversationId, - threadId: binding.threadId, - text, - }), - EXTERNAL_IO_TIMEOUT_MS, - `sendMessage(${adapter.providerId})`, - ); + try { + await withTimeout( + adapter.sendMessage({ + conversationId: binding.conversationId, + threadId: binding.threadId, + text, + }), + EXTERNAL_IO_TIMEOUT_MS, + `sendMessage(${adapter.providerId})`, + ); + } catch (error) { + if (adapter.providerId === 'telegram' && isTelegramPermanentDeliveryFailure(error)) { + params.deps.onWarning?.( + `Detected permanent Telegram delivery failure; advancing outbound cursor without retry for session=${binding.sessionId} provider=${binding.providerId} conversation=${binding.conversationId} seq=${nextSeq}`, + error, + ); + } else { + throw error; + } + } const persisted = await persistCursor(nextSeq); if (!persisted) { break; diff --git a/apps/cli/src/cli/commands/bridge.test.ts b/apps/cli/src/cli/commands/bridge.test.ts index 9318d271be..6a271ca6bf 100644 --- a/apps/cli/src/cli/commands/bridge.test.ts +++ b/apps/cli/src/cli/commands/bridge.test.ts @@ -16,6 +16,7 @@ const createKvClientMock = vi.fn(); const readKvConfigMock = vi.fn(); const upsertKvConfigMock = vi.fn(); const clearKvConfigMock = vi.fn(); +const replaceKvRawConfigMock = vi.fn(); vi.mock('@/persistence', () => ({ readCredentials: readCredentialsMock, @@ -35,6 +36,7 @@ vi.mock('@/channels/channelBridgeServerKv', () => ({ createAxiosChannelBridgeKvClient: createKvClientMock, upsertChannelBridgeTelegramConfigInKv: upsertKvConfigMock, clearChannelBridgeTelegramConfigInKv: clearKvConfigMock, + replaceChannelBridgeTelegramConfigRawInKv: replaceKvRawConfigMock, readChannelBridgeTelegramConfigFromKv: readKvConfigMock, })); @@ -58,9 +60,10 @@ describe('happier bridge command', () => { }); createKvClientMock.mockReturnValue({ get: vi.fn(), mutate: vi.fn() }); - readKvConfigMock.mockResolvedValue({ record: null, version: -1 }); + readKvConfigMock.mockResolvedValue({ record: null, version: -1, rawValueBase64: null }); upsertKvConfigMock.mockResolvedValue(undefined); clearKvConfigMock.mockResolvedValue(undefined); + replaceKvRawConfigMock.mockResolvedValue(undefined); homeDir = await mkdtemp(join(tmpdir(), 'happier-bridge-cmd-')); process.env.HAPPIER_HOME_DIR = homeDir; @@ -299,6 +302,40 @@ describe('happier bridge command', () => { serverId: configuration.activeServerId, accountId: 'acct_123', }); + expect(replaceKvRawConfigMock).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + expect(errorSpy).toHaveBeenCalledWith(expect.anything(), expect.stringContaining('settings unavailable')); + } finally { + errorSpy.mockRestore(); + } + }); + + it('restores raw pre-existing KV payload when local scoped settings write fails', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + const rawMalformedPayload = Buffer.from('not-json', 'utf8').toString('base64'); + updateSettingsMock.mockRejectedValueOnce(new Error('settings unavailable')); + readKvConfigMock.mockResolvedValueOnce({ + record: null, + version: 7, + rawValueBase64: rawMalformedPayload, + }); + + try { + const { handleBridgeCliCommand } = await import('./bridge'); + + await handleBridgeCliCommand({ + args: ['bridge', 'telegram', 'set', '--bot-token', 'bot-token-1', '--require-topics', 'true'], + rawArgv: [], + terminalRuntime: null, + }); + + expect(upsertKvConfigMock).toHaveBeenCalledTimes(1); + expect(replaceKvRawConfigMock).toHaveBeenCalledWith({ + kv: expect.any(Object), + serverId: configuration.activeServerId, + accountId: 'acct_123', + valueBase64: rawMalformedPayload, + }); expect(process.exitCode).toBe(1); expect(errorSpy).toHaveBeenCalledWith(expect.anything(), expect.stringContaining('settings unavailable')); } finally { @@ -309,6 +346,20 @@ describe('happier bridge command', () => { it('restores previous shared KV config when local clear fails', async () => { const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); updateSettingsMock.mockRejectedValueOnce(new Error('settings clear unavailable')); + const previousRawValueBase64 = Buffer.from(JSON.stringify({ + schemaVersion: 1, + updatedAtMs: 123, + telegram: { + allowedChatIds: ['-100111'], + requireTopics: true, + webhook: { + enabled: true, + host: '127.0.0.1', + port: 8787, + }, + }, + }), 'utf8').toString('base64'); + readKvConfigMock.mockResolvedValueOnce({ record: { schemaVersion: 1, @@ -324,6 +375,7 @@ describe('happier bridge command', () => { }, }, version: 4, + rawValueBase64: previousRawValueBase64, }); try { @@ -336,17 +388,11 @@ describe('happier bridge command', () => { }); expect(clearKvConfigMock).toHaveBeenCalledTimes(1); - expect(upsertKvConfigMock).toHaveBeenCalledWith({ + expect(replaceKvRawConfigMock).toHaveBeenCalledWith({ kv: expect.any(Object), serverId: configuration.activeServerId, accountId: 'acct_123', - update: { - allowedChatIds: ['-100111'], - requireTopics: true, - webhookEnabled: true, - webhookHost: '127.0.0.1', - webhookPort: 8787, - }, + valueBase64: previousRawValueBase64, }); expect(process.exitCode).toBe(1); expect(errorSpy).toHaveBeenCalledWith(expect.anything(), expect.stringContaining('settings clear unavailable')); diff --git a/apps/cli/src/cli/commands/bridge.ts b/apps/cli/src/cli/commands/bridge.ts index 6d78de90ce..122198a543 100644 --- a/apps/cli/src/cli/commands/bridge.ts +++ b/apps/cli/src/cli/commands/bridge.ts @@ -14,9 +14,9 @@ import { import { resolveChannelBridgeRuntimeConfig } from '@/channels/channelBridgeConfig'; import { clearChannelBridgeTelegramConfigInKv, - type ChannelBridgeServerTelegramConfigRecord, createAxiosChannelBridgeKvClient, readChannelBridgeTelegramConfigFromKv, + replaceChannelBridgeTelegramConfigRawInKv, upsertChannelBridgeTelegramConfigInKv, } from '@/channels/channelBridgeServerKv'; import { overlayServerKvTelegramConfigInSettings } from '@/channels/channelBridgeServerConfigOverlay'; @@ -77,59 +77,18 @@ async function resolveActiveAuthContext(): Promise | null { - if (!record) return null; - - const restored: { - tickMs?: number; - allowedChatIds?: string[]; - requireTopics?: boolean; - webhookEnabled?: boolean; - webhookHost?: string; - webhookPort?: number; - } = {}; - - if (typeof record.tickMs === 'number' && Number.isFinite(record.tickMs)) { - restored.tickMs = Math.trunc(record.tickMs); - } - if (Array.isArray(record.telegram.allowedChatIds)) { - restored.allowedChatIds = [...record.telegram.allowedChatIds]; - } - if (typeof record.telegram.requireTopics === 'boolean') { - restored.requireTopics = record.telegram.requireTopics; - } - - const webhook = record.telegram.webhook; - if (typeof webhook?.enabled === 'boolean') { - restored.webhookEnabled = webhook.enabled; - } - if (typeof webhook?.host === 'string') { - restored.webhookHost = webhook.host; - } - if (typeof webhook?.port === 'number' && Number.isFinite(webhook.port)) { - restored.webhookPort = Math.trunc(webhook.port); - } - - return restored; -} +type TelegramKvRawSnapshot = Readonly<{ + version: number; + rawValueBase64: string | null; +}>; async function rollbackServerKvAfterLocalSettingsFailure(params: Readonly<{ kv: ReturnType; serverId: string; accountId: string; - previousRecord: ChannelBridgeServerTelegramConfigRecord | null; + previousSnapshot: TelegramKvRawSnapshot; }>): Promise { - const rollbackUpdate = restoreSharedTelegramUpdateFromKvRecord(params.previousRecord); - if (!rollbackUpdate || Object.keys(rollbackUpdate).length === 0) { + if (params.previousSnapshot.version < 0 && params.previousSnapshot.rawValueBase64 === null) { await clearChannelBridgeTelegramConfigInKv({ kv: params.kv, serverId: params.serverId, @@ -138,11 +97,11 @@ async function rollbackServerKvAfterLocalSettingsFailure(params: Readonly<{ return; } - await upsertChannelBridgeTelegramConfigInKv({ + await replaceChannelBridgeTelegramConfigRawInKv({ kv: params.kv, serverId: params.serverId, accountId: params.accountId, - update: rollbackUpdate, + valueBase64: params.previousSnapshot.rawValueBase64, }); } @@ -344,7 +303,7 @@ async function cmdTelegramSet(args: string[]): Promise { const shouldWriteSharedKv = hasSharedTelegramBridgeUpdate({ update: split.sharedUpdate }); let kv: ReturnType | null = null; - let previousServerKvRecord: ChannelBridgeServerTelegramConfigRecord | null = null; + let previousServerKvSnapshot: TelegramKvRawSnapshot | null = null; if (shouldWriteSharedKv) { kv = createAxiosChannelBridgeKvClient({ token: auth.token }); @@ -354,7 +313,10 @@ async function cmdTelegramSet(args: string[]): Promise { accountId, allowUnsupportedSchema: true, }); - previousServerKvRecord = previous.record; + previousServerKvSnapshot = { + version: previous.version, + rawValueBase64: previous.rawValueBase64, + }; await upsertChannelBridgeTelegramConfigInKv({ kv, @@ -380,7 +342,7 @@ async function cmdTelegramSet(args: string[]): Promise { kv, serverId, accountId, - previousRecord: previousServerKvRecord, + previousSnapshot: previousServerKvSnapshot ?? { version: -1, rawValueBase64: null }, }); } catch (rollbackError) { const primaryMessage = error instanceof Error ? error.message : String(error); @@ -420,6 +382,10 @@ async function cmdTelegramClear(): Promise { accountId, allowUnsupportedSchema: true, }); + const previousSnapshot: TelegramKvRawSnapshot = { + version: previous.version, + rawValueBase64: previous.rawValueBase64, + }; await clearChannelBridgeTelegramConfigInKv({ kv, @@ -441,7 +407,7 @@ async function cmdTelegramClear(): Promise { kv, serverId, accountId, - previousRecord: previous.record, + previousSnapshot, }); } catch (rollbackError) { const primaryMessage = error instanceof Error ? error.message : String(error); From 377c1704a8f921d6194c12a00ed0748604749cb2 Mon Sep 17 00:00:00 2001 From: Chris Denneen Date: Fri, 6 Mar 2026 17:24:51 -0500 Subject: [PATCH 14/81] Address PR #115 review feedback on config precedence and rollback safety --- .../channelBindingStore.server.test.ts | 65 ++++++++++++-- .../channels/channelBindingStore.server.ts | 11 +-- .../src/channels/channelBridgeConfig.test.ts | 58 ++++++++++++ apps/cli/src/channels/channelBridgeConfig.ts | 88 ++++++++++++++----- .../channels/channelBridgeServerKv.test.ts | 28 +++++- .../cli/src/channels/channelBridgeServerKv.ts | 40 +++++++-- .../channels/core/channelBridgeWorker.test.ts | 64 +++++++++++++- .../src/channels/core/channelBridgeWorker.ts | 49 ++++++++--- ...ChannelBridgeWorker.startupFailure.test.ts | 4 +- .../channels/startChannelBridgeWorker.test.ts | 10 +-- apps/cli/src/cli/commands/bridge.test.ts | 11 ++- apps/cli/src/cli/commands/bridge.ts | 48 +++++----- 12 files changed, 380 insertions(+), 96 deletions(-) diff --git a/apps/cli/src/channels/channelBindingStore.server.test.ts b/apps/cli/src/channels/channelBindingStore.server.test.ts index 447e212c50..52e613ef23 100644 --- a/apps/cli/src/channels/channelBindingStore.server.test.ts +++ b/apps/cli/src/channels/channelBindingStore.server.test.ts @@ -350,7 +350,10 @@ describe('createServerBackedChannelBindingStore', () => { providerId: 'telegram', conversationId: '-100111', threadId: null, - }, 10); + }, { + expectedSessionId: 'sess-1', + seq: 10, + }); const updated = await store.getBinding({ providerId: 'telegram', @@ -395,12 +398,18 @@ describe('createServerBackedChannelBindingStore', () => { providerId: 'telegram', conversationId: '-100222', threadId: null, - }, 10); + }, { + expectedSessionId: 'sess-2', + seq: 10, + }); await store.updateLastForwardedSeq({ providerId: 'telegram', conversationId: '-100-missing', threadId: null, - }, 1); + }, { + expectedSessionId: 'sess-missing', + seq: 1, + }); const missingRemoved = await store.removeBinding({ providerId: 'telegram', conversationId: '-100-missing', @@ -411,6 +420,44 @@ describe('createServerBackedChannelBindingStore', () => { expect(counting.mutateCallCount()).toBe(initialMutations); }); + it('does not advance cursor when expected session id does not match', async () => { + const counting = createCountingKvClient(); + const store = createServerBackedChannelBindingStore({ + kv: counting.kv, + serverId: 'local-3005', + accountId: 'acct-1', + }); + + await store.upsertBinding({ + providerId: 'telegram', + conversationId: '-100session-guard', + threadId: null, + sessionId: 'sess-current', + lastForwardedSeq: 5, + }); + + const beforeMutations = counting.mutateCallCount(); + const advanced = await store.updateLastForwardedSeq({ + providerId: 'telegram', + conversationId: '-100session-guard', + threadId: null, + }, { + expectedSessionId: 'sess-stale', + seq: 99, + }); + + expect(advanced).toBe(false); + expect(counting.mutateCallCount()).toBe(beforeMutations); + + const binding = await store.getBinding({ + providerId: 'telegram', + conversationId: '-100session-guard', + threadId: null, + }); + expect(binding?.sessionId).toBe('sess-current'); + expect(binding?.lastForwardedSeq).toBe(5); + }); + it('rejects invalid identity/cursor values and skips invalid seq writes', async () => { const counting = createCountingKvClient(); const store = createServerBackedChannelBindingStore({ @@ -464,13 +511,19 @@ describe('createServerBackedChannelBindingStore', () => { providerId: 'telegram', conversationId: '-100nf', threadId: '11', - }, Number.POSITIVE_INFINITY); + }, { + expectedSessionId: 'sess-nf', + seq: Number.POSITIVE_INFINITY, + }); await store.updateLastForwardedSeq({ providerId: 'telegram', conversationId: '-100nf', threadId: '11', - }, Number.NaN); + }, { + expectedSessionId: 'sess-nf', + seq: Number.NaN, + }); expect(counting.mutateCallCount()).toBe(initialMutations); @@ -607,7 +660,7 @@ describe('createServerBackedChannelBindingStore', () => { }, ]); - await new Promise((resolve) => setTimeout(resolve, 1)); + await new Promise((resolve) => setTimeout(resolve, 10)); const second = await store.listBindings(); expect(second).toEqual(first); expect(getCalls).toBeGreaterThanOrEqual(2); diff --git a/apps/cli/src/channels/channelBindingStore.server.ts b/apps/cli/src/channels/channelBindingStore.server.ts index 2615bca342..20697984f4 100644 --- a/apps/cli/src/channels/channelBindingStore.server.ts +++ b/apps/cli/src/channels/channelBindingStore.server.ts @@ -242,20 +242,21 @@ export function createServerBackedChannelBindingStore(params: Readonly<{ }; }); }, - updateLastForwardedSeq: async (ref, seq) => { - await withOptimisticWrite((currentBindings) => { + updateLastForwardedSeq: async (ref, params) => { + return await withOptimisticWrite((currentBindings) => { const key = bindingKey(ref); - const parsedSeq = toNonNegativeInt(seq); + const parsedSeq = toNonNegativeInt(params.seq); if (parsedSeq === null) { return { nextBindings: currentBindings, - result: undefined, + result: false, changed: false, }; } let changed = false; const nextBindings = currentBindings.map((binding) => { if (bindingKey(binding) !== key) return binding; + if (binding.sessionId !== params.expectedSessionId) return binding; if (parsedSeq <= binding.lastForwardedSeq) return binding; changed = true; return { @@ -266,7 +267,7 @@ export function createServerBackedChannelBindingStore(params: Readonly<{ }); return { nextBindings, - result: undefined, + result: changed, changed, }; }); diff --git a/apps/cli/src/channels/channelBridgeConfig.test.ts b/apps/cli/src/channels/channelBridgeConfig.test.ts index 184eaace36..5b21ea1dc0 100644 --- a/apps/cli/src/channels/channelBridgeConfig.test.ts +++ b/apps/cli/src/channels/channelBridgeConfig.test.ts @@ -148,6 +148,64 @@ describe('resolveChannelBridgeRuntimeConfig', () => { expect(config.telegram.allowedChatIds).toEqual(['-100settings']); }); + it('falls back to lower scope when account allowedChatIds is malformed', () => { + const config = resolveChannelBridgeRuntimeConfig({ + env: {}, + serverId: 'local-test', + accountId: 'acct-123', + settings: { + channelBridge: { + providers: { + telegram: { + allowedChatIds: ['-100-global'], + }, + }, + byServerId: { + 'local-test': { + providers: { + telegram: { + allowedChatIds: ['-100-server'], + }, + }, + byAccountId: { + 'acct-123': { + providers: { + telegram: { + allowedChatIds: [{}], + }, + }, + }, + }, + }, + }, + }, + }, + }); + + expect(config.telegram.allowedChatIds).toEqual(['-100-server']); + }); + + it('ignores non-loopback env webhook host and keeps settings host', () => { + const config = resolveChannelBridgeRuntimeConfig({ + env: { + HAPPIER_TELEGRAM_WEBHOOK_HOST: '0.0.0.0', + }, + settings: { + channelBridge: { + providers: { + telegram: { + webhook: { + host: '127.0.0.1', + }, + }, + }, + }, + }, + }); + + expect(config.telegram.webhookHost).toBe('127.0.0.1'); + }); + it('rejects webhook port zero and falls back to default', () => { const config = resolveChannelBridgeRuntimeConfig({ env: { diff --git a/apps/cli/src/channels/channelBridgeConfig.ts b/apps/cli/src/channels/channelBridgeConfig.ts index 823e1c56f3..e306479c11 100644 --- a/apps/cli/src/channels/channelBridgeConfig.ts +++ b/apps/cli/src/channels/channelBridgeConfig.ts @@ -1,3 +1,5 @@ +import { isLoopbackHost } from '@/channels/telegram/telegramWebhookRelay'; + type RecordLike = Record; function asRecord(value: unknown): RecordLike | null { @@ -63,15 +65,14 @@ function readTrimmedString(value: unknown): string | null { return value.trim(); } -function mergeRecords(levels: Array): RecordLike | null { - const merged: RecordLike = {}; - let hasAny = false; - for (const level of levels) { - if (!level) continue; - hasAny = true; - Object.assign(merged, level); +function firstParsed(values: readonly unknown[], parse: (value: unknown) => T | null): T | null { + for (const value of values) { + const parsed = parse(value); + if (parsed !== null) { + return parsed; + } } - return hasAny ? merged : null; + return null; } type TelegramChannelBridgeRuntimeConfig = Readonly<{ @@ -121,22 +122,20 @@ export function resolveChannelBridgeRuntimeConfig(params: Readonly<{ const telegramGlobal = asRecord(providersGlobal?.telegram); const telegramServer = asRecord(providersServer?.telegram); const telegramAccount = asRecord(providersAccount?.telegram); - const telegram = mergeRecords([telegramGlobal, telegramServer, telegramAccount]); const secretsGlobal = asRecord(telegramGlobal?.secrets); const secretsServer = asRecord(telegramServer?.secrets); const secretsAccount = asRecord(telegramAccount?.secrets); - const secrets = mergeRecords([secretsGlobal, secretsServer, secretsAccount]); const webhookGlobal = asRecord(telegramGlobal?.webhook); const webhookServer = asRecord(telegramServer?.webhook); const webhookAccount = asRecord(telegramAccount?.webhook); - const webhook = mergeRecords([webhookGlobal, webhookServer, webhookAccount]); const settingsTickMs = - parseInteger(channelBridgeAccount?.tickMs, 250, 60_000) - ?? parseInteger(channelBridgeServer?.tickMs, 250, 60_000) - ?? parseInteger(channelBridgeGlobal?.tickMs, 250, 60_000); + firstParsed( + [channelBridgeAccount?.tickMs, channelBridgeServer?.tickMs, channelBridgeGlobal?.tickMs], + (value) => parseInteger(value, 250, 60_000), + ); const envTickMs = typeof env.HAPPIER_CHANNEL_BRIDGE_TICK_MS === 'string' ? parseStrictInteger(env.HAPPIER_CHANNEL_BRIDGE_TICK_MS, 250, 60_000) @@ -144,15 +143,26 @@ export function resolveChannelBridgeRuntimeConfig(params: Readonly<{ const tickMs = envTickMs ?? settingsTickMs ?? 2_500; const settingsBotToken = - readTrimmedString(secrets?.botToken) - ?? readTrimmedString(telegram?.botToken) + firstParsed([ + secretsAccount?.botToken, + telegramAccount?.botToken, + secretsServer?.botToken, + telegramServer?.botToken, + secretsGlobal?.botToken, + telegramGlobal?.botToken, + ], readTrimmedString) ?? ''; const botToken = typeof env.HAPPIER_TELEGRAM_BOT_TOKEN === 'string' ? env.HAPPIER_TELEGRAM_BOT_TOKEN.trim() : settingsBotToken; - const settingsAllowedChatIds = parseStringArray(telegram?.allowedChatIds) ?? []; + const settingsAllowedChatIds = + firstParsed( + [telegramAccount?.allowedChatIds, telegramServer?.allowedChatIds, telegramGlobal?.allowedChatIds], + parseStringArray, + ) + ?? []; const envAllowedChatIdsRaw = typeof env.HAPPIER_TELEGRAM_ALLOWED_CHAT_IDS === 'string' ? env.HAPPIER_TELEGRAM_ALLOWED_CHAT_IDS.trim() @@ -166,14 +176,24 @@ export function resolveChannelBridgeRuntimeConfig(params: Readonly<{ ? parsedEnvAllowedChatIds : settingsAllowedChatIds; - const settingsRequireTopics = parseBoolean(telegram?.requireTopics) ?? false; + const settingsRequireTopics = + firstParsed( + [telegramAccount?.requireTopics, telegramServer?.requireTopics, telegramGlobal?.requireTopics], + parseBoolean, + ) + ?? false; const envRequireTopics = typeof env.HAPPIER_TELEGRAM_REQUIRE_TOPICS === 'string' ? parseBoolean(env.HAPPIER_TELEGRAM_REQUIRE_TOPICS) : null; const requireTopics = envRequireTopics ?? settingsRequireTopics; - const settingsWebhookEnabled = parseBoolean(webhook?.enabled) ?? false; + const settingsWebhookEnabled = + firstParsed( + [webhookAccount?.enabled, webhookServer?.enabled, webhookGlobal?.enabled], + parseBoolean, + ) + ?? false; const envWebhookEnabled = typeof env.HAPPIER_TELEGRAM_WEBHOOK_ENABLED === 'string' ? parseBoolean(env.HAPPIER_TELEGRAM_WEBHOOK_ENABLED) @@ -181,22 +201,42 @@ export function resolveChannelBridgeRuntimeConfig(params: Readonly<{ const webhookEnabled = envWebhookEnabled ?? settingsWebhookEnabled; const settingsWebhookSecret = - readTrimmedString(secrets?.webhookSecret) - ?? readTrimmedString(webhook?.secret) + firstParsed([ + secretsAccount?.webhookSecret, + webhookAccount?.secret, + secretsServer?.webhookSecret, + webhookServer?.secret, + secretsGlobal?.webhookSecret, + webhookGlobal?.secret, + ], readTrimmedString) ?? ''; const webhookSecret = typeof env.HAPPIER_TELEGRAM_WEBHOOK_SECRET === 'string' ? env.HAPPIER_TELEGRAM_WEBHOOK_SECRET.trim() : settingsWebhookSecret; - const settingsWebhookHost = readTrimmedString(webhook?.host) || '127.0.0.1'; + const settingsWebhookHost = + firstParsed( + [webhookAccount?.host, webhookServer?.host, webhookGlobal?.host], + readTrimmedString, + ) + || '127.0.0.1'; const envWebhookHostRaw = typeof env.HAPPIER_TELEGRAM_WEBHOOK_HOST === 'string' ? env.HAPPIER_TELEGRAM_WEBHOOK_HOST.trim() : null; - const webhookHost = envWebhookHostRaw ? envWebhookHostRaw : settingsWebhookHost; + const envWebhookHost = + envWebhookHostRaw && isLoopbackHost(envWebhookHostRaw) + ? envWebhookHostRaw + : null; + const webhookHost = envWebhookHost ?? settingsWebhookHost; - const settingsWebhookPort = parseInteger(webhook?.port, 1, 65_535) ?? 8_787; + const settingsWebhookPort = + firstParsed( + [webhookAccount?.port, webhookServer?.port, webhookGlobal?.port], + (value) => parseInteger(value, 1, 65_535), + ) + ?? 8_787; const envWebhookPort = typeof env.HAPPIER_TELEGRAM_WEBHOOK_PORT === 'string' ? parseStrictInteger(env.HAPPIER_TELEGRAM_WEBHOOK_PORT, 1, 65_535) diff --git a/apps/cli/src/channels/channelBridgeServerKv.test.ts b/apps/cli/src/channels/channelBridgeServerKv.test.ts index 3a419cc3a0..2537c949c3 100644 --- a/apps/cli/src/channels/channelBridgeServerKv.test.ts +++ b/apps/cli/src/channels/channelBridgeServerKv.test.ts @@ -302,6 +302,32 @@ describe('channelBridgeServerKv', () => { expect(readWithUnsupported.rawValueBase64).toBe(rawUnsupported); }); + it('refuses raw replace when expected current version does not match', async () => { + const kv = createInMemoryKvClient(); + + await upsertChannelBridgeTelegramConfigInKv({ + kv, + serverId: 'local-3005', + accountId: 'acct-1', + update: { + requireTopics: true, + }, + }); + + const rawUnsupported = Buffer.from(JSON.stringify({ + schemaVersion: 999, + unsupported: true, + }), 'utf8').toString('base64'); + + await expect(replaceChannelBridgeTelegramConfigRawInKv({ + kv, + serverId: 'local-3005', + accountId: 'acct-1', + valueBase64: rawUnsupported, + expectedCurrentVersion: 999, + })).rejects.toThrow('KV version mismatch'); + }); + it('upsert replaces unsupported telegram config schema instead of failing', async () => { const unknownSchemaValueBase64 = Buffer.from(JSON.stringify({ schemaVersion: 999, @@ -374,7 +400,7 @@ describe('channelBridgeServerKv', () => { update: { allowedChatIds: ['-100111'], }, - })).resolves.toBeUndefined(); + })).resolves.toBe(5); const config = await readChannelBridgeTelegramConfigFromKv({ kv, diff --git a/apps/cli/src/channels/channelBridgeServerKv.ts b/apps/cli/src/channels/channelBridgeServerKv.ts index 1a2be0b13c..3c2e0326b2 100644 --- a/apps/cli/src/channels/channelBridgeServerKv.ts +++ b/apps/cli/src/channels/channelBridgeServerKv.ts @@ -451,8 +451,30 @@ export async function replaceChannelBridgeTelegramConfigRawInKv(params: Readonly serverId: string; accountId: string; valueBase64: string | null; + expectedCurrentVersion?: number; }>): Promise { const key = telegramConfigKvKey(params.serverId, params.accountId); + + if (typeof params.expectedCurrentVersion === 'number' && Number.isFinite(params.expectedCurrentVersion)) { + const expectedCurrentVersion = Math.trunc(params.expectedCurrentVersion); + const current = await readJsonValue({ kv: params.kv, key }); + if (current.version !== expectedCurrentVersion) { + throw new ChannelBridgeKvVersionMismatchError({ + key, + currentVersion: current.version, + currentValueBase64: current.valueBase64, + }); + } + + await writeJsonValue({ + kv: params.kv, + key, + valueBase64: params.valueBase64, + expectedVersion: expectedCurrentVersion, + }); + return; + } + let current = await readJsonValue({ kv: params.kv, key }); for (let attempt = 0; attempt < 4; attempt += 1) { @@ -571,9 +593,9 @@ export async function upsertChannelBridgeTelegramConfigInKv(params: Readonly<{ serverId: string; accountId: string; update: ScopedTelegramBridgeUpdate; -}>): Promise { +}>): Promise { if (!hasNonSecretTelegramConfigUpdate(params.update)) { - return; + return null; } const key = telegramConfigKvKey(params.serverId, params.accountId); @@ -595,13 +617,13 @@ export async function upsertChannelBridgeTelegramConfigInKv(params: Readonly<{ const nextValueBase64 = encodeJsonToBase64(nextRecord); try { - await writeJsonValue({ + const writtenVersion = await writeJsonValue({ kv: params.kv, key, valueBase64: nextValueBase64, expectedVersion: current.version, }); - return; + return writtenVersion; } catch (error) { if (error instanceof ChannelBridgeKvVersionMismatchError) { try { @@ -627,7 +649,7 @@ export async function clearChannelBridgeTelegramConfigInKv(params: Readonly<{ kv: ChannelBridgeKvClient; serverId: string; accountId: string; -}>): Promise { +}>): Promise { const key = telegramConfigKvKey(params.serverId, params.accountId); const current = await readChannelBridgeTelegramConfigFromKv({ kv: params.kv, @@ -636,21 +658,21 @@ export async function clearChannelBridgeTelegramConfigInKv(params: Readonly<{ allowUnsupportedSchema: true, }); let expectedVersion = current.version; - if (expectedVersion < 0) return; + if (expectedVersion < 0) return null; for (let attempt = 0; attempt < 4; attempt += 1) { try { - await writeJsonValue({ + const writtenVersion = await writeJsonValue({ kv: params.kv, key, valueBase64: null, expectedVersion, }); - return; + return writtenVersion; } catch (error) { if (error instanceof ChannelBridgeKvVersionMismatchError) { if (error.currentVersion < 0) { - return; + return null; } expectedVersion = error.currentVersion; continue; diff --git a/apps/cli/src/channels/core/channelBridgeWorker.test.ts b/apps/cli/src/channels/core/channelBridgeWorker.test.ts index f7e3e2fcf0..205e50b091 100644 --- a/apps/cli/src/channels/core/channelBridgeWorker.test.ts +++ b/apps/cli/src/channels/core/channelBridgeWorker.test.ts @@ -161,8 +161,14 @@ describe('createInMemoryChannelBindingStore', () => { const created = await store.getBinding(ref); expect(created?.lastForwardedSeq).toBe(0); - await store.updateLastForwardedSeq(ref, 7); - await store.updateLastForwardedSeq(ref, Number.NaN); + await store.updateLastForwardedSeq(ref, { + expectedSessionId: 'sess-cursor', + seq: 7, + }); + await store.updateLastForwardedSeq(ref, { + expectedSessionId: 'sess-cursor', + seq: Number.NaN, + }); const updated = await store.getBinding(ref); expect(updated?.lastForwardedSeq).toBe(7); @@ -187,7 +193,10 @@ describe('executeChannelBridgeTick', () => { providerId: 'telegram', conversationId: '-100sanity', threadId: null, - }, Number.POSITIVE_INFINITY); + }, { + expectedSessionId: 'sess-sanity', + seq: Number.POSITIVE_INFINITY, + }); const afterInvalidUpdate = await store.getBinding({ providerId: 'telegram', @@ -1577,6 +1586,55 @@ describe('executeChannelBridgeTick', () => { expect(binding?.lastForwardedSeq).toBe(9); }); + it('does not advance cursor when conversation is reattached to a different session', async () => { + const baseStore = createInMemoryChannelBindingStore(); + const ref = { + providerId: 'telegram', + conversationId: '-3005', + threadId: null, + } as const; + + await baseStore.upsertBinding({ + ...ref, + sessionId: 'sess-old', + lastForwardedSeq: 0, + }); + + const store: ChannelBindingStore = { + ...baseStore, + updateLastForwardedSeq: async (bindingRef, params) => { + await baseStore.upsertBinding({ + ...bindingRef, + sessionId: 'sess-new', + lastForwardedSeq: 0, + }); + return await baseStore.updateLastForwardedSeq(bindingRef, params); + }, + }; + + const harness = createAdapterHarness(); + const { deps, warnings } = createDepsHarness({ + fetchAgentMessagesAfterSeq: async () => [ + { seq: 1, text: 'first send' }, + { seq: 2, text: 'must not send' }, + ], + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(harness.sent.map((row) => row.text)).toEqual(['first send']); + + const rebound = await baseStore.getBinding(ref); + expect(rebound?.sessionId).toBe('sess-new'); + expect(rebound?.lastForwardedSeq).toBe(0); + expect(warnings.some((row) => row.message.includes('Skipped cursor advance because binding changed'))).toBe(true); + }); + it('warns when binding provider has no active adapter for outbound forwarding', async () => { const store = createInMemoryChannelBindingStore(); const harness = createAdapterHarness('discord'); diff --git a/apps/cli/src/channels/core/channelBridgeWorker.ts b/apps/cli/src/channels/core/channelBridgeWorker.ts index 419dc91a7d..5ee0346905 100644 --- a/apps/cli/src/channels/core/channelBridgeWorker.ts +++ b/apps/cli/src/channels/core/channelBridgeWorker.ts @@ -124,7 +124,10 @@ export type ChannelBindingStore = Readonly<{ sessionId: string; lastForwardedSeq: number; }>) => Promise; - updateLastForwardedSeq: (ref: ChannelBridgeConversationRef, seq: number) => Promise; + updateLastForwardedSeq: ( + ref: ChannelBridgeConversationRef, + params: Readonly<{ expectedSessionId: string; seq: number }>, + ) => Promise; removeBinding: (ref: ChannelBridgeConversationRef) => Promise; }>; @@ -292,18 +295,23 @@ export function createInMemoryChannelBindingStore(now: () => number = () => Date byKey.set(key, { ...next }); return { ...next }; }, - updateLastForwardedSeq: async (ref, seq) => { + updateLastForwardedSeq: async (ref, params) => { const key = bindingKey(ref); const existing = byKey.get(key); - if (!existing) return; - const parsedSeq = toNonNegativeInt(seq); - if (parsedSeq === null) return; + if (!existing) return false; + if (existing.sessionId !== params.expectedSessionId) return false; + const parsedSeq = toNonNegativeInt(params.seq); + if (parsedSeq === null) return false; const nextSeq = Math.max(existing.lastForwardedSeq, parsedSeq); + if (nextSeq === existing.lastForwardedSeq) { + return false; + } byKey.set(key, { ...existing, lastForwardedSeq: nextSeq, updatedAtMs: now(), }); + return true; }, removeBinding: async (ref) => byKey.delete(bindingKey(ref)), }; @@ -370,10 +378,14 @@ async function authorizeCommand(params: Readonly<{ }; try { - const result = await authorize({ - commandName: params.commandName, - actor, - }); + const result = await withTimeout( + authorize({ + commandName: params.commandName, + actor, + }), + EXTERNAL_IO_TIMEOUT_MS, + `authorizeCommand(/${params.commandName})`, + ); if (typeof result === 'boolean') { return { allowed: result, @@ -872,11 +884,20 @@ export async function executeChannelBridgeTick(params: Readonly<{ const persistCursor = async (nextSeq: number): Promise => { try { maxSeq = nextSeq; - await withTimeout( - params.store.updateLastForwardedSeq(binding, maxSeq), + const advanced = await withTimeout( + params.store.updateLastForwardedSeq(binding, { + expectedSessionId: binding.sessionId, + seq: maxSeq, + }), EXTERNAL_IO_TIMEOUT_MS, `store.updateLastForwardedSeq(${binding.providerId}:${binding.conversationId}:${binding.threadId ?? 'null'}:${binding.sessionId}:${maxSeq})`, ); + if (!advanced) { + params.deps.onWarning?.( + `Skipped cursor advance because binding changed or cursor was stale for session=${binding.sessionId} provider=${binding.providerId} conversation=${binding.conversationId} seq=${nextSeq}`, + ); + return false; + } return true; } catch (error) { params.deps.onWarning?.( @@ -1026,7 +1047,11 @@ export function startChannelBridgeWorker(params: Readonly<{ const stopResults = await Promise.allSettled( adaptersToStop.map(async (adapter) => { if (typeof adapter.stop !== 'function') return; - await adapter.stop(); + await withTimeout( + Promise.resolve(adapter.stop()), + EXTERNAL_IO_TIMEOUT_MS, + `adapter.stop(${adapter.providerId})`, + ); }), ); diff --git a/apps/cli/src/channels/startChannelBridgeWorker.startupFailure.test.ts b/apps/cli/src/channels/startChannelBridgeWorker.startupFailure.test.ts index b17bac599a..816a2ff230 100644 --- a/apps/cli/src/channels/startChannelBridgeWorker.startupFailure.test.ts +++ b/apps/cli/src/channels/startChannelBridgeWorker.startupFailure.test.ts @@ -27,7 +27,7 @@ describe('startChannelBridgeFromEnv startup failures', () => { createdAtMs: Date.now(), updatedAtMs: Date.now(), }), - updateLastForwardedSeq: async () => undefined, + updateLastForwardedSeq: async () => true, removeBinding: async () => false, })), startChannelBridgeWorker: vi.fn(() => { @@ -95,7 +95,7 @@ describe('startChannelBridgeFromEnv startup failures', () => { createdAtMs: Date.now(), updatedAtMs: Date.now(), }), - updateLastForwardedSeq: async () => undefined, + updateLastForwardedSeq: async () => true, removeBinding: async () => false, })), startChannelBridgeWorker: vi.fn(() => ({ diff --git a/apps/cli/src/channels/startChannelBridgeWorker.test.ts b/apps/cli/src/channels/startChannelBridgeWorker.test.ts index e44eca6096..631e1cd0c0 100644 --- a/apps/cli/src/channels/startChannelBridgeWorker.test.ts +++ b/apps/cli/src/channels/startChannelBridgeWorker.test.ts @@ -92,7 +92,7 @@ describe('startChannelBridgeFromEnv', () => { createdAtMs: Date.now(), updatedAtMs: Date.now(), }), - updateLastForwardedSeq: async () => undefined, + updateLastForwardedSeq: async () => true, removeBinding: async () => false, })), startChannelBridgeWorker: vi.fn((params: { deps: typeof capturedDeps }) => { @@ -202,7 +202,7 @@ describe('startChannelBridgeFromEnv', () => { createdAtMs: Date.now(), updatedAtMs: Date.now(), }), - updateLastForwardedSeq: async () => undefined, + updateLastForwardedSeq: async () => true, removeBinding: async () => false, })), startChannelBridgeWorker: vi.fn((params: { deps: typeof capturedDeps }) => { @@ -341,7 +341,7 @@ describe('startChannelBridgeFromEnv', () => { createdAtMs: Date.now(), updatedAtMs: Date.now(), }), - updateLastForwardedSeq: async () => undefined, + updateLastForwardedSeq: async () => true, removeBinding: async () => false, })), startChannelBridgeWorker: vi.fn((params: { deps: typeof capturedDeps }) => { @@ -438,7 +438,7 @@ describe('startChannelBridgeFromEnv', () => { createdAtMs: Date.now(), updatedAtMs: Date.now(), }), - updateLastForwardedSeq: async () => undefined, + updateLastForwardedSeq: async () => true, removeBinding: async () => false, })), startChannelBridgeWorker: vi.fn((params: { deps: typeof capturedDeps }) => { @@ -542,7 +542,7 @@ describe('startChannelBridgeFromEnv', () => { createdAtMs: Date.now(), updatedAtMs: Date.now(), }), - updateLastForwardedSeq: async () => undefined, + updateLastForwardedSeq: async () => true, removeBinding: async () => false, })), startChannelBridgeWorker: vi.fn(() => ({ diff --git a/apps/cli/src/cli/commands/bridge.test.ts b/apps/cli/src/cli/commands/bridge.test.ts index 6a271ca6bf..b5b083be63 100644 --- a/apps/cli/src/cli/commands/bridge.test.ts +++ b/apps/cli/src/cli/commands/bridge.test.ts @@ -61,8 +61,8 @@ describe('happier bridge command', () => { createKvClientMock.mockReturnValue({ get: vi.fn(), mutate: vi.fn() }); readKvConfigMock.mockResolvedValue({ record: null, version: -1, rawValueBase64: null }); - upsertKvConfigMock.mockResolvedValue(undefined); - clearKvConfigMock.mockResolvedValue(undefined); + upsertKvConfigMock.mockResolvedValue(12); + clearKvConfigMock.mockResolvedValue(7); replaceKvRawConfigMock.mockResolvedValue(undefined); homeDir = await mkdtemp(join(tmpdir(), 'happier-bridge-cmd-')); @@ -297,12 +297,13 @@ describe('happier bridge command', () => { }); expect(upsertKvConfigMock).toHaveBeenCalledTimes(1); - expect(clearKvConfigMock).toHaveBeenCalledWith({ + expect(replaceKvRawConfigMock).toHaveBeenCalledWith({ kv: expect.any(Object), serverId: configuration.activeServerId, accountId: 'acct_123', + valueBase64: null, + expectedCurrentVersion: 12, }); - expect(replaceKvRawConfigMock).not.toHaveBeenCalled(); expect(process.exitCode).toBe(1); expect(errorSpy).toHaveBeenCalledWith(expect.anything(), expect.stringContaining('settings unavailable')); } finally { @@ -335,6 +336,7 @@ describe('happier bridge command', () => { serverId: configuration.activeServerId, accountId: 'acct_123', valueBase64: rawMalformedPayload, + expectedCurrentVersion: 12, }); expect(process.exitCode).toBe(1); expect(errorSpy).toHaveBeenCalledWith(expect.anything(), expect.stringContaining('settings unavailable')); @@ -393,6 +395,7 @@ describe('happier bridge command', () => { serverId: configuration.activeServerId, accountId: 'acct_123', valueBase64: previousRawValueBase64, + expectedCurrentVersion: 7, }); expect(process.exitCode).toBe(1); expect(errorSpy).toHaveBeenCalledWith(expect.anything(), expect.stringContaining('settings clear unavailable')); diff --git a/apps/cli/src/cli/commands/bridge.ts b/apps/cli/src/cli/commands/bridge.ts index 122198a543..329ea4651c 100644 --- a/apps/cli/src/cli/commands/bridge.ts +++ b/apps/cli/src/cli/commands/bridge.ts @@ -87,21 +87,14 @@ async function rollbackServerKvAfterLocalSettingsFailure(params: Readonly<{ serverId: string; accountId: string; previousSnapshot: TelegramKvRawSnapshot; + rollbackGuardVersion: number; }>): Promise { - if (params.previousSnapshot.version < 0 && params.previousSnapshot.rawValueBase64 === null) { - await clearChannelBridgeTelegramConfigInKv({ - kv: params.kv, - serverId: params.serverId, - accountId: params.accountId, - }); - return; - } - await replaceChannelBridgeTelegramConfigRawInKv({ kv: params.kv, serverId: params.serverId, accountId: params.accountId, valueBase64: params.previousSnapshot.rawValueBase64, + expectedCurrentVersion: params.rollbackGuardVersion, }); } @@ -304,6 +297,7 @@ async function cmdTelegramSet(args: string[]): Promise { let kv: ReturnType | null = null; let previousServerKvSnapshot: TelegramKvRawSnapshot | null = null; + let rollbackGuardVersion: number | null = null; if (shouldWriteSharedKv) { kv = createAxiosChannelBridgeKvClient({ token: auth.token }); @@ -318,7 +312,7 @@ async function cmdTelegramSet(args: string[]): Promise { rawValueBase64: previous.rawValueBase64, }; - await upsertChannelBridgeTelegramConfigInKv({ + rollbackGuardVersion = await upsertChannelBridgeTelegramConfigInKv({ kv, serverId, accountId, @@ -336,13 +330,14 @@ async function cmdTelegramSet(args: string[]): Promise { }), ); } catch (error) { - if (shouldWriteSharedKv && kv) { + if (shouldWriteSharedKv && kv && typeof rollbackGuardVersion === 'number') { try { await rollbackServerKvAfterLocalSettingsFailure({ kv, serverId, accountId, previousSnapshot: previousServerKvSnapshot ?? { version: -1, rawValueBase64: null }, + rollbackGuardVersion, }); } catch (rollbackError) { const primaryMessage = error instanceof Error ? error.message : String(error); @@ -387,7 +382,7 @@ async function cmdTelegramClear(): Promise { rawValueBase64: previous.rawValueBase64, }; - await clearChannelBridgeTelegramConfigInKv({ + const rollbackGuardVersion = await clearChannelBridgeTelegramConfigInKv({ kv, serverId, accountId, @@ -402,19 +397,22 @@ async function cmdTelegramClear(): Promise { }), ); } catch (error) { - try { - await rollbackServerKvAfterLocalSettingsFailure({ - kv, - serverId, - accountId, - previousSnapshot, - }); - } catch (rollbackError) { - const primaryMessage = error instanceof Error ? error.message : String(error); - const rollbackMessage = rollbackError instanceof Error ? rollbackError.message : String(rollbackError); - throw new Error( - `Scoped settings clear failed and server KV rollback failed (${primaryMessage}; rollback: ${rollbackMessage})`, - ); + if (typeof rollbackGuardVersion === 'number') { + try { + await rollbackServerKvAfterLocalSettingsFailure({ + kv, + serverId, + accountId, + previousSnapshot, + rollbackGuardVersion, + }); + } catch (rollbackError) { + const primaryMessage = error instanceof Error ? error.message : String(error); + const rollbackMessage = rollbackError instanceof Error ? rollbackError.message : String(rollbackError); + throw new Error( + `Scoped settings clear failed and server KV rollback failed (${primaryMessage}; rollback: ${rollbackMessage})`, + ); + } } throw error; } From bc77848dca6aec8aa9646d1159f22e5748692a51 Mon Sep 17 00:00:00 2001 From: Chris Denneen Date: Fri, 6 Mar 2026 18:07:03 -0500 Subject: [PATCH 15/81] fix(cli): resolve PR115 review findings for telegram bridge --- .../channels/channelBridgeServerKv.test.ts | 67 +++++++++++++++ .../cli/src/channels/channelBridgeServerKv.ts | 20 ++++- .../channels/core/channelBridgeWorker.test.ts | 83 +++++++++++++++++++ .../src/channels/core/channelBridgeWorker.ts | 17 ++-- .../channels/startChannelBridgeWorker.test.ts | 12 +-- .../src/channels/startChannelBridgeWorker.ts | 6 +- .../src/channels/telegram/telegramAdapter.ts | 42 ++++++++-- apps/cli/src/cli/commands/bridge.ts | 2 +- 8 files changed, 215 insertions(+), 34 deletions(-) diff --git a/apps/cli/src/channels/channelBridgeServerKv.test.ts b/apps/cli/src/channels/channelBridgeServerKv.test.ts index 2537c949c3..d70e9ebdda 100644 --- a/apps/cli/src/channels/channelBridgeServerKv.test.ts +++ b/apps/cli/src/channels/channelBridgeServerKv.test.ts @@ -916,6 +916,73 @@ describe('channelBridgeServerKv', () => { })).rejects.toThrow('Invalid telegram.webhook.port payload'); }); + it('throws when telegram config payload contains non-loopback webhook.host', async () => { + const invalidWebhookHostValueBase64 = Buffer.from(JSON.stringify({ + schemaVersion: 1, + telegram: { + webhook: { + enabled: true, + host: '0.0.0.0', + port: 8787, + }, + }, + updatedAtMs: 123, + }), 'utf8').toString('base64'); + const kv: ChannelBridgeKvClient = { + get: async () => ({ + status: 200, + body: { + key: 'k', + value: invalidWebhookHostValueBase64, + version: 3, + }, + }), + mutate: async () => ({ status: 200, body: { success: true, results: [] } }), + }; + + await expect(readChannelBridgeTelegramConfigFromKv({ + kv, + serverId: 'local-3005', + accountId: 'acct-1', + })).rejects.toThrow('Invalid telegram.webhook.host payload'); + }); + + it('treats invalid webhook.host payload as recoverable when allowUnsupportedSchema=true', async () => { + const invalidWebhookHostValueBase64 = Buffer.from(JSON.stringify({ + schemaVersion: 1, + telegram: { + webhook: { + enabled: true, + host: '0.0.0.0', + port: 8787, + }, + }, + updatedAtMs: 123, + }), 'utf8').toString('base64'); + const kv: ChannelBridgeKvClient = { + get: async () => ({ + status: 200, + body: { + key: 'k', + value: invalidWebhookHostValueBase64, + version: 7, + }, + }), + mutate: async () => ({ status: 200, body: { success: true, results: [] } }), + }; + + await expect(readChannelBridgeTelegramConfigFromKv({ + kv, + serverId: 'local-3005', + accountId: 'acct-1', + allowUnsupportedSchema: true, + })).resolves.toEqual({ + record: null, + version: 7, + rawValueBase64: invalidWebhookHostValueBase64, + }); + }); + it('treats malformed telegram config payload as recoverable when allowUnsupportedSchema=true', async () => { const malformedValueBase64 = Buffer.from('not-json', 'utf8').toString('base64'); const kv: ChannelBridgeKvClient = { diff --git a/apps/cli/src/channels/channelBridgeServerKv.ts b/apps/cli/src/channels/channelBridgeServerKv.ts index 3c2e0326b2..0d14660e8d 100644 --- a/apps/cli/src/channels/channelBridgeServerKv.ts +++ b/apps/cli/src/channels/channelBridgeServerKv.ts @@ -206,7 +206,15 @@ function parseTelegramConfigRecord(value: unknown): ChannelBridgeServerTelegramC if (webhook) { const outWebhook: { enabled?: boolean; host?: string; port?: number } = {}; if (typeof webhook.enabled === 'boolean') outWebhook.enabled = webhook.enabled; - if (typeof webhook.host === 'string') outWebhook.host = webhook.host; + if (typeof webhook.host === 'string') { + const trimmedHost = webhook.host.trim(); + if (trimmedHost.length > 0) { + if (!isLoopbackHost(trimmedHost)) { + throw new ChannelBridgeBadPayloadError('Invalid telegram.webhook.host payload'); + } + outWebhook.host = trimmedHost; + } + } if (webhook.port !== undefined) { if ( typeof webhook.port !== 'number' @@ -431,7 +439,15 @@ export async function readChannelBridgeTelegramConfigFromKv(params: Readonly<{ throw error; } - const parsed = parseTelegramConfigRecord(decoded); + let parsed: ChannelBridgeServerTelegramConfigRecord | null; + try { + parsed = parseTelegramConfigRecord(decoded); + } catch (error) { + if (params.allowUnsupportedSchema && error instanceof ChannelBridgeBadPayloadError) { + return { record: null, version: row.version, rawValueBase64: row.valueBase64 }; + } + throw error; + } if (!parsed) { if (params.allowUnsupportedSchema) { return { record: null, version: row.version, rawValueBase64: row.valueBase64 }; diff --git a/apps/cli/src/channels/core/channelBridgeWorker.test.ts b/apps/cli/src/channels/core/channelBridgeWorker.test.ts index 205e50b091..c8e960e886 100644 --- a/apps/cli/src/channels/core/channelBridgeWorker.test.ts +++ b/apps/cli/src/channels/core/channelBridgeWorker.test.ts @@ -10,6 +10,7 @@ import { type ChannelBridgeDeps, type ChannelBridgeInboundMessage, } from '@/channels/core/channelBridgeWorker'; +import { TelegramApiError } from '@/channels/telegram/telegramAdapter'; interface SentConversationMessage { conversationId: string; @@ -1586,6 +1587,88 @@ describe('executeChannelBridgeTick', () => { expect(binding?.lastForwardedSeq).toBe(9); }); + it('treats typed Telegram permanent delivery failures as non-retryable and advances cursor', async () => { + const store = createInMemoryChannelBindingStore(); + const { deps, warnings } = createDepsHarness({ + fetchAgentMessagesAfterSeq: async () => [{ seq: 1, text: 'delivery fails permanently' }], + }); + + await store.upsertBinding({ + providerId: 'telegram', + conversationId: '-3004b', + threadId: null, + sessionId: 'sess-typed-permanent', + lastForwardedSeq: 0, + }); + + const adapter: ChannelBridgeAdapter = { + providerId: 'telegram', + pullInboundMessages: async () => [], + sendMessage: async () => { + throw new TelegramApiError({ + method: 'sendMessage', + statusCode: 403, + data: { description: 'Forbidden: bot was blocked by the user' }, + }); + }, + }; + + await executeChannelBridgeTick({ + store, + adapters: [adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + const [binding] = await store.listBindings(); + expect(binding?.lastForwardedSeq).toBe(1); + expect( + warnings.some((row) => row.message.includes('Detected permanent Telegram delivery failure')), + ).toBe(true); + expect( + warnings.some((row) => row.message.includes('Failed to forward agent output to channel')), + ).toBe(false); + }); + + it('does not treat untyped telegram-like send errors as permanent failures', async () => { + const store = createInMemoryChannelBindingStore(); + const { deps, warnings } = createDepsHarness({ + fetchAgentMessagesAfterSeq: async () => [{ seq: 1, text: 'must not be treated as permanent' }], + }); + + await store.upsertBinding({ + providerId: 'telegram', + conversationId: '-3004c', + threadId: null, + sessionId: 'sess-untyped-error', + lastForwardedSeq: 0, + }); + + const adapter: ChannelBridgeAdapter = { + providerId: 'telegram', + pullInboundMessages: async () => [], + sendMessage: async () => { + throw new Error('Telegram sendMessage failed (403): Forbidden'); + }, + }; + + await executeChannelBridgeTick({ + store, + adapters: [adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + const [binding] = await store.listBindings(); + expect(binding?.lastForwardedSeq).toBe(0); + expect( + warnings.some((row) => row.message.includes('Detected permanent Telegram delivery failure')), + ).toBe(false); + expect( + warnings.some((row) => row.message.includes('Failed to forward agent output to channel')), + ).toBe(true); + }); + it('does not advance cursor when conversation is reattached to a different session', async () => { const baseStore = createInMemoryChannelBindingStore(); const ref = { diff --git a/apps/cli/src/channels/core/channelBridgeWorker.ts b/apps/cli/src/channels/core/channelBridgeWorker.ts index 5ee0346905..ee544bd729 100644 --- a/apps/cli/src/channels/core/channelBridgeWorker.ts +++ b/apps/cli/src/channels/core/channelBridgeWorker.ts @@ -14,6 +14,7 @@ * - `updateLastForwardedSeq` must persist the maximum forwarded sequence. */ import { startSingleFlightIntervalLoop, type SingleFlightIntervalLoopHandle } from '@/daemon/lifecycle/singleFlightIntervalLoop'; +import { TelegramApiError } from '@/channels/telegram/telegramAdapter'; /** * Logical channel conversation reference. @@ -198,19 +199,11 @@ async function withTimeout(promise: Promise, timeoutMs: number, label: str } } -function parseTelegramSendMessageFailureStatus(error: unknown): number | null { - if (!(error instanceof Error)) return null; - const match = error.message.match(/Telegram sendMessage failed \((\d{3})\)/); - if (!match) return null; - const parsed = Number.parseInt(match[1] ?? '', 10); - return Number.isInteger(parsed) ? parsed : null; -} - function isTelegramPermanentDeliveryFailure(error: unknown): boolean { - if (!(error instanceof Error)) return false; - const status = parseTelegramSendMessageFailureStatus(error); - if (status === 403) return true; - if (status === 400 && /chat not found/i.test(error.message)) return true; + if (!(error instanceof TelegramApiError)) return false; + if (error.method !== 'sendMessage') return false; + if (error.statusCode === 403) return true; + if (error.statusCode === 400 && /chat not found/i.test(error.message)) return true; return false; } diff --git a/apps/cli/src/channels/startChannelBridgeWorker.test.ts b/apps/cli/src/channels/startChannelBridgeWorker.test.ts index 631e1cd0c0..8b0dd05ff9 100644 --- a/apps/cli/src/channels/startChannelBridgeWorker.test.ts +++ b/apps/cli/src/channels/startChannelBridgeWorker.test.ts @@ -51,7 +51,7 @@ describe('startChannelBridgeFromEnv', () => { expect(stopSpy).toHaveBeenCalledTimes(1); }); - it('paginates listSessions across cursored pages in default bridge deps', async () => { + it('fetches only the first sessions page in default bridge deps', async () => { vi.resetModules(); const fetchSessionsPage = vi @@ -127,20 +127,14 @@ describe('startChannelBridgeFromEnv', () => { expect(capturedDeps).not.toBeNull(); const sessions = await capturedDeps!.listSessions(); - expect(fetchSessionsPage).toHaveBeenCalledTimes(2); + expect(fetchSessionsPage).toHaveBeenCalledTimes(1); expect(fetchSessionsPage).toHaveBeenNthCalledWith(1, { token: credentials.token, activeOnly: false, limit: 20, cursor: undefined, }); - expect(fetchSessionsPage).toHaveBeenNthCalledWith(2, { - token: credentials.token, - activeOnly: false, - limit: 20, - cursor: 'cursor-1', - }); - expect(sessions.map((row) => row.sessionId)).toEqual(['sess-1', 'sess-2']); + expect(sessions.map((row) => row.sessionId)).toEqual(['sess-1']); await handle?.stop(); }); diff --git a/apps/cli/src/channels/startChannelBridgeWorker.ts b/apps/cli/src/channels/startChannelBridgeWorker.ts index ad54aaaebe..22ddf64be0 100644 --- a/apps/cli/src/channels/startChannelBridgeWorker.ts +++ b/apps/cli/src/channels/startChannelBridgeWorker.ts @@ -154,7 +154,7 @@ function createDefaultChannelBridgeDeps(credentials: Credentials): DefaultChanne listSessions: async () => { const displayLimit = 20; const pageSize = displayLimit; - const maxPages = displayLimit; + const maxPages = 1; const sessions: RawSessionListRow[] = []; let cursor: string | undefined; @@ -177,10 +177,6 @@ function createDefaultChannelBridgeDeps(credentials: Credentials): DefaultChanne cursor = page.nextCursor; } - if (cursor) { - logger.warn(`[channelBridge] Session list pagination stopped after ${maxPages} pages; truncating /sessions results`); - } - return sessions.slice(0, displayLimit).map((row) => { const meta = tryDecryptSessionMetadata({ credentials, rawSession: row }); const tagRaw = meta?.tag; diff --git a/apps/cli/src/channels/telegram/telegramAdapter.ts b/apps/cli/src/channels/telegram/telegramAdapter.ts index 0f802dd141..61935921a4 100644 --- a/apps/cli/src/channels/telegram/telegramAdapter.ts +++ b/apps/cli/src/channels/telegram/telegramAdapter.ts @@ -4,6 +4,7 @@ import type { ChannelBridgeAdapter, ChannelBridgeInboundMessage } from '@/channe import { logger } from '@/ui/logger'; type TelegramSelfUser = Readonly<{ id: number; username: string | null }>; +type TelegramApiMethod = 'getMe' | 'getUpdates' | 'sendMessage'; type TelegramApiClient = Readonly<{ getMe: () => Promise; @@ -19,14 +20,33 @@ function telegramApiUrl(botToken: string, method: string): string { return `https://api.telegram.org/bot${botToken}/${method}`; } -function formatTelegramApiFailure(method: 'getMe' | 'getUpdates' | 'sendMessage', status: number, data: unknown): string { +function extractTelegramApiDescription(data: unknown): string | null { const record = asRecord(data); const description = record && typeof record.description === 'string' ? record.description.trim() : ''; - return description.length > 0 + return description.length > 0 ? description : null; +} + +function formatTelegramApiFailure(method: TelegramApiMethod, status: number, description: string | null): string { + return description && description.length > 0 ? `Telegram ${method} failed (${status}): ${description}` : `Telegram ${method} failed (${status})`; } +export class TelegramApiError extends Error { + readonly method: TelegramApiMethod; + readonly statusCode: number; + readonly description: string | null; + + constructor(params: Readonly<{ method: TelegramApiMethod; statusCode: number; data: unknown }>) { + const description = extractTelegramApiDescription(params.data); + super(formatTelegramApiFailure(params.method, params.statusCode, description)); + this.name = 'TelegramApiError'; + this.method = params.method; + this.statusCode = params.statusCode; + this.description = description; + } +} + function createDefaultTelegramApiClient(botToken: string): TelegramApiClient { return { getMe: async () => { @@ -35,7 +55,11 @@ function createDefaultTelegramApiClient(botToken: string): TelegramApiClient { validateStatus: () => true, }); if (response.status !== 200 || !response.data || response.data.ok !== true || !response.data.result) { - throw new Error(formatTelegramApiFailure('getMe', response.status, response.data)); + throw new TelegramApiError({ + method: 'getMe', + statusCode: response.status, + data: response.data, + }); } const user = response.data.result; return { @@ -54,7 +78,11 @@ function createDefaultTelegramApiClient(botToken: string): TelegramApiClient { validateStatus: () => true, }); if (response.status !== 200 || !response.data || response.data.ok !== true || !Array.isArray(response.data.result)) { - throw new Error(formatTelegramApiFailure('getUpdates', response.status, response.data)); + throw new TelegramApiError({ + method: 'getUpdates', + statusCode: response.status, + data: response.data, + }); } return response.data.result; }, @@ -72,7 +100,11 @@ function createDefaultTelegramApiClient(botToken: string): TelegramApiClient { validateStatus: () => true, }); if (response.status !== 200 || !response.data || response.data.ok !== true) { - throw new Error(formatTelegramApiFailure('sendMessage', response.status, response.data)); + throw new TelegramApiError({ + method: 'sendMessage', + statusCode: response.status, + data: response.data, + }); } }, }; diff --git a/apps/cli/src/cli/commands/bridge.ts b/apps/cli/src/cli/commands/bridge.ts index 329ea4651c..2a286712e8 100644 --- a/apps/cli/src/cli/commands/bridge.ts +++ b/apps/cli/src/cli/commands/bridge.ts @@ -104,7 +104,7 @@ ${chalk.bold('happier bridge')} - Channel bridge configuration (account-scoped) ${chalk.bold('Usage:')} happier bridge list - happier bridge telegram set --bot-token [--allowed-chat-ids |--allow-all] [--require-topics ] [--tick-ms ] [--webhook-enabled ] [--webhook-secret ] [--webhook-host ] [--webhook-port ] + happier bridge telegram set [--bot-token ] [--allowed-chat-ids |--allow-all] [--require-topics ] [--tick-ms ] [--webhook-enabled ] [--webhook-secret ] [--webhook-host ] [--webhook-port ] happier bridge telegram clear ${chalk.bold('Notes:')} From 48d2a6e6815edd203bc9a2ce9ce0605ba2fa2ca0 Mon Sep 17 00:00:00 2001 From: Chris Denneen Date: Fri, 6 Mar 2026 18:34:44 -0500 Subject: [PATCH 16/81] fix(cli): harden telegram adapter ack and kv validation --- .../channels/channelBridgeServerKv.test.ts | 40 +++++++++++ .../cli/src/channels/channelBridgeServerKv.ts | 8 ++- .../channels/core/channelBridgeWorker.test.ts | 40 +++++++++++ .../src/channels/core/channelBridgeWorker.ts | 2 +- .../channels/telegram/telegramAdapter.test.ts | 36 +++++++++- .../src/channels/telegram/telegramAdapter.ts | 67 +++++++++++++++++-- 6 files changed, 184 insertions(+), 9 deletions(-) diff --git a/apps/cli/src/channels/channelBridgeServerKv.test.ts b/apps/cli/src/channels/channelBridgeServerKv.test.ts index d70e9ebdda..1426b39053 100644 --- a/apps/cli/src/channels/channelBridgeServerKv.test.ts +++ b/apps/cli/src/channels/channelBridgeServerKv.test.ts @@ -267,6 +267,19 @@ describe('channelBridgeServerKv', () => { })).rejects.toThrow('Invalid telegram.webhook.port update payload'); }); + it('rejects non-loopback webhook host updates', async () => { + const kv = createInMemoryKvClient(); + + await expect(upsertChannelBridgeTelegramConfigInKv({ + kv, + serverId: 'local-3005', + accountId: 'acct-1', + update: { + webhookHost: '0.0.0.0', + }, + })).rejects.toThrow('Invalid telegram.webhook.host update payload'); + }); + it('replaces raw telegram config bytes in KV without schema parsing', async () => { const kv = createInMemoryKvClient(); @@ -885,6 +898,33 @@ describe('channelBridgeServerKv', () => { })).rejects.toThrow('Invalid telegram.allowedChatIds payload'); }); + it('throws when telegram config payload contains non-array allowedChatIds', async () => { + const malformedAllowedChatIdsTypeValueBase64 = Buffer.from(JSON.stringify({ + schemaVersion: 1, + telegram: { + allowedChatIds: '-100123', + }, + updatedAtMs: 123, + }), 'utf8').toString('base64'); + const kv: ChannelBridgeKvClient = { + get: async () => ({ + status: 200, + body: { + key: 'k', + value: malformedAllowedChatIdsTypeValueBase64, + version: 3, + }, + }), + mutate: async () => ({ status: 200, body: { success: true, results: [] } }), + }; + + await expect(readChannelBridgeTelegramConfigFromKv({ + kv, + serverId: 'local-3005', + accountId: 'acct-1', + })).rejects.toThrow('Invalid telegram.allowedChatIds payload'); + }); + it('throws when telegram config payload contains invalid webhook.port', async () => { const invalidWebhookPortValueBase64 = Buffer.from(JSON.stringify({ schemaVersion: 1, diff --git a/apps/cli/src/channels/channelBridgeServerKv.ts b/apps/cli/src/channels/channelBridgeServerKv.ts index 0d14660e8d..61dc575ca2 100644 --- a/apps/cli/src/channels/channelBridgeServerKv.ts +++ b/apps/cli/src/channels/channelBridgeServerKv.ts @@ -191,7 +191,10 @@ function parseTelegramConfigRecord(value: unknown): ChannelBridgeServerTelegramC out.tickMs = Math.trunc(record.tickMs); } - if (Array.isArray(telegram.allowedChatIds)) { + if (telegram.allowedChatIds !== undefined) { + if (!Array.isArray(telegram.allowedChatIds)) { + throw new ChannelBridgeBadPayloadError('Invalid telegram.allowedChatIds payload'); + } const normalizedAllowedChatIds = normalizeStringArray(telegram.allowedChatIds); if (telegram.allowedChatIds.length > 0 && normalizedAllowedChatIds.length === 0) { throw new ChannelBridgeBadPayloadError('Invalid telegram.allowedChatIds payload'); @@ -574,6 +577,9 @@ function applyTelegramConfigUpdate(current: ChannelBridgeServerTelegramConfigRec if (normalizedHost.length === 0) { throw new ChannelBridgeBadPayloadError('Invalid telegram.webhook.host update payload'); } + if (!isLoopbackHost(normalizedHost)) { + throw new ChannelBridgeBadPayloadError('Invalid telegram.webhook.host update payload'); + } nextWebhook.host = normalizedHost; } if (typeof update.webhookPort === 'number') { diff --git a/apps/cli/src/channels/core/channelBridgeWorker.test.ts b/apps/cli/src/channels/core/channelBridgeWorker.test.ts index c8e960e886..088e6a37d7 100644 --- a/apps/cli/src/channels/core/channelBridgeWorker.test.ts +++ b/apps/cli/src/channels/core/channelBridgeWorker.test.ts @@ -1630,6 +1630,46 @@ describe('executeChannelBridgeTick', () => { ).toBe(false); }); + it('treats typed Telegram permanent failures as non-retryable even for non-default provider ids', async () => { + const store = createInMemoryChannelBindingStore(); + const { deps, warnings } = createDepsHarness({ + fetchAgentMessagesAfterSeq: async () => [{ seq: 1, text: 'delivery fails permanently' }], + }); + + await store.upsertBinding({ + providerId: 'telegram-v2', + conversationId: '-3004b-alt', + threadId: null, + sessionId: 'sess-typed-permanent-alt', + lastForwardedSeq: 0, + }); + + const adapter: ChannelBridgeAdapter = { + providerId: 'telegram-v2', + pullInboundMessages: async () => [], + sendMessage: async () => { + throw new TelegramApiError({ + method: 'sendMessage', + statusCode: 403, + data: { description: 'Forbidden: bot was blocked by the user' }, + }); + }, + }; + + await executeChannelBridgeTick({ + store, + adapters: [adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + const [binding] = await store.listBindings(); + expect(binding?.lastForwardedSeq).toBe(1); + expect( + warnings.some((row) => row.message.includes('Detected permanent Telegram delivery failure')), + ).toBe(true); + }); + it('does not treat untyped telegram-like send errors as permanent failures', async () => { const store = createInMemoryChannelBindingStore(); const { deps, warnings } = createDepsHarness({ diff --git a/apps/cli/src/channels/core/channelBridgeWorker.ts b/apps/cli/src/channels/core/channelBridgeWorker.ts index ee544bd729..247dab6e7e 100644 --- a/apps/cli/src/channels/core/channelBridgeWorker.ts +++ b/apps/cli/src/channels/core/channelBridgeWorker.ts @@ -927,7 +927,7 @@ export async function executeChannelBridgeTick(params: Readonly<{ `sendMessage(${adapter.providerId})`, ); } catch (error) { - if (adapter.providerId === 'telegram' && isTelegramPermanentDeliveryFailure(error)) { + if (isTelegramPermanentDeliveryFailure(error)) { params.deps.onWarning?.( `Detected permanent Telegram delivery failure; advancing outbound cursor without retry for session=${binding.sessionId} provider=${binding.providerId} conversation=${binding.conversationId} seq=${nextSeq}`, error, diff --git a/apps/cli/src/channels/telegram/telegramAdapter.test.ts b/apps/cli/src/channels/telegram/telegramAdapter.test.ts index 663c26890b..4173a5a7f5 100644 --- a/apps/cli/src/channels/telegram/telegramAdapter.test.ts +++ b/apps/cli/src/channels/telegram/telegramAdapter.test.ts @@ -296,7 +296,35 @@ describe('createTelegramChannelAdapter', () => { ); }); - it('advances polling offset only after successful parsing', async () => { + it('truncates outbound telegram messages by Unicode code points without splitting surrogate pairs', async () => { + type SendMessageParams = Readonly<{ chatId: string; threadId: string | null; text: string }>; + + const api = { + getMe: vi.fn(async () => ({ id: 9, username: 'happier_bot' })), + getUpdates: vi.fn(async () => []), + sendMessage: vi.fn(async (_params: SendMessageParams) => undefined), + }; + + const adapter = createTelegramChannelAdapter({ + botToken: 'test-token', + api, + }); + + const nearBoundary = 'a'.repeat(4_095) + '🎉'; + await adapter.sendMessage({ + conversationId: '1234', + threadId: null, + text: nearBoundary, + }); + + expect(api.sendMessage).toHaveBeenCalledTimes(1); + const calls = api.sendMessage.mock.calls as SendMessageParams[][]; + expect(calls[0]?.[0]?.text).toBe(nearBoundary); + expect(Array.from(calls[0]?.[0]?.text ?? '').length).toBe(4_096); + expect(loggerWarn).not.toHaveBeenCalled(); + }); + + it('advances polling offset only after adapter acknowledgements in polling mode', async () => { const getMe = vi .fn() .mockRejectedValueOnce(new Error('temporary getMe failure')) @@ -346,6 +374,12 @@ describe('createTelegramChannelAdapter', () => { expect(parsed).toHaveLength(1); expect(getUpdates).toHaveBeenNthCalledWith(2, { offset: null, limit: 100 }); + const replayBeforeAck = await adapter.pullInboundMessages(); + expect(replayBeforeAck).toEqual(parsed); + expect(getUpdates).toHaveBeenCalledTimes(2); + + await adapter.ackInboundMessages?.(parsed); + await adapter.pullInboundMessages(); expect(getUpdates).toHaveBeenNthCalledWith(3, { offset: 12, limit: 100 }); }); diff --git a/apps/cli/src/channels/telegram/telegramAdapter.ts b/apps/cli/src/channels/telegram/telegramAdapter.ts index 61935921a4..9d3d02415f 100644 --- a/apps/cli/src/channels/telegram/telegramAdapter.ts +++ b/apps/cli/src/channels/telegram/telegramAdapter.ts @@ -235,6 +235,13 @@ export function createTelegramChannelAdapter(params: Readonly<{ maxUpdateId: number | null; }; + type PendingPollingBatchAck = { + messages: ChannelBridgeInboundMessage[]; + pendingMessageKeys: Set; + ackedMessageKeys: Set; + maxUpdateId: number | null; + }; + let selfBotId: number | null = null; /** * Telegram polling cursor (`getUpdates` offset). @@ -249,6 +256,7 @@ export function createTelegramChannelAdapter(params: Readonly<{ let nextQueuedWebhookId = 1; let droppedWebhookUpdates = 0; const pendingWebhookAcksByMessageKey = new Map(); + let pendingPollingBatchAck: PendingPollingBatchAck | null = null; function dropPendingWebhookAckIds(ids: ReadonlySet): void { if (ids.size === 0) return; @@ -354,6 +362,10 @@ export function createTelegramChannelAdapter(params: Readonly<{ return parsed; } + if (pendingPollingBatchAck) { + return pendingPollingBatchAck.messages.map((message) => ({ ...message })); + } + const updates = await api.getUpdates({ offset: updateOffset, limit: updateLimit, @@ -361,13 +373,55 @@ export function createTelegramChannelAdapter(params: Readonly<{ const parsed = await parseUpdates(updates); const maxUpdateId = parseHighestUpdateOffset(updates); - if (maxUpdateId !== null) { - updateOffset = maxUpdateId + 1; + if (parsed.length === 0) { + if (maxUpdateId !== null) { + const nextOffset = maxUpdateId + 1; + updateOffset = updateOffset === null ? nextOffset : Math.max(updateOffset, nextOffset); + } + return []; } + + pendingPollingBatchAck = { + messages: parsed, + pendingMessageKeys: new Set(parsed.map((message) => inboundMessageKey(message))), + ackedMessageKeys: new Set(), + maxUpdateId, + }; + return parsed; }, ackInboundMessages: async (messages) => { - if (!webhookMode || messages.length === 0) { + if (!webhookMode) { + const pendingBatch = pendingPollingBatchAck; + if (!pendingBatch || messages.length === 0) { + return; + } + + for (const message of messages) { + const key = inboundMessageKey(message); + if (pendingBatch.pendingMessageKeys.has(key)) { + pendingBatch.ackedMessageKeys.add(key); + } + } + + const allPendingMessagesAcked = + pendingBatch.pendingMessageKeys.size > 0 + && Array.from(pendingBatch.pendingMessageKeys).every((key) => pendingBatch.ackedMessageKeys.has(key)); + + if (!allPendingMessagesAcked) { + return; + } + + if (pendingBatch.maxUpdateId !== null) { + const nextOffset = pendingBatch.maxUpdateId + 1; + updateOffset = updateOffset === null ? nextOffset : Math.max(updateOffset, nextOffset); + } + + pendingPollingBatchAck = null; + return; + } + + if (messages.length === 0) { return; } @@ -403,15 +457,16 @@ export function createTelegramChannelAdapter(params: Readonly<{ }, sendMessage: async (message) => { const normalizedText = String(message.text).trim(); - const text = normalizedText.length > TELEGRAM_MAX_SEND_MESSAGE_TEXT_LENGTH - ? normalizedText.slice(0, TELEGRAM_MAX_SEND_MESSAGE_TEXT_LENGTH) + const codePoints = Array.from(normalizedText); + const text = codePoints.length > TELEGRAM_MAX_SEND_MESSAGE_TEXT_LENGTH + ? codePoints.slice(0, TELEGRAM_MAX_SEND_MESSAGE_TEXT_LENGTH).join('') : normalizedText; if (!text) { return; } - if (text.length !== normalizedText.length) { + if (text !== normalizedText) { logger.warn( `[channelBridge] Truncated Telegram outbound message for conversation ${message.conversationId} to ${TELEGRAM_MAX_SEND_MESSAGE_TEXT_LENGTH} characters`, ); From da20c828dcdd4a3ccfdb69a211ef019c7b474cb2 Mon Sep 17 00:00:00 2001 From: Chris Denneen Date: Fri, 6 Mar 2026 18:57:56 -0500 Subject: [PATCH 17/81] chore(cli): address follow-up reviewer nitpicks on bridge runtime --- apps/cli/src/channels/channelBridgeServerKv.ts | 3 +++ apps/cli/src/channels/core/channelBridgeWorker.ts | 2 ++ apps/cli/src/channels/startChannelBridgeWorker.ts | 3 +++ apps/cli/src/channels/telegram/telegramAdapter.ts | 2 +- 4 files changed, 9 insertions(+), 1 deletion(-) diff --git a/apps/cli/src/channels/channelBridgeServerKv.ts b/apps/cli/src/channels/channelBridgeServerKv.ts index 61dc575ca2..27cdfa4a0e 100644 --- a/apps/cli/src/channels/channelBridgeServerKv.ts +++ b/apps/cli/src/channels/channelBridgeServerKv.ts @@ -545,6 +545,9 @@ function applyTelegramConfigUpdate(current: ChannelBridgeServerTelegramConfigRec if (typeof current?.tickMs === 'number') { next.tickMs = current.tickMs; } + // `current` may be null when the existing KV payload is absent/unsupported and the caller + // intentionally rewrites canonical schema. In that case, unspecified tickMs is not carried + // forward and runtime defaults apply unless update.tickMs is explicitly provided. if (typeof update.tickMs === 'number' && Number.isFinite(update.tickMs)) { next.tickMs = Math.trunc(update.tickMs); } diff --git a/apps/cli/src/channels/core/channelBridgeWorker.ts b/apps/cli/src/channels/core/channelBridgeWorker.ts index 247dab6e7e..59af1df27f 100644 --- a/apps/cli/src/channels/core/channelBridgeWorker.ts +++ b/apps/cli/src/channels/core/channelBridgeWorker.ts @@ -220,6 +220,8 @@ export function createChannelBridgeInboundDeduper(now: () => number = () => Date const recent = new Map(); const ttlMs = 24 * 60 * 60 * 1000; const maxEntries = 100_000; + // Avoid full TTL scans on every message. If the map exceeds maxEntries, + // prune still runs immediately even within the minimum interval. const minPruneIntervalMs = 1_000; let lastPrunedAtMs = 0; diff --git a/apps/cli/src/channels/startChannelBridgeWorker.ts b/apps/cli/src/channels/startChannelBridgeWorker.ts index 22ddf64be0..b4c495d4ee 100644 --- a/apps/cli/src/channels/startChannelBridgeWorker.ts +++ b/apps/cli/src/channels/startChannelBridgeWorker.ts @@ -123,6 +123,9 @@ function createDefaultChannelBridgeDeps(credentials: Credentials): DefaultChanne const sessionRuntimeCache = new Map(); const SESSION_CTX_CACHE_MAX_ENTRIES = 200; + // Bounded LRU-style cache: cache hits are reinserted to the tail as most-recently-used. + // No TTL is applied by design to avoid per-tick server fetches; stale entries are naturally + // refreshed on miss/eviction and this cache stores only encryption runtime metadata. const rememberSessionRuntime = (sessionId: string, runtime: CachedSessionRuntime): CachedSessionRuntime => { if (sessionRuntimeCache.has(sessionId)) { sessionRuntimeCache.delete(sessionId); diff --git a/apps/cli/src/channels/telegram/telegramAdapter.ts b/apps/cli/src/channels/telegram/telegramAdapter.ts index 9d3d02415f..030e5a9a77 100644 --- a/apps/cli/src/channels/telegram/telegramAdapter.ts +++ b/apps/cli/src/channels/telegram/telegramAdapter.ts @@ -13,7 +13,7 @@ type TelegramApiClient = Readonly<{ }>; const TELEGRAM_GET_UPDATES_LONG_POLL_TIMEOUT_SECONDS = 25; -const TELEGRAM_GET_UPDATES_HTTP_TIMEOUT_MS = 30_000; +const TELEGRAM_GET_UPDATES_HTTP_TIMEOUT_MS = (TELEGRAM_GET_UPDATES_LONG_POLL_TIMEOUT_SECONDS + 10) * 1_000; const TELEGRAM_MAX_SEND_MESSAGE_TEXT_LENGTH = 4_096; function telegramApiUrl(botToken: string, method: string): string { From f291e28c212246442e885767b60f5c2da2f08c1d Mon Sep 17 00:00:00 2001 From: Chris Denneen Date: Fri, 6 Mar 2026 19:23:55 -0500 Subject: [PATCH 18/81] fix(cli): close remaining review gaps in bridge and doctor flows --- .../src/channels/channelBridgeConfig.test.ts | 2 +- apps/cli/src/channels/channelBridgeConfig.ts | 3 +- .../channels/core/channelBridgeWorker.test.ts | 48 +++++++++++++++++++ .../src/channels/core/channelBridgeWorker.ts | 3 ++ apps/cli/src/ui/doctor.test.ts | 27 ++++++++++- apps/cli/src/ui/doctor.ts | 8 ++++ 6 files changed, 88 insertions(+), 3 deletions(-) diff --git a/apps/cli/src/channels/channelBridgeConfig.test.ts b/apps/cli/src/channels/channelBridgeConfig.test.ts index 5b21ea1dc0..f5edf2fb45 100644 --- a/apps/cli/src/channels/channelBridgeConfig.test.ts +++ b/apps/cli/src/channels/channelBridgeConfig.test.ts @@ -32,7 +32,7 @@ describe('resolveChannelBridgeRuntimeConfig', () => { expect(config.telegram.requireTopics).toBe(true); expect(config.telegram.webhookEnabled).toBe(true); expect(config.telegram.webhookSecret).toBe('settings-secret'); - expect(config.telegram.webhookHost).toBe('0.0.0.0'); + expect(config.telegram.webhookHost).toBe('127.0.0.1'); expect(config.telegram.webhookPort).toBe(9_001); }); diff --git a/apps/cli/src/channels/channelBridgeConfig.ts b/apps/cli/src/channels/channelBridgeConfig.ts index e306479c11..2189b4d509 100644 --- a/apps/cli/src/channels/channelBridgeConfig.ts +++ b/apps/cli/src/channels/channelBridgeConfig.ts @@ -215,12 +215,13 @@ export function resolveChannelBridgeRuntimeConfig(params: Readonly<{ ? env.HAPPIER_TELEGRAM_WEBHOOK_SECRET.trim() : settingsWebhookSecret; - const settingsWebhookHost = + const settingsWebhookHostRaw = firstParsed( [webhookAccount?.host, webhookServer?.host, webhookGlobal?.host], readTrimmedString, ) || '127.0.0.1'; + const settingsWebhookHost = isLoopbackHost(settingsWebhookHostRaw) ? settingsWebhookHostRaw : '127.0.0.1'; const envWebhookHostRaw = typeof env.HAPPIER_TELEGRAM_WEBHOOK_HOST === 'string' ? env.HAPPIER_TELEGRAM_WEBHOOK_HOST.trim() diff --git a/apps/cli/src/channels/core/channelBridgeWorker.test.ts b/apps/cli/src/channels/core/channelBridgeWorker.test.ts index 088e6a37d7..447a7a2848 100644 --- a/apps/cli/src/channels/core/channelBridgeWorker.test.ts +++ b/apps/cli/src/channels/core/channelBridgeWorker.test.ts @@ -1944,6 +1944,54 @@ describe('executeChannelBridgeTick', () => { expect(acknowledged).toHaveLength(1); expect(acknowledged[0]?.map((message) => message.messageId)).toEqual(['m-ack-1']); }); + + it('acknowledges slash-command and no-binding inbound messages in the same tick', async () => { + const store = createInMemoryChannelBindingStore(); + + const acknowledged: ChannelBridgeInboundMessage[][] = []; + const adapter: ChannelBridgeAdapter = { + providerId: 'telegram', + pullInboundMessages: async () => [ + { + providerId: 'telegram', + conversationId: 'room-cmd-ack', + threadId: null, + senderId: 'user-1', + text: '/sessions', + messageId: 'm-ack-command', + }, + { + providerId: 'telegram', + conversationId: 'room-cmd-ack', + threadId: null, + senderId: 'user-1', + text: 'hello on unbound conversation', + messageId: 'm-ack-unbound', + }, + ], + ackInboundMessages: async (messages) => { + acknowledged.push(messages.map((message) => ({ ...message }))); + }, + sendMessage: async () => {}, + }; + + const depsHarness = createDepsHarness({ + sessions: [{ sessionId: 'sess-1', label: 'demo' }], + }); + + await executeChannelBridgeTick({ + store, + adapters: [adapter], + deps: depsHarness.deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(acknowledged).toHaveLength(1); + expect(acknowledged[0]?.map((message) => message.messageId)).toEqual([ + 'm-ack-command', + 'm-ack-unbound', + ]); + }); }); describe('startChannelBridgeWorker', () => { it('runs the first tick on startup', async () => { diff --git a/apps/cli/src/channels/core/channelBridgeWorker.ts b/apps/cli/src/channels/core/channelBridgeWorker.ts index 59af1df27f..4c35619d64 100644 --- a/apps/cli/src/channels/core/channelBridgeWorker.ts +++ b/apps/cli/src/channels/core/channelBridgeWorker.ts @@ -716,6 +716,7 @@ export async function executeChannelBridgeTick(params: Readonly<{ store: params.store, deps: params.deps, }); + ackableInbound.push(event); processedSuccessfully = true; continue; } @@ -725,6 +726,7 @@ export async function executeChannelBridgeTick(params: Readonly<{ conversationId: event.conversationId, threadId: event.threadId, }, 'Unknown command. Use /help for supported commands.'); + ackableInbound.push(event); processedSuccessfully = true; continue; } @@ -760,6 +762,7 @@ export async function executeChannelBridgeTick(params: Readonly<{ ref, 'No session is attached here. Use /attach first.', ); + ackableInbound.push(event); processedSuccessfully = true; continue; } diff --git a/apps/cli/src/ui/doctor.test.ts b/apps/cli/src/ui/doctor.test.ts index 31ceb886fe..c79a0090ea 100644 --- a/apps/cli/src/ui/doctor.test.ts +++ b/apps/cli/src/ui/doctor.test.ts @@ -4,6 +4,7 @@ import { resolveTelegramWebhookValidationInputs, isMissingRequiredTelegramWebhookSecret, parseStrictWebhookPort, + applyDoctorExitCode, maskValue, redactSettingsForDisplay, redactDaemonStateForDisplay, @@ -258,6 +259,30 @@ describe('telegram webhook validation inputs', () => { ).toEqual({ webhookHost: '', webhookPort: null, - }); }); + }); +}); + +describe('doctor exit code behavior', () => { + it('sets process exit code to 1 when critical failures are present', () => { + const previousExitCode = process.exitCode; + try { + process.exitCode = 0; + applyDoctorExitCode(true); + expect(process.exitCode).toBe(1); + } finally { + process.exitCode = previousExitCode; + } + }); + + it('leaves process exit code unchanged when no critical failures are present', () => { + const previousExitCode = process.exitCode; + try { + process.exitCode = 0; + applyDoctorExitCode(false); + expect(process.exitCode).toBe(0); + } finally { + process.exitCode = previousExitCode; + } + }); }); diff --git a/apps/cli/src/ui/doctor.ts b/apps/cli/src/ui/doctor.ts index 0b3e64fc18..fdc13dc0e4 100644 --- a/apps/cli/src/ui/doctor.ts +++ b/apps/cli/src/ui/doctor.ts @@ -264,6 +264,12 @@ export function shouldShowGlobalProcessInventory(filter: 'all' | 'daemon'): bool return filter === 'all'; } +export function applyDoctorExitCode(hasCriticalFailures: boolean): void { + if (hasCriticalFailures) { + process.exitCode = 1; + } +} + export async function runDoctorCommand(filter?: 'all' | 'daemon'): Promise { // Default to 'all' if no filter specified if (!filter) { @@ -692,4 +698,6 @@ export async function runDoctorCommand(filter?: 'all' | 'daemon'): Promise } else { console.log(chalk.green('\n✅ Doctor diagnosis complete!\n')); } + + applyDoctorExitCode(hasCriticalFailures); } From 6afcaffe0e437945632c1a9954d0fafcda636863 Mon Sep 17 00:00:00 2001 From: Chris Denneen Date: Fri, 6 Mar 2026 19:48:46 -0500 Subject: [PATCH 19/81] fix(cli): harden scoped fallbacks and inbound dedupe retry semantics --- .../src/channels/channelBridgeConfig.test.ts | 79 +++++++++++++++++++ apps/cli/src/channels/channelBridgeConfig.ts | 8 +- .../channels/core/channelBridgeWorker.test.ts | 69 ++++++++++++++++ .../src/channels/core/channelBridgeWorker.ts | 36 ++++++--- 4 files changed, 181 insertions(+), 11 deletions(-) diff --git a/apps/cli/src/channels/channelBridgeConfig.test.ts b/apps/cli/src/channels/channelBridgeConfig.test.ts index f5edf2fb45..7a9a03a5d4 100644 --- a/apps/cli/src/channels/channelBridgeConfig.test.ts +++ b/apps/cli/src/channels/channelBridgeConfig.test.ts @@ -185,6 +185,85 @@ describe('resolveChannelBridgeRuntimeConfig', () => { expect(config.telegram.allowedChatIds).toEqual(['-100-server']); }); + it('falls back to lower scope when account allowedChatIds string normalizes empty', () => { + const config = resolveChannelBridgeRuntimeConfig({ + env: {}, + serverId: 'local-test', + accountId: 'acct-123', + settings: { + channelBridge: { + byServerId: { + 'local-test': { + providers: { + telegram: { + allowedChatIds: ['-100-server'], + }, + }, + byAccountId: { + 'acct-123': { + providers: { + telegram: { + allowedChatIds: ', ,', + }, + }, + }, + }, + }, + }, + }, + }, + }); + + expect(config.telegram.allowedChatIds).toEqual(['-100-server']); + }); + + it('falls back to lower scope when higher-scope string secrets are blank', () => { + const config = resolveChannelBridgeRuntimeConfig({ + env: {}, + serverId: 'local-test', + accountId: 'acct-123', + settings: { + channelBridge: { + providers: { + telegram: { + botToken: 'global-token', + webhook: { + secret: 'global-secret', + }, + }, + }, + byServerId: { + 'local-test': { + providers: { + telegram: { + botToken: 'server-token', + webhook: { + secret: 'server-secret', + }, + }, + }, + byAccountId: { + 'acct-123': { + providers: { + telegram: { + botToken: ' ', + webhook: { + secret: ' ', + }, + }, + }, + }, + }, + }, + }, + }, + }, + }); + + expect(config.telegram.botToken).toBe('server-token'); + expect(config.telegram.webhookSecret).toBe('server-secret'); + }); + it('ignores non-loopback env webhook host and keeps settings host', () => { const config = resolveChannelBridgeRuntimeConfig({ env: { diff --git a/apps/cli/src/channels/channelBridgeConfig.ts b/apps/cli/src/channels/channelBridgeConfig.ts index 2189b4d509..310a854079 100644 --- a/apps/cli/src/channels/channelBridgeConfig.ts +++ b/apps/cli/src/channels/channelBridgeConfig.ts @@ -45,7 +45,10 @@ function parseCsv(raw: string): string[] { } function parseStringArray(value: unknown): string[] | null { - if (typeof value === 'string') return parseCsv(value); + if (typeof value === 'string') { + const parsed = parseCsv(value); + return parsed.length > 0 ? parsed : null; + } if (!Array.isArray(value)) return null; const out = value .map((entry) => { @@ -62,7 +65,8 @@ function parseStringArray(value: unknown): string[] | null { function readTrimmedString(value: unknown): string | null { if (typeof value !== 'string') return null; - return value.trim(); + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; } function firstParsed(values: readonly unknown[], parse: (value: unknown) => T | null): T | null { diff --git a/apps/cli/src/channels/core/channelBridgeWorker.test.ts b/apps/cli/src/channels/core/channelBridgeWorker.test.ts index 447a7a2848..7b5926d1d0 100644 --- a/apps/cli/src/channels/core/channelBridgeWorker.test.ts +++ b/apps/cli/src/channels/core/channelBridgeWorker.test.ts @@ -1402,6 +1402,75 @@ describe('executeChannelBridgeTick', () => { expect(sentToSession).toHaveLength(1); }); + it('does not mark inbound messages as seen when processing fails before completion', async () => { + const baseStore = createInMemoryChannelBindingStore(); + const ref = { + providerId: 'telegram', + conversationId: 'retry-on-failure-room', + threadId: null, + } as const; + + await baseStore.upsertBinding({ + ...ref, + sessionId: 'sess-retry-on-failure', + lastForwardedSeq: 0, + }); + + let failGetBindingOnce = true; + const store: ChannelBindingStore = { + ...baseStore, + getBinding: async (bindingRef) => { + if (failGetBindingOnce) { + failGetBindingOnce = false; + throw new Error('temporary store failure'); + } + return await baseStore.getBinding(bindingRef); + }, + }; + + const repeatedEvent: ChannelBridgeInboundMessage = { + providerId: 'telegram', + conversationId: 'retry-on-failure-room', + threadId: null, + senderId: 'user-1', + text: 'retry me', + messageId: 'm-retry-after-failure', + }; + + let pullCount = 0; + const adapter: ChannelBridgeAdapter = { + providerId: 'telegram', + pullInboundMessages: async () => { + pullCount += 1; + if (pullCount <= 2) { + return [repeatedEvent]; + } + return []; + }, + sendMessage: async () => {}, + }; + + const { deps, sentToSession, warnings } = createDepsHarness(); + const deduper = createChannelBridgeInboundDeduper(); + + await executeChannelBridgeTick({ + store, + adapters: [adapter], + deps, + inboundDeduper: deduper, + }); + await executeChannelBridgeTick({ + store, + adapters: [adapter], + deps, + inboundDeduper: deduper, + }); + + expect(warnings.some((row) => row.message.includes('Failed to read binding for inbound message forwarding'))).toBe(true); + expect(sentToSession).toHaveLength(1); + expect(sentToSession[0]?.text).toBe('retry me'); + }); + it('does not dedupe messages when inbound messageId is empty', async () => { const store = createInMemoryChannelBindingStore(); const harness = createAdapterHarness(); diff --git a/apps/cli/src/channels/core/channelBridgeWorker.ts b/apps/cli/src/channels/core/channelBridgeWorker.ts index 4c35619d64..9b486d6593 100644 --- a/apps/cli/src/channels/core/channelBridgeWorker.ts +++ b/apps/cli/src/channels/core/channelBridgeWorker.ts @@ -208,7 +208,8 @@ function isTelegramPermanentDeliveryFailure(error: unknown): boolean { } type ChannelBridgeInboundDeduper = Readonly<{ - isDuplicate: (message: ChannelBridgeInboundMessage) => boolean; + hasSeen: (message: ChannelBridgeInboundMessage) => boolean; + markSeen: (message: ChannelBridgeInboundMessage) => void; }>; /** @@ -243,19 +244,32 @@ export function createChannelBridgeInboundDeduper(now: () => number = () => Date } }; + const dedupeKey = (message: ChannelBridgeInboundMessage): string | null => { + const normalizedMessageId = String(message.messageId).trim(); + if (normalizedMessageId.length === 0) { + return null; + } + return JSON.stringify([message.providerId, message.conversationId, message.threadId, normalizedMessageId]); + }; + return { - isDuplicate: (message) => { - const normalizedMessageId = String(message.messageId).trim(); - if (normalizedMessageId.length === 0) { + hasSeen: (message) => { + const key = dedupeKey(message); + if (!key) { return false; } - - const key = JSON.stringify([message.providerId, message.conversationId, message.threadId, normalizedMessageId]); const currentNow = now(); prune(currentNow); - if (recent.has(key)) return true; + return recent.has(key); + }, + markSeen: (message) => { + const key = dedupeKey(message); + if (!key) { + return; + } + const currentNow = now(); + prune(currentNow); recent.set(key, currentNow); - return false; }, }; } @@ -699,7 +713,7 @@ export async function executeChannelBridgeTick(params: Readonly<{ ); } - if (deduper.isDuplicate(event)) { + if (deduper.hasSeen(event)) { ackableInbound.push(event); continue; } @@ -716,6 +730,7 @@ export async function executeChannelBridgeTick(params: Readonly<{ store: params.store, deps: params.deps, }); + deduper.markSeen(event); ackableInbound.push(event); processedSuccessfully = true; continue; @@ -726,6 +741,7 @@ export async function executeChannelBridgeTick(params: Readonly<{ conversationId: event.conversationId, threadId: event.threadId, }, 'Unknown command. Use /help for supported commands.'); + deduper.markSeen(event); ackableInbound.push(event); processedSuccessfully = true; continue; @@ -762,6 +778,7 @@ export async function executeChannelBridgeTick(params: Readonly<{ ref, 'No session is attached here. Use /attach first.', ); + deduper.markSeen(event); ackableInbound.push(event); processedSuccessfully = true; continue; @@ -797,6 +814,7 @@ export async function executeChannelBridgeTick(params: Readonly<{ } if (processedSuccessfully) { + deduper.markSeen(event); ackableInbound.push(event); } } From 10a7e494eb14b9223aa2253275f9f37e5c628a25 Mon Sep 17 00:00:00 2001 From: Chris Denneen Date: Fri, 6 Mar 2026 20:13:08 -0500 Subject: [PATCH 20/81] fix(cli): align bridge tick deduper type with runtime API --- apps/cli/src/channels/core/channelBridgeWorker.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/apps/cli/src/channels/core/channelBridgeWorker.ts b/apps/cli/src/channels/core/channelBridgeWorker.ts index 9b486d6593..02ab9826aa 100644 --- a/apps/cli/src/channels/core/channelBridgeWorker.ts +++ b/apps/cli/src/channels/core/channelBridgeWorker.ts @@ -665,9 +665,7 @@ export async function executeChannelBridgeTick(params: Readonly<{ store: ChannelBindingStore; adapters: readonly ChannelBridgeAdapter[]; deps: ChannelBridgeDeps; - inboundDeduper: Readonly<{ - isDuplicate: (message: ChannelBridgeInboundMessage) => boolean; - }>; + inboundDeduper: ChannelBridgeInboundDeduper; warnedMissingAdapterBindings?: Set; }>): Promise { const activeAdapters: ChannelBridgeAdapter[] = []; From da28bfada0735b0af601ffcb5c9bdb6d624ab691 Mon Sep 17 00:00:00 2001 From: Chris Denneen Date: Fri, 6 Mar 2026 20:38:12 -0500 Subject: [PATCH 21/81] fix(cli): advance bridge cursor across non-agent transcript windows --- .../channels/core/channelBridgeWorker.test.ts | 36 +++++++++++++++ .../src/channels/core/channelBridgeWorker.ts | 44 ++++++++++++++++++- .../channels/startChannelBridgeWorker.test.ts | 19 ++++---- .../src/channels/startChannelBridgeWorker.ts | 28 +++++++++++- 4 files changed, 115 insertions(+), 12 deletions(-) diff --git a/apps/cli/src/channels/core/channelBridgeWorker.test.ts b/apps/cli/src/channels/core/channelBridgeWorker.test.ts index 7b5926d1d0..78a8fe32d2 100644 --- a/apps/cli/src/channels/core/channelBridgeWorker.test.ts +++ b/apps/cli/src/channels/core/channelBridgeWorker.test.ts @@ -760,6 +760,42 @@ describe('executeChannelBridgeTick', () => { expect(binding?.lastForwardedSeq).toBe(11); }); + it('advances outbound cursor when transcript page contains only non-agent rows', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + + await store.upsertBinding({ + providerId: 'telegram', + conversationId: '-1005b', + threadId: null, + sessionId: 'sess-non-agent-window', + lastForwardedSeq: 9, + }); + + const { deps } = createDepsHarness({ + fetchAgentMessagesAfterSeq: async ({ afterSeq }: { afterSeq: number }) => { + if (afterSeq === 9) { + return { + messages: [], + highestSeenSeq: 59, + }; + } + return []; + }, + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(harness.sent).toEqual([]); + const [binding] = await store.listBindings(); + expect(binding?.lastForwardedSeq).toBe(59); + }); + it('skips agent rows with invalid seq values and continues forwarding valid rows', async () => { const store = createInMemoryChannelBindingStore(); const harness = createAdapterHarness(); diff --git a/apps/cli/src/channels/core/channelBridgeWorker.ts b/apps/cli/src/channels/core/channelBridgeWorker.ts index 02ab9826aa..c013c1e1d4 100644 --- a/apps/cli/src/channels/core/channelBridgeWorker.ts +++ b/apps/cli/src/channels/core/channelBridgeWorker.ts @@ -85,12 +85,24 @@ export type ResolveSessionIdResult = | Readonly<{ ok: true; sessionId: string }> | Readonly<{ ok: false; code: 'session_not_found' | 'session_id_ambiguous' | 'unsupported'; candidates?: string[] }>; +export type ChannelBridgeAgentMessageRow = Readonly<{ + seq: number; + text: string; +}>; + +export type ChannelBridgeAgentFetchResult = Readonly<{ + messages: readonly ChannelBridgeAgentMessageRow[]; + highestSeenSeq?: number | null; +}>; + /** * Bridge dependencies supplied by runtime integration. * * - `resolveLatestSessionSeq` should return the latest valid non-negative transcript cursor. * - `fetchAgentMessagesAfterSeq` should return rows with `seq > afterSeq`. * Results may be unsorted; the worker enforces ascending `seq` delivery before forwarding. + * - `fetchAgentMessagesAfterSeq` may optionally return `highestSeenSeq` when no agent rows are + * present so the worker can advance cursor windows across non-agent transcript pages. * - `onWarning` receives non-fatal operational issues; worker continues best-effort. */ export type ChannelBridgeDeps = Readonly<{ @@ -105,7 +117,9 @@ export type ChannelBridgeDeps = Readonly<{ threadId: string | null; }>) => Promise; resolveLatestSessionSeq: (sessionId: string) => Promise; - fetchAgentMessagesAfterSeq: (params: Readonly<{ sessionId: string; afterSeq: number }>) => Promise>>; + fetchAgentMessagesAfterSeq: (params: Readonly<{ sessionId: string; afterSeq: number }>) => Promise< + readonly ChannelBridgeAgentMessageRow[] | ChannelBridgeAgentFetchResult + >; authorizeCommand?: (params: Readonly<{ commandName: string; actor: ChannelBridgeActorContext }>) => Promise>; onWarning?: (message: string, error?: unknown) => void; }>; @@ -274,6 +288,22 @@ export function createChannelBridgeInboundDeduper(now: () => number = () => Date }; } +function normalizeAgentFetchResult( + fetched: readonly ChannelBridgeAgentMessageRow[] | ChannelBridgeAgentFetchResult, +): Readonly<{ messages: readonly ChannelBridgeAgentMessageRow[]; highestSeenSeq: number | null }> { + if (Array.isArray(fetched)) { + return { + messages: fetched, + highestSeenSeq: null, + }; + } + + return { + messages: fetched.messages, + highestSeenSeq: toNonNegativeInt(fetched.highestSeenSeq ?? null), + }; +} + /** * Create an in-memory binding store. * @@ -868,7 +898,7 @@ export async function executeChannelBridgeTick(params: Readonly<{ warnedMissingAdapterBindings?.delete(missingBindingWarningKey); try { - const messages = await withTimeout( + const fetchedMessages = await withTimeout( params.deps.fetchAgentMessagesAfterSeq({ sessionId: binding.sessionId, afterSeq: binding.lastForwardedSeq, @@ -877,6 +907,11 @@ export async function executeChannelBridgeTick(params: Readonly<{ `fetchAgentMessagesAfterSeq(${binding.sessionId})`, ); + const { + messages, + highestSeenSeq, + } = normalizeAgentFetchResult(fetchedMessages); + const orderedMessages: Array> = []; for (const row of messages) { const parsedSeq = toNonNegativeInt(row.seq); @@ -922,6 +957,11 @@ export async function executeChannelBridgeTick(params: Readonly<{ } }; + if (orderedMessages.length === 0 && highestSeenSeq !== null && highestSeenSeq > maxSeq) { + await persistCursor(highestSeenSeq); + continue; + } + for (const row of orderedMessages) { const parsedSeq = row.seq; if (parsedSeq <= maxSeq) { diff --git a/apps/cli/src/channels/startChannelBridgeWorker.test.ts b/apps/cli/src/channels/startChannelBridgeWorker.test.ts index 8b0dd05ff9..096f6ea88e 100644 --- a/apps/cli/src/channels/startChannelBridgeWorker.test.ts +++ b/apps/cli/src/channels/startChannelBridgeWorker.test.ts @@ -160,7 +160,7 @@ describe('startChannelBridgeFromEnv', () => { conversationId: string; threadId: string | null; }) => Promise; - fetchAgentMessagesAfterSeq: (params: { sessionId: string; afterSeq: number }) => Promise>; + fetchAgentMessagesAfterSeq: (params: { sessionId: string; afterSeq: number }) => Promise; } | null = null; vi.doMock('@/sessionControl/sessionsHttp', async (importOriginal) => { @@ -299,7 +299,7 @@ describe('startChannelBridgeFromEnv', () => { ])); let capturedDeps: { - fetchAgentMessagesAfterSeq: (params: { sessionId: string; afterSeq: number }) => Promise>; + fetchAgentMessagesAfterSeq: (params: { sessionId: string; afterSeq: number }) => Promise; } | null = null; vi.doMock('@/sessionControl/sessionsHttp', async (importOriginal) => { @@ -374,12 +374,15 @@ describe('startChannelBridgeFromEnv', () => { afterSeq: 0, }); - expect(rows).toEqual([ - { - seq: 11, - text: 'plain assistant output', - }, - ]); + expect(rows).toEqual({ + messages: [ + { + seq: 11, + text: 'plain assistant output', + }, + ], + highestSeenSeq: 12, + }); expect(decryptRows).not.toHaveBeenCalled(); await handle?.stop(); diff --git a/apps/cli/src/channels/startChannelBridgeWorker.ts b/apps/cli/src/channels/startChannelBridgeWorker.ts index b4c495d4ee..70c0037867 100644 --- a/apps/cli/src/channels/startChannelBridgeWorker.ts +++ b/apps/cli/src/channels/startChannelBridgeWorker.ts @@ -21,6 +21,7 @@ import { startChannelBridgeWorker, type ChannelBindingStore, type ChannelBridgeAdapter, + type ChannelBridgeAgentMessageRow, type ChannelBridgeDeps, type ChannelBridgeWorkerHandle, } from './core/channelBridgeWorker'; @@ -42,6 +43,12 @@ function asRecord(value: unknown): Record | null { return value as Record; } +function toNonNegativeInt(value: unknown): number | null { + if (typeof value !== 'number' || !Number.isFinite(value)) return null; + const truncated = Math.trunc(value); + return truncated >= 0 ? truncated : null; +} + function extractAssistantText(content: unknown): string | null { const contentRecord = asRecord(content); if (!contentRecord) return null; @@ -242,7 +249,12 @@ function createDefaultChannelBridgeDeps(credentials: Credentials): DefaultChanne }, fetchAgentMessagesAfterSeq: async ({ sessionId, afterSeq }) => { const runtime = await resolveSessionRuntime(sessionId); - if (!runtime) return []; + if (!runtime) { + return { + messages: [], + highestSeenSeq: null, + }; + } const encrypted = await fetchEncryptedTranscriptPageAfterSeq({ token: credentials.token, @@ -251,6 +263,13 @@ function createDefaultChannelBridgeDeps(credentials: Credentials): DefaultChanne limit: 50, }); + const highestSeenSeq = encrypted.reduce((currentMax, row) => { + const parsedSeq = toNonNegativeInt(row.seq); + if (parsedSeq === null) return currentMax; + if (currentMax === null) return parsedSeq; + return parsedSeq > currentMax ? parsedSeq : currentMax; + }, null); + const transcriptRows = runtime.mode === 'plain' ? parsePlainTranscriptRows(encrypted) : decryptTranscriptRows({ @@ -258,11 +277,16 @@ function createDefaultChannelBridgeDeps(credentials: Credentials): DefaultChanne rows: encrypted, }); - return transcriptRows + const messages: ChannelBridgeAgentMessageRow[] = transcriptRows .filter((row) => row.role === 'agent') .map((row) => ({ seq: row.seq, text: extractAssistantText(row.content) })) .filter((row) => typeof row.text === 'string' && row.text.trim().length > 0) .map((row) => ({ seq: row.seq, text: row.text! })); + + return { + messages, + highestSeenSeq, + }; }, onWarning: (message, error) => { if (typeof error === 'undefined') { From 49ed7624730fbe52aa2f541b1472a2acf9b7d22a Mon Sep 17 00:00:00 2001 From: Chris Denneen Date: Fri, 6 Mar 2026 21:04:36 -0500 Subject: [PATCH 22/81] fix(cli): harden command acking and startup cleanup for bridge worker --- .../channels/core/channelBridgeWorker.test.ts | 80 ++++++++++++++-- .../src/channels/core/channelBridgeWorker.ts | 72 +++++++-------- .../channels/startChannelBridgeWorker.test.ts | 78 ++++++++++++++++ .../src/channels/startChannelBridgeWorker.ts | 91 +++++++++---------- 4 files changed, 231 insertions(+), 90 deletions(-) diff --git a/apps/cli/src/channels/core/channelBridgeWorker.test.ts b/apps/cli/src/channels/core/channelBridgeWorker.test.ts index 78a8fe32d2..28e692de34 100644 --- a/apps/cli/src/channels/core/channelBridgeWorker.test.ts +++ b/apps/cli/src/channels/core/channelBridgeWorker.test.ts @@ -80,12 +80,14 @@ function createAdapterHarness(providerId: string = 'telegram'): { pushInbound: (event: ChannelBridgeInboundMessage) => void; sent: SentConversationMessage[]; failPullOnce: (error: Error) => void; + failSendOnce: (error: Error) => void; stopCalls: () => number; pendingInboundCount: () => number; } { const queue: ChannelBridgeInboundMessage[] = []; const sent: SentConversationMessage[] = []; let pullError: Error | null = null; + let sendError: Error | null = null; let stopCallCount = 0; return { @@ -102,6 +104,11 @@ function createAdapterHarness(providerId: string = 'telegram'): { return items; }, sendMessage: async (params) => { + if (sendError) { + const error = sendError; + sendError = null; + throw error; + } sent.push({ conversationId: params.conversationId, threadId: params.threadId, @@ -119,6 +126,9 @@ function createAdapterHarness(providerId: string = 'telegram'): { failPullOnce: (error) => { pullError = error; }, + failSendOnce: (error) => { + sendError = error; + }, stopCalls: () => stopCallCount, pendingInboundCount: () => queue.length, }; @@ -339,6 +349,50 @@ describe('executeChannelBridgeTick', () => { expect(harness.sent.some((row) => row.text.includes('replaced previous session sess-old'))).toBe(true); }); + it('does not retry /attach when success reply delivery fails', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + let latestSeqCallCount = 0; + + const { deps } = createDepsHarness({ + resolveSessionIdOrPrefix: async () => ({ ok: true as const, sessionId: 'sess-no-retry' }), + resolveLatestSessionSeq: async () => { + latestSeqCallCount += 1; + return latestSeqCallCount === 1 ? 10 : 25; + }, + }); + + harness.failSendOnce(new Error('temporary command reply failure')); + const deduper = createChannelBridgeInboundDeduper(); + const attachEvent: ChannelBridgeInboundMessage = { + providerId: 'telegram', + conversationId: '-1001', + threadId: '88', + text: '/attach sess-no-retry', + messageId: 'm-attach-reply-fails-once', + }; + + harness.pushInbound(attachEvent); + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: deduper, + }); + + harness.pushInbound(attachEvent); + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: deduper, + }); + + expect(latestSeqCallCount).toBe(1); + const [binding] = await store.listBindings(); + expect(binding?.lastForwardedSeq).toBe(10); + }); + it('supports /sessions and /detach command flow', async () => { const store = createInMemoryChannelBindingStore(); const harness = createAdapterHarness(); @@ -672,24 +726,37 @@ describe('executeChannelBridgeTick', () => { const harness = createAdapterHarness(); const { deps, warnings, sentToSession } = createDepsHarness(); - harness.pushInbound({ + const failedEvent: ChannelBridgeInboundMessage = { providerId: 'telegram', conversationId: 'bound-room', threadId: null, text: 'hello from channel', messageId: 'non-command-getbinding-fail', + }; + + harness.pushInbound(failedEvent); + const deduper = createChannelBridgeInboundDeduper(); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: deduper, }); + harness.pushInbound(failedEvent); + await executeChannelBridgeTick({ store, adapters: [harness.adapter], deps, - inboundDeduper: createChannelBridgeInboundDeduper(), + inboundDeduper: deduper, }); expect(sentToSession).toHaveLength(0); expect(warnings.some((row) => row.message.includes('Failed to read binding for inbound message forwarding'))).toBe(true); - expect(harness.sent.some((row) => row.text.includes('Failed to read current session binding'))).toBe(true); + expect(warnings.filter((row) => row.message.includes('Failed to read binding for inbound message forwarding'))).toHaveLength(1); + expect(harness.sent.filter((row) => row.text.includes('Failed to read current session binding'))).toHaveLength(1); }); it('indicates when /sessions output is truncated', async () => { @@ -1438,7 +1505,7 @@ describe('executeChannelBridgeTick', () => { expect(sentToSession).toHaveLength(1); }); - it('does not mark inbound messages as seen when processing fails before completion', async () => { + it('marks inbound messages as seen after replying on transient binding-read failure', async () => { const baseStore = createInMemoryChannelBindingStore(); const ref = { providerId: 'telegram', @@ -1502,9 +1569,8 @@ describe('executeChannelBridgeTick', () => { inboundDeduper: deduper, }); - expect(warnings.some((row) => row.message.includes('Failed to read binding for inbound message forwarding'))).toBe(true); - expect(sentToSession).toHaveLength(1); - expect(sentToSession[0]?.text).toBe('retry me'); + expect(warnings.filter((row) => row.message.includes('Failed to read binding for inbound message forwarding'))).toHaveLength(1); + expect(sentToSession).toHaveLength(0); }); it('does not dedupe messages when inbound messageId is empty', async () => { diff --git a/apps/cli/src/channels/core/channelBridgeWorker.ts b/apps/cli/src/channels/core/channelBridgeWorker.ts index c013c1e1d4..3437f917b5 100644 --- a/apps/cli/src/channels/core/channelBridgeWorker.ts +++ b/apps/cli/src/channels/core/channelBridgeWorker.ts @@ -458,6 +458,17 @@ async function handleCommand(params: Readonly<{ threadId: event.threadId, }; + const replyForCommand = async (text: string): Promise => { + try { + await replyToConversation(adapter, ref, text); + } catch (error) { + deps.onWarning?.( + `Failed to send command reply for /${command.name} (provider=${ref.providerId} conversation=${ref.conversationId} thread=${ref.threadId ?? 'null'})`, + error, + ); + } + }; + if (command.name !== 'help' && command.name !== 'start') { const authz = await authorizeCommand({ commandName: command.name, @@ -465,19 +476,13 @@ async function handleCommand(params: Readonly<{ deps, }); if (!authz.allowed) { - await replyToConversation( - adapter, - ref, - authz.message ?? 'You are not authorized to run this command here.', - ); + await replyForCommand(authz.message ?? 'You are not authorized to run this command here.'); return true; } } if (command.name === 'help' || command.name === 'start') { - await replyToConversation( - adapter, - ref, + await replyForCommand( [ 'Happier bridge commands:', '/sessions - list recent sessions', @@ -501,10 +506,10 @@ async function handleCommand(params: Readonly<{ ); } catch (error) { deps.onWarning?.('Failed to list sessions for /sessions command', error); - await replyToConversation(adapter, ref, 'Failed to retrieve sessions. Please try again later.'); + await replyForCommand('Failed to retrieve sessions. Please try again later.'); return true; } - await replyToConversation(adapter, ref, formatSessionsMessage(sessions)); + await replyForCommand(formatSessionsMessage(sessions)); return true; } @@ -518,22 +523,22 @@ async function handleCommand(params: Readonly<{ ); } catch (error) { deps.onWarning?.('Failed to read binding for /session command', error); - await replyToConversation(adapter, ref, 'Failed to read current session binding. Please try again later.'); + await replyForCommand('Failed to read current session binding. Please try again later.'); return true; } if (!existing) { - await replyToConversation(adapter, ref, 'No session is attached here. Use /attach .'); + await replyForCommand('No session is attached here. Use /attach .'); return true; } - await replyToConversation(adapter, ref, `Attached session: ${existing.sessionId}`); + await replyForCommand(`Attached session: ${existing.sessionId}`); return true; } if (command.name === 'attach') { const idOrPrefix = String(command.args[0] ?? '').trim(); if (!idOrPrefix) { - await replyToConversation(adapter, ref, 'Usage: /attach '); + await replyForCommand('Usage: /attach '); return true; } @@ -546,9 +551,7 @@ async function handleCommand(params: Readonly<{ ); } catch (error) { deps.onWarning?.('Failed to resolve session by id/prefix for attach', error); - await replyToConversation( - adapter, - ref, + await replyForCommand( `Failed to attach to session ${idOrPrefix}: unable to resolve session identifier.`, ); return true; @@ -557,23 +560,21 @@ async function handleCommand(params: Readonly<{ if (!resolved.ok) { if (resolved.code === 'session_id_ambiguous') { if (resolved.candidates && resolved.candidates.length > 0) { - await replyToConversation( - adapter, - ref, + await replyForCommand( `Ambiguous session prefix. Candidates:\n${resolved.candidates.map((id) => `• ${id}`).join('\n')}`, ); return true; } - await replyToConversation(adapter, ref, 'Ambiguous session prefix. Use /sessions to list active sessions.'); + await replyForCommand('Ambiguous session prefix. Use /sessions to list active sessions.'); return true; } if (resolved.code === 'unsupported') { - await replyToConversation(adapter, ref, 'Attaching by session ID or prefix is not supported in this environment.'); + await replyForCommand('Attaching by session ID or prefix is not supported in this environment.'); return true; } - await replyToConversation(adapter, ref, 'Session not found. Use /sessions to list recent sessions.'); + await replyForCommand('Session not found. Use /sessions to list recent sessions.'); return true; } @@ -588,9 +589,7 @@ async function handleCommand(params: Readonly<{ deps.onWarning?.( `resolveLatestSessionSeq returned an invalid value for session ${resolved.sessionId}; expected a non-negative integer`, ); - await replyToConversation( - adapter, - ref, + await replyForCommand( `Failed to attach to session ${resolved.sessionId}: unable to resolve latest sequence cursor.`, ); return true; @@ -598,9 +597,7 @@ async function handleCommand(params: Readonly<{ latestSeq = resolvedSeq; } catch (error) { deps.onWarning?.('Failed to resolve latest session sequence for attach', error); - await replyToConversation( - adapter, - ref, + await replyForCommand( `Failed to attach to session ${resolved.sessionId}: unable to resolve latest sequence cursor.`, ); return true; @@ -615,7 +612,7 @@ async function handleCommand(params: Readonly<{ ); } catch (error) { deps.onWarning?.('Failed to read existing binding during /attach', error); - await replyToConversation(adapter, ref, 'Failed to read current binding before attach. Please try again later.'); + await replyForCommand('Failed to read current binding before attach. Please try again later.'); return true; } const previousSessionId = previousBinding?.sessionId ?? null; @@ -634,7 +631,7 @@ async function handleCommand(params: Readonly<{ ); } catch (error) { deps.onWarning?.('Failed to persist binding during /attach', error); - await replyToConversation(adapter, ref, `Failed to attach to session ${resolved.sessionId}: unable to persist binding.`); + await replyForCommand(`Failed to attach to session ${resolved.sessionId}: unable to persist binding.`); return true; } @@ -642,7 +639,7 @@ async function handleCommand(params: Readonly<{ previousSessionId && previousSessionId !== resolved.sessionId ? ` (replaced previous session ${previousSessionId})` : ''; - await replyToConversation(adapter, ref, `Attached this conversation to session ${resolved.sessionId}${switchedFrom}.`); + await replyForCommand(`Attached this conversation to session ${resolved.sessionId}${switchedFrom}.`); return true; } @@ -656,19 +653,19 @@ async function handleCommand(params: Readonly<{ ); } catch (error) { deps.onWarning?.('Failed to remove binding for /detach command', error); - await replyToConversation(adapter, ref, 'Failed to detach current session binding. Please try again later.'); + await replyForCommand('Failed to detach current session binding. Please try again later.'); return true; } if (removed) { - await replyToConversation(adapter, ref, 'Detached this conversation from Happier session.'); + await replyForCommand('Detached this conversation from Happier session.'); } else { - await replyToConversation(adapter, ref, 'No session was attached here.'); + await replyForCommand('No session was attached here.'); } return true; } - await replyToConversation(adapter, ref, `Unknown command: /${command.name}. Use /help for supported commands.`); + await replyForCommand(`Unknown command: /${command.name}. Use /help for supported commands.`); return true; } @@ -797,6 +794,9 @@ export async function executeChannelBridgeTick(params: Readonly<{ ref, 'Failed to read current session binding. Please try again later.', ); + deduper.markSeen(event); + ackableInbound.push(event); + processedSuccessfully = true; continue; } diff --git a/apps/cli/src/channels/startChannelBridgeWorker.test.ts b/apps/cli/src/channels/startChannelBridgeWorker.test.ts index 096f6ea88e..51452b576e 100644 --- a/apps/cli/src/channels/startChannelBridgeWorker.test.ts +++ b/apps/cli/src/channels/startChannelBridgeWorker.test.ts @@ -575,4 +575,82 @@ describe('startChannelBridgeFromEnv', () => { await handle?.stop(); }); + + it('stops webhook relay when store initialization fails before worker startup', async () => { + vi.resetModules(); + + const relayStop = vi.fn(async () => undefined); + const startWorker = vi.fn(); + const kvInitError = new Error('kv init failed'); + + vi.doMock('@/ui/logger', () => ({ + logger: { + warn: vi.fn(), + info: vi.fn(), + debug: vi.fn(), + }, + })); + + vi.doMock('./core/channelBridgeWorker', () => ({ + createInMemoryChannelBindingStore: vi.fn(() => ({ + listBindings: async () => [], + getBinding: async () => null, + upsertBinding: async () => ({ + providerId: 'telegram', + conversationId: 'conv-1', + threadId: null, + sessionId: 'sess-1', + lastForwardedSeq: 0, + createdAtMs: Date.now(), + updatedAtMs: Date.now(), + }), + updateLastForwardedSeq: async () => true, + removeBinding: async () => false, + })), + startChannelBridgeWorker: startWorker, + })); + + vi.doMock('./telegram/telegramAdapter', () => ({ + createTelegramChannelAdapter: vi.fn(() => ({ + providerId: 'telegram', + pullInboundMessages: async () => [], + sendMessage: async () => undefined, + enqueueWebhookUpdate: vi.fn(), + stop: async () => undefined, + })), + })); + + vi.doMock('./telegram/telegramWebhookRelay', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + startTelegramWebhookRelay: vi.fn(async () => ({ + port: 8787, + stop: relayStop, + })), + }; + }); + + vi.doMock('./channelBridgeServerKv', () => ({ + createAxiosChannelBridgeKvClient: vi.fn(() => { + throw kvInitError; + }), + })); + + const { startChannelBridgeFromEnv } = await import('./startChannelBridgeWorker'); + + await expect(startChannelBridgeFromEnv({ + credentials, + serverId: 'server-a', + accountId: 'acct-a', + env: { + HAPPIER_TELEGRAM_BOT_TOKEN: 'bot-token', + HAPPIER_TELEGRAM_WEBHOOK_ENABLED: 'true', + HAPPIER_TELEGRAM_WEBHOOK_SECRET: 'secret-token', + } as NodeJS.ProcessEnv, + })).rejects.toThrow('kv init failed'); + + expect(relayStop).toHaveBeenCalledTimes(1); + expect(startWorker).not.toHaveBeenCalled(); + }); }); diff --git a/apps/cli/src/channels/startChannelBridgeWorker.ts b/apps/cli/src/channels/startChannelBridgeWorker.ts index 70c0037867..b6043b54f1 100644 --- a/apps/cli/src/channels/startChannelBridgeWorker.ts +++ b/apps/cli/src/channels/startChannelBridgeWorker.ts @@ -387,61 +387,58 @@ export async function startChannelBridgeFromEnv(params: Readonly<{ } let defaultDepsHandle: DefaultChannelBridgeDepsHandle | null = null; - const deps = (() => { - if (params.deps) return params.deps; - defaultDepsHandle = createDefaultChannelBridgeDeps(params.credentials); - return defaultDepsHandle.deps; - })(); - let store = params.store ?? null; - if (!store) { - const serverId = typeof params.serverId === 'string' ? params.serverId.trim() : ''; - const accountId = typeof params.accountId === 'string' ? params.accountId.trim() : ''; - if (serverId && accountId) { - const kv = createAxiosChannelBridgeKvClient({ token: params.credentials.token }); - store = createServerBackedChannelBindingStore({ - kv, - serverId, - accountId, - }); + try { + const deps = (() => { + if (params.deps) return params.deps; + defaultDepsHandle = createDefaultChannelBridgeDeps(params.credentials); + return defaultDepsHandle.deps; + })(); + + let store = params.store ?? null; + if (!store) { + const serverId = typeof params.serverId === 'string' ? params.serverId.trim() : ''; + const accountId = typeof params.accountId === 'string' ? params.accountId.trim() : ''; + if (serverId && accountId) { + const kv = createAxiosChannelBridgeKvClient({ token: params.credentials.token }); + store = createServerBackedChannelBindingStore({ + kv, + serverId, + accountId, + }); + } + } + if (!store) { + store = createInMemoryChannelBindingStore(); } - } - if (!store) { - store = createInMemoryChannelBindingStore(); - } - const tickMs = runtimeConfig.tickMs; - let worker: ChannelBridgeWorkerHandle; - try { - worker = startChannelBridgeWorker({ + const worker = startChannelBridgeWorker({ store, adapters, deps, - tickMs, + tickMs: runtimeConfig.tickMs, }); - } catch (error) { - if (relayHandle) { - await relayHandle.stop().catch(() => undefined); - } - defaultDepsHandle?.dispose(); - throw error; - } - return { - trigger: worker.trigger, - stop: async () => { - try { - await worker.stop(); - } finally { + return { + trigger: worker.trigger, + stop: async () => { try { - if (relayHandle) { - await relayHandle.stop().catch((error: unknown) => { - logger.warn('[channelBridge] Error stopping webhook relay during shutdown', error); - }); - } + await worker.stop(); } finally { - defaultDepsHandle?.dispose(); + try { + if (relayHandle) { + await relayHandle.stop().catch((error: unknown) => { + logger.warn('[channelBridge] Error stopping webhook relay during shutdown', error); + }); + } + } finally { + defaultDepsHandle?.dispose(); + } } - } - }, - }; + }, + }; + } catch (error) { + await relayHandle?.stop().catch(() => undefined); + defaultDepsHandle?.dispose(); + throw error; + } } From d047b7750777934e273c528690bd193e5ab69892 Mon Sep 17 00:00:00 2001 From: Chris Denneen Date: Fri, 6 Mar 2026 21:28:47 -0500 Subject: [PATCH 23/81] fix(cli): harden inbound ack paths and stable local ids for channel bridge --- .../channels/core/channelBridgeWorker.test.ts | 75 +++++++++++ .../src/channels/core/channelBridgeWorker.ts | 34 +++-- .../channels/startChannelBridgeWorker.test.ts | 121 ++++++++++++++++++ .../src/channels/startChannelBridgeWorker.ts | 32 ++++- 4 files changed, 251 insertions(+), 11 deletions(-) diff --git a/apps/cli/src/channels/core/channelBridgeWorker.test.ts b/apps/cli/src/channels/core/channelBridgeWorker.test.ts index 28e692de34..43729c4709 100644 --- a/apps/cli/src/channels/core/channelBridgeWorker.test.ts +++ b/apps/cli/src/channels/core/channelBridgeWorker.test.ts @@ -25,6 +25,7 @@ interface SentSessionMessage { providerId: string; conversationId: string; threadId: string | null; + messageId?: string; } interface WarningRecord { @@ -310,6 +311,7 @@ describe('executeChannelBridgeTick', () => { providerId: 'telegram', conversationId: '-1001', threadId: '88', + messageId: 'm2', }, ]); }); @@ -659,6 +661,42 @@ describe('executeChannelBridgeTick', () => { expect(sentToSession).toHaveLength(0); }); + it('acks unknown-command events even when reply delivery fails', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + const { deps, warnings, sentToSession } = createDepsHarness(); + const deduper = createChannelBridgeInboundDeduper(); + + const event: ChannelBridgeInboundMessage = { + providerId: 'telegram', + conversationId: '-1001', + threadId: '99', + text: '/@', + messageId: 'm-unknown-reply-failure', + }; + + harness.failSendOnce(new Error('reply failed')); + harness.pushInbound(event); + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: deduper, + }); + + harness.pushInbound(event); + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: deduper, + }); + + expect(warnings.filter((row) => row.message.includes('Failed to send unknown-command reply'))).toHaveLength(1); + expect(harness.sent).toHaveLength(0); + expect(sentToSession).toHaveLength(0); + }); + it('replies for malformed slash command tokens instead of forwarding them', async () => { const store = createInMemoryChannelBindingStore(); const harness = createAdapterHarness(); @@ -715,6 +753,42 @@ describe('executeChannelBridgeTick', () => { expect(sentToSession).toHaveLength(0); }); + it('acks non-command unbound events even when no-binding reply delivery fails', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + const { deps, warnings, sentToSession } = createDepsHarness(); + const deduper = createChannelBridgeInboundDeduper(); + + const event: ChannelBridgeInboundMessage = { + providerId: 'telegram', + conversationId: 'unbound-room-fail-reply', + threadId: null, + text: 'hello from unbound thread', + messageId: 'm-unbound-reply-failure', + }; + + harness.failSendOnce(new Error('no-binding reply failed')); + harness.pushInbound(event); + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: deduper, + }); + + harness.pushInbound(event); + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: deduper, + }); + + expect(warnings.filter((row) => row.message.includes('Failed to send no-binding reply'))).toHaveLength(1); + expect(harness.sent).toHaveLength(0); + expect(sentToSession).toHaveLength(0); + }); + it('warns and replies when non-command forwarding cannot read binding from store', async () => { const baseStore = createInMemoryChannelBindingStore(); const store: ChannelBindingStore = { @@ -1307,6 +1381,7 @@ describe('executeChannelBridgeTick', () => { providerId: 'discord', conversationId: 'discord-room', threadId: null, + messageId: 'discord-message', }, ]); expect(warnings.some((row) => row.message.includes('Failed to pull inbound messages for adapter telegram'))).toBe(true); diff --git a/apps/cli/src/channels/core/channelBridgeWorker.ts b/apps/cli/src/channels/core/channelBridgeWorker.ts index 3437f917b5..d8fdea3454 100644 --- a/apps/cli/src/channels/core/channelBridgeWorker.ts +++ b/apps/cli/src/channels/core/channelBridgeWorker.ts @@ -115,6 +115,7 @@ export type ChannelBridgeDeps = Readonly<{ providerId: string; conversationId: string; threadId: string | null; + messageId?: string; }>) => Promise; resolveLatestSessionSeq: (sessionId: string) => Promise; fetchAgentMessagesAfterSeq: (params: Readonly<{ sessionId: string; afterSeq: number }>) => Promise< @@ -762,10 +763,17 @@ export async function executeChannelBridgeTick(params: Readonly<{ } if (event.text.trim().startsWith('/')) { - await replyToConversation(adapter, { - conversationId: event.conversationId, - threadId: event.threadId, - }, 'Unknown command. Use /help for supported commands.'); + try { + await replyToConversation(adapter, { + conversationId: event.conversationId, + threadId: event.threadId, + }, 'Unknown command. Use /help for supported commands.'); + } catch (replyError) { + params.deps.onWarning?.( + `Failed to send unknown-command reply for provider=${adapter.providerId} conversation=${event.conversationId} thread=${event.threadId ?? 'null'}`, + replyError, + ); + } deduper.markSeen(event); ackableInbound.push(event); processedSuccessfully = true; @@ -801,11 +809,18 @@ export async function executeChannelBridgeTick(params: Readonly<{ } if (!binding) { - await replyToConversation( - adapter, - ref, - 'No session is attached here. Use /attach first.', - ); + try { + await replyToConversation( + adapter, + ref, + 'No session is attached here. Use /attach first.', + ); + } catch (replyError) { + params.deps.onWarning?.( + `Failed to send no-binding reply for provider=${adapter.providerId} conversation=${event.conversationId} thread=${event.threadId ?? 'null'}`, + replyError, + ); + } deduper.markSeen(event); ackableInbound.push(event); processedSuccessfully = true; @@ -821,6 +836,7 @@ export async function executeChannelBridgeTick(params: Readonly<{ providerId: adapter.providerId, conversationId: event.conversationId, threadId: event.threadId, + messageId: event.messageId, }), EXTERNAL_IO_TIMEOUT_MS, `sendUserMessageToSession(${binding.sessionId})`, diff --git a/apps/cli/src/channels/startChannelBridgeWorker.test.ts b/apps/cli/src/channels/startChannelBridgeWorker.test.ts index 51452b576e..65e137fa44 100644 --- a/apps/cli/src/channels/startChannelBridgeWorker.test.ts +++ b/apps/cli/src/channels/startChannelBridgeWorker.test.ts @@ -388,6 +388,127 @@ describe('startChannelBridgeFromEnv', () => { await handle?.stop(); }); + it('uses deterministic localId for retried channel messages with stable message ids', async () => { + vi.resetModules(); + + const fetchSessionById = vi.fn(async () => ({ + id: 'sess-local-id-1', + encryptionMode: 'plain', + metadata: null, + dataEncryptionKey: null, + })); + const sendCommitted = vi.fn(async () => undefined); + + let capturedDeps: { + sendUserMessageToSession: (params: { + sessionId: string; + text: string; + sentFrom: string; + providerId: string; + conversationId: string; + threadId: string | null; + messageId?: string; + }) => Promise; + } | null = null; + + vi.doMock('@/sessionControl/sessionsHttp', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchSessionById, + }; + }); + + vi.doMock('@/sessionControl/sessionSocketSendMessage', () => ({ + sendSessionMessageViaSocketCommitted: sendCommitted, + })); + + vi.doMock('./core/channelBridgeWorker', () => ({ + createInMemoryChannelBindingStore: vi.fn(() => ({ + listBindings: async () => [], + getBinding: async () => null, + upsertBinding: async () => ({ + providerId: 'telegram', + conversationId: 'conv-1', + threadId: null, + sessionId: 'sess-1', + lastForwardedSeq: 0, + createdAtMs: Date.now(), + updatedAtMs: Date.now(), + }), + updateLastForwardedSeq: async () => true, + removeBinding: async () => false, + })), + startChannelBridgeWorker: vi.fn((params: { deps: typeof capturedDeps }) => { + capturedDeps = params.deps as typeof capturedDeps; + return { + trigger: vi.fn(), + stop: vi.fn(async () => undefined), + }; + }), + })); + + vi.doMock('./telegram/telegramAdapter', () => ({ + createTelegramChannelAdapter: vi.fn(() => ({ + providerId: 'telegram', + pullInboundMessages: async () => [], + sendMessage: async () => undefined, + enqueueWebhookUpdate: vi.fn(), + stop: async () => undefined, + })), + })); + + const { startChannelBridgeFromEnv } = await import('./startChannelBridgeWorker'); + + const handle = await startChannelBridgeFromEnv({ + credentials, + env: { + HAPPIER_TELEGRAM_BOT_TOKEN: 'bot-token', + } as NodeJS.ProcessEnv, + }); + + expect(handle).not.toBeNull(); + expect(capturedDeps).not.toBeNull(); + + await capturedDeps!.sendUserMessageToSession({ + sessionId: 'sess-local-id-1', + text: 'first attempt', + sentFrom: 'telegram', + providerId: 'telegram', + conversationId: '-100local', + threadId: '42', + messageId: 'msg-1', + }); + await capturedDeps!.sendUserMessageToSession({ + sessionId: 'sess-local-id-1', + text: 'retry same message', + sentFrom: 'telegram', + providerId: 'telegram', + conversationId: '-100local', + threadId: '42', + messageId: 'msg-1', + }); + await capturedDeps!.sendUserMessageToSession({ + sessionId: 'sess-local-id-1', + text: 'different message', + sentFrom: 'telegram', + providerId: 'telegram', + conversationId: '-100local', + threadId: '42', + messageId: 'msg-2', + }); + + expect(sendCommitted).toHaveBeenCalledTimes(3); + const firstLocalId = sendCommitted.mock.calls[0]?.[0]?.localId; + const secondLocalId = sendCommitted.mock.calls[1]?.[0]?.localId; + const thirdLocalId = sendCommitted.mock.calls[2]?.[0]?.localId; + + expect(firstLocalId).toBe(secondLocalId); + expect(thirdLocalId).not.toBe(firstLocalId); + + await handle?.stop(); + }); + it('refreshes session runtime LRU order on cache hits', async () => { vi.resetModules(); diff --git a/apps/cli/src/channels/startChannelBridgeWorker.ts b/apps/cli/src/channels/startChannelBridgeWorker.ts index b6043b54f1..47ed49c7c6 100644 --- a/apps/cli/src/channels/startChannelBridgeWorker.ts +++ b/apps/cli/src/channels/startChannelBridgeWorker.ts @@ -1,4 +1,4 @@ -import { randomUUID } from 'node:crypto'; +import { createHash, randomUUID } from 'node:crypto'; import { serializeAxiosErrorForLog } from '@/api/client/serializeAxiosErrorForLog'; import { fetchEncryptedTranscriptPageAfterSeq, fetchEncryptedTranscriptPageLatest } from '@/api/session/fetchEncryptedTranscriptWindow'; @@ -49,6 +49,29 @@ function toNonNegativeInt(value: unknown): number | null { return truncated >= 0 ? truncated : null; } +function createStableBridgeLocalId(params: Readonly<{ + providerId: string; + conversationId: string; + threadId: string | null; + messageId?: string; +}>): string { + const normalizedMessageId = typeof params.messageId === 'string' ? params.messageId.trim() : ''; + if (normalizedMessageId.length === 0) { + return randomUUID(); + } + + const digest = createHash('sha256') + .update(params.providerId) + .update('\u0000') + .update(params.conversationId) + .update('\u0000') + .update(params.threadId ?? '') + .update('\u0000') + .update(normalizedMessageId) + .digest('hex'); + return `bridge-${digest}`; +} + function extractAssistantText(content: unknown): string | null { const contentRecord = asRecord(content); if (!contentRecord) return null; @@ -234,7 +257,12 @@ function createDefaultChannelBridgeDeps(credentials: Credentials): DefaultChanne token: credentials.token, sessionId: params.sessionId, content, - localId: randomUUID(), + localId: createStableBridgeLocalId({ + providerId: params.providerId, + conversationId: params.conversationId, + threadId: params.threadId, + messageId: params.messageId, + }), sentFrom: params.sentFrom, }); }, From b5c7431dbe59959db1ededc7d999877cc8fa266a Mon Sep 17 00:00:00 2001 From: Chris Denneen Date: Fri, 6 Mar 2026 21:51:07 -0500 Subject: [PATCH 24/81] fix(cli): guard binding-failure reply path and preserve ack progression --- .../channels/core/channelBridgeWorker.test.ts | 43 +++++++++++++++++++ .../src/channels/core/channelBridgeWorker.ts | 17 +++++--- 2 files changed, 55 insertions(+), 5 deletions(-) diff --git a/apps/cli/src/channels/core/channelBridgeWorker.test.ts b/apps/cli/src/channels/core/channelBridgeWorker.test.ts index 43729c4709..7639544ee8 100644 --- a/apps/cli/src/channels/core/channelBridgeWorker.test.ts +++ b/apps/cli/src/channels/core/channelBridgeWorker.test.ts @@ -833,6 +833,49 @@ describe('executeChannelBridgeTick', () => { expect(harness.sent.filter((row) => row.text.includes('Failed to read current session binding'))).toHaveLength(1); }); + it('acks binding-read failures even when failure reply delivery throws', async () => { + const baseStore = createInMemoryChannelBindingStore(); + const store: ChannelBindingStore = { + ...baseStore, + getBinding: async () => { + throw new Error('binding read failed'); + }, + }; + const harness = createAdapterHarness(); + const { deps, warnings, sentToSession } = createDepsHarness(); + + const deduper = createChannelBridgeInboundDeduper(); + const failedEvent: ChannelBridgeInboundMessage = { + providerId: 'telegram', + conversationId: 'bound-room-reply-fail', + threadId: null, + text: 'hello from channel', + messageId: 'non-command-getbinding-fail-reply-send', + }; + + harness.failSendOnce(new Error('temporary reply transport failure')); + harness.pushInbound(failedEvent); + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: deduper, + }); + + harness.pushInbound(failedEvent); + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: deduper, + }); + + expect(sentToSession).toHaveLength(0); + expect(warnings.filter((row) => row.message.includes('Failed to read binding for inbound message forwarding'))).toHaveLength(1); + expect(warnings.filter((row) => row.message.includes('Failed to send binding-read-failure reply'))).toHaveLength(1); + expect(harness.sent).toHaveLength(0); + }); + it('indicates when /sessions output is truncated', async () => { const store = createInMemoryChannelBindingStore(); const harness = createAdapterHarness(); diff --git a/apps/cli/src/channels/core/channelBridgeWorker.ts b/apps/cli/src/channels/core/channelBridgeWorker.ts index d8fdea3454..cbc26a0fc3 100644 --- a/apps/cli/src/channels/core/channelBridgeWorker.ts +++ b/apps/cli/src/channels/core/channelBridgeWorker.ts @@ -797,11 +797,18 @@ export async function executeChannelBridgeTick(params: Readonly<{ `Failed to read binding for inbound message forwarding (provider=${adapter.providerId} conversation=${event.conversationId} thread=${event.threadId ?? 'null'})`, error, ); - await replyToConversation( - adapter, - ref, - 'Failed to read current session binding. Please try again later.', - ); + try { + await replyToConversation( + adapter, + ref, + 'Failed to read current session binding. Please try again later.', + ); + } catch (replyError) { + params.deps.onWarning?.( + `Failed to send binding-read-failure reply for provider=${adapter.providerId} conversation=${event.conversationId} thread=${event.threadId ?? 'null'}`, + replyError, + ); + } deduper.markSeen(event); ackableInbound.push(event); processedSuccessfully = true; From 9b48d5db009d0ceaeb68b2f35215d5157cc4af2f Mon Sep 17 00:00:00 2001 From: Chris Denneen Date: Fri, 6 Mar 2026 22:13:35 -0500 Subject: [PATCH 25/81] fix(cli): guard session-forward failure replies to preserve ack semantics --- .../channels/core/channelBridgeWorker.test.ts | 49 +++++++++++++++++++ .../src/channels/core/channelBridgeWorker.ts | 17 +++++-- 2 files changed, 61 insertions(+), 5 deletions(-) diff --git a/apps/cli/src/channels/core/channelBridgeWorker.test.ts b/apps/cli/src/channels/core/channelBridgeWorker.test.ts index 7639544ee8..45bc78d1bf 100644 --- a/apps/cli/src/channels/core/channelBridgeWorker.test.ts +++ b/apps/cli/src/channels/core/channelBridgeWorker.test.ts @@ -1509,6 +1509,55 @@ describe('executeChannelBridgeTick', () => { expect(harness.sent.some((row) => row.text.includes('Failed to send message to session sess-fail.'))).toBe(true); }); + it('acks session-forward failures even when failure reply delivery throws', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + + await store.upsertBinding({ + providerId: 'telegram', + conversationId: 'failing-room-reply-fail', + threadId: null, + sessionId: 'sess-fail-reply', + lastForwardedSeq: 0, + }); + + const { deps, warnings } = createDepsHarness({ + sendUserMessageToSession: async () => { + throw new Error('session unavailable'); + }, + }); + + const deduper = createChannelBridgeInboundDeduper(); + const failedEvent: ChannelBridgeInboundMessage = { + providerId: 'telegram', + conversationId: 'failing-room-reply-fail', + threadId: null, + text: 'hello', + messageId: 'send-fail-reply-ack', + }; + + harness.failSendOnce(new Error('temporary reply transport failure')); + harness.pushInbound(failedEvent); + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: deduper, + }); + + harness.pushInbound(failedEvent); + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: deduper, + }); + + expect(warnings.filter((row) => row.message.includes('Failed to forward channel message into session'))).toHaveLength(1); + expect(warnings.filter((row) => row.message.includes('Failed to send session-forward-failure reply'))).toHaveLength(1); + expect(harness.sent).toHaveLength(0); + }); + it('persists cursor after successful sends when a later outbound row fails', async () => { const store = createInMemoryChannelBindingStore(); diff --git a/apps/cli/src/channels/core/channelBridgeWorker.ts b/apps/cli/src/channels/core/channelBridgeWorker.ts index cbc26a0fc3..78020841aa 100644 --- a/apps/cli/src/channels/core/channelBridgeWorker.ts +++ b/apps/cli/src/channels/core/channelBridgeWorker.ts @@ -853,11 +853,18 @@ export async function executeChannelBridgeTick(params: Readonly<{ `Failed to forward channel message into session ${binding.sessionId} (provider=${adapter.providerId} conversation=${event.conversationId} thread=${event.threadId ?? 'null'} messageId=${event.messageId}); message will not be retried because the user is notified in-channel`, error, ); - await replyToConversation( - adapter, - ref, - `Failed to send message to session ${binding.sessionId}.`, - ); + try { + await replyToConversation( + adapter, + ref, + `Failed to send message to session ${binding.sessionId}.`, + ); + } catch (replyError) { + params.deps.onWarning?.( + `Failed to send session-forward-failure reply for provider=${adapter.providerId} conversation=${event.conversationId} thread=${event.threadId ?? 'null'}`, + replyError, + ); + } } processedSuccessfully = true; } catch (error) { From fc7fc16906609e7e1aef786394ee4925cd80da30 Mon Sep 17 00:00:00 2001 From: Chris Denneen Date: Fri, 6 Mar 2026 22:36:55 -0500 Subject: [PATCH 26/81] fix(cli): validate tickMs writes and warn on unextractable agent rows --- .../channels/channelBridgeServerKv.test.ts | 22 ++++ .../cli/src/channels/channelBridgeServerKv.ts | 6 +- .../channels/startChannelBridgeWorker.test.ts | 120 ++++++++++++++++++ .../src/channels/startChannelBridgeWorker.ts | 40 ++++-- 4 files changed, 175 insertions(+), 13 deletions(-) diff --git a/apps/cli/src/channels/channelBridgeServerKv.test.ts b/apps/cli/src/channels/channelBridgeServerKv.test.ts index 1426b39053..25ad63252f 100644 --- a/apps/cli/src/channels/channelBridgeServerKv.test.ts +++ b/apps/cli/src/channels/channelBridgeServerKv.test.ts @@ -267,6 +267,28 @@ describe('channelBridgeServerKv', () => { })).rejects.toThrow('Invalid telegram.webhook.port update payload'); }); + it('rejects out-of-range tickMs updates', async () => { + const kv = createInMemoryKvClient(); + + await expect(upsertChannelBridgeTelegramConfigInKv({ + kv, + serverId: 'local-3005', + accountId: 'acct-1', + update: { + tickMs: 100, + }, + })).rejects.toThrow('Invalid tickMs update payload'); + + await expect(upsertChannelBridgeTelegramConfigInKv({ + kv, + serverId: 'local-3005', + accountId: 'acct-1', + update: { + tickMs: 120_000, + }, + })).rejects.toThrow('Invalid tickMs update payload'); + }); + it('rejects non-loopback webhook host updates', async () => { const kv = createInMemoryKvClient(); diff --git a/apps/cli/src/channels/channelBridgeServerKv.ts b/apps/cli/src/channels/channelBridgeServerKv.ts index 27cdfa4a0e..c163fed80b 100644 --- a/apps/cli/src/channels/channelBridgeServerKv.ts +++ b/apps/cli/src/channels/channelBridgeServerKv.ts @@ -549,7 +549,11 @@ function applyTelegramConfigUpdate(current: ChannelBridgeServerTelegramConfigRec // intentionally rewrites canonical schema. In that case, unspecified tickMs is not carried // forward and runtime defaults apply unless update.tickMs is explicitly provided. if (typeof update.tickMs === 'number' && Number.isFinite(update.tickMs)) { - next.tickMs = Math.trunc(update.tickMs); + const normalizedTickMs = Math.trunc(update.tickMs); + if (normalizedTickMs < 250 || normalizedTickMs > 60_000) { + throw new ChannelBridgeBadPayloadError('Invalid tickMs update payload: must be in [250, 60000]'); + } + next.tickMs = normalizedTickMs; } if (Array.isArray(update.allowedChatIds)) { diff --git a/apps/cli/src/channels/startChannelBridgeWorker.test.ts b/apps/cli/src/channels/startChannelBridgeWorker.test.ts index 65e137fa44..4321e5b81b 100644 --- a/apps/cli/src/channels/startChannelBridgeWorker.test.ts +++ b/apps/cli/src/channels/startChannelBridgeWorker.test.ts @@ -509,6 +509,126 @@ describe('startChannelBridgeFromEnv', () => { await handle?.stop(); }); + it('warns when agent transcript rows cannot be mapped to bridge text output', async () => { + vi.resetModules(); + + const warnSpy = vi.fn(); + const fetchSessionById = vi.fn(async () => ({ + id: 'sess-plain-unextractable', + encryptionMode: 'plain', + metadata: null, + dataEncryptionKey: null, + })); + const fetchAfterSeq = vi.fn(async () => ([ + { + seq: 11, + createdAt: 1, + content: { + t: 'plain', + v: { + role: 'agent', + content: { + type: 'output', + data: { + message: 123, + }, + }, + }, + }, + }, + ])); + + let capturedDeps: { + fetchAgentMessagesAfterSeq: (params: { sessionId: string; afterSeq: number }) => Promise; + } | null = null; + + vi.doMock('@/ui/logger', () => ({ + logger: { + warn: warnSpy, + info: vi.fn(), + debug: vi.fn(), + }, + })); + + vi.doMock('@/sessionControl/sessionsHttp', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchSessionById, + }; + }); + + vi.doMock('@/api/session/fetchEncryptedTranscriptWindow', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchEncryptedTranscriptPageAfterSeq: fetchAfterSeq, + }; + }); + + vi.doMock('./core/channelBridgeWorker', () => ({ + createInMemoryChannelBindingStore: vi.fn(() => ({ + listBindings: async () => [], + getBinding: async () => null, + upsertBinding: async () => ({ + providerId: 'telegram', + conversationId: 'conv-1', + threadId: null, + sessionId: 'sess-1', + lastForwardedSeq: 0, + createdAtMs: Date.now(), + updatedAtMs: Date.now(), + }), + updateLastForwardedSeq: async () => true, + removeBinding: async () => false, + })), + startChannelBridgeWorker: vi.fn((params: { deps: typeof capturedDeps }) => { + capturedDeps = params.deps as typeof capturedDeps; + return { + trigger: vi.fn(), + stop: vi.fn(async () => undefined), + }; + }), + })); + + vi.doMock('./telegram/telegramAdapter', () => ({ + createTelegramChannelAdapter: vi.fn(() => ({ + providerId: 'telegram', + pullInboundMessages: async () => [], + sendMessage: async () => undefined, + enqueueWebhookUpdate: vi.fn(), + stop: async () => undefined, + })), + })); + + const { startChannelBridgeFromEnv } = await import('./startChannelBridgeWorker'); + + const handle = await startChannelBridgeFromEnv({ + credentials, + env: { + HAPPIER_TELEGRAM_BOT_TOKEN: 'bot-token', + } as NodeJS.ProcessEnv, + }); + + expect(handle).not.toBeNull(); + expect(capturedDeps).not.toBeNull(); + + const rows = await capturedDeps!.fetchAgentMessagesAfterSeq({ + sessionId: 'sess-plain-unextractable', + afterSeq: 0, + }); + + expect(rows).toEqual({ + messages: [], + highestSeenSeq: 11, + }); + expect(warnSpy).toHaveBeenCalledWith( + expect.stringContaining('[channelBridge] Skipped agent transcript row with unsupported content'), + ); + + await handle?.stop(); + }); + it('refreshes session runtime LRU order on cache hits', async () => { vi.resetModules(); diff --git a/apps/cli/src/channels/startChannelBridgeWorker.ts b/apps/cli/src/channels/startChannelBridgeWorker.ts index 47ed49c7c6..99c2151867 100644 --- a/apps/cli/src/channels/startChannelBridgeWorker.ts +++ b/apps/cli/src/channels/startChannelBridgeWorker.ts @@ -182,6 +182,14 @@ function createDefaultChannelBridgeDeps(credentials: Credentials): DefaultChanne return rememberSessionRuntime(sessionId, runtime); } + const onWarning: NonNullable = (message, error) => { + if (typeof error === 'undefined') { + logger.warn(`[channelBridge] ${message}`); + } else { + logger.warn(`[channelBridge] ${message}`, serializeAxiosErrorForLog(error)); + } + }; + return { deps: { listSessions: async () => { @@ -305,24 +313,32 @@ function createDefaultChannelBridgeDeps(credentials: Credentials): DefaultChanne rows: encrypted, }); - const messages: ChannelBridgeAgentMessageRow[] = transcriptRows - .filter((row) => row.role === 'agent') - .map((row) => ({ seq: row.seq, text: extractAssistantText(row.content) })) - .filter((row) => typeof row.text === 'string' && row.text.trim().length > 0) - .map((row) => ({ seq: row.seq, text: row.text! })); + const messages: ChannelBridgeAgentMessageRow[] = []; + for (const row of transcriptRows) { + if (row.role !== 'agent') { + continue; + } + + const text = extractAssistantText(row.content); + if (!text) { + onWarning( + `Skipped agent transcript row with unsupported content for session=${sessionId} seq=${row.seq}`, + ); + continue; + } + + messages.push({ + seq: row.seq, + text, + }); + } return { messages, highestSeenSeq, }; }, - onWarning: (message, error) => { - if (typeof error === 'undefined') { - logger.warn(`[channelBridge] ${message}`); - } else { - logger.warn(`[channelBridge] ${message}`, serializeAxiosErrorForLog(error)); - } - }, + onWarning, }, dispose: () => { sessionRuntimeCache.clear(); From 06ebe6dde4f82a46a73464717108ac489759d39d Mon Sep 17 00:00:00 2001 From: Chris Denneen Date: Fri, 6 Mar 2026 22:59:09 -0500 Subject: [PATCH 27/81] fix(cli): match telegram permanent-failure detection to typed description --- .../channels/core/channelBridgeWorker.test.ts | 40 +++++++++++++++++++ .../src/channels/core/channelBridgeWorker.ts | 2 +- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/apps/cli/src/channels/core/channelBridgeWorker.test.ts b/apps/cli/src/channels/core/channelBridgeWorker.test.ts index 45bc78d1bf..ef39332d95 100644 --- a/apps/cli/src/channels/core/channelBridgeWorker.test.ts +++ b/apps/cli/src/channels/core/channelBridgeWorker.test.ts @@ -2008,6 +2008,46 @@ describe('executeChannelBridgeTick', () => { ).toBe(true); }); + it('treats typed Telegram 400 chat-not-found failures as non-retryable', async () => { + const store = createInMemoryChannelBindingStore(); + const { deps, warnings } = createDepsHarness({ + fetchAgentMessagesAfterSeq: async () => [{ seq: 1, text: 'delivery fails permanently' }], + }); + + await store.upsertBinding({ + providerId: 'telegram', + conversationId: '-3004c', + threadId: null, + sessionId: 'sess-typed-permanent-chat-not-found', + lastForwardedSeq: 0, + }); + + const adapter: ChannelBridgeAdapter = { + providerId: 'telegram', + pullInboundMessages: async () => [], + sendMessage: async () => { + throw new TelegramApiError({ + method: 'sendMessage', + statusCode: 400, + data: { description: 'Bad Request: chat not found' }, + }); + }, + }; + + await executeChannelBridgeTick({ + store, + adapters: [adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + const [binding] = await store.listBindings(); + expect(binding?.lastForwardedSeq).toBe(1); + expect( + warnings.some((row) => row.message.includes('Detected permanent Telegram delivery failure')), + ).toBe(true); + }); + it('does not treat untyped telegram-like send errors as permanent failures', async () => { const store = createInMemoryChannelBindingStore(); const { deps, warnings } = createDepsHarness({ diff --git a/apps/cli/src/channels/core/channelBridgeWorker.ts b/apps/cli/src/channels/core/channelBridgeWorker.ts index 78020841aa..846730b6ef 100644 --- a/apps/cli/src/channels/core/channelBridgeWorker.ts +++ b/apps/cli/src/channels/core/channelBridgeWorker.ts @@ -218,7 +218,7 @@ function isTelegramPermanentDeliveryFailure(error: unknown): boolean { if (!(error instanceof TelegramApiError)) return false; if (error.method !== 'sendMessage') return false; if (error.statusCode === 403) return true; - if (error.statusCode === 400 && /chat not found/i.test(error.message)) return true; + if (error.statusCode === 400 && error.description !== null && /chat not found/i.test(error.description)) return true; return false; } From 20684f5c4bdfafe1a549a2b05dca38fb029dcd31 Mon Sep 17 00:00:00 2001 From: Chris Denneen Date: Fri, 6 Mar 2026 23:25:55 -0500 Subject: [PATCH 28/81] fix(cli): align bridge config parsing, idempotency, and key normalization --- .../channels/channelBridgeServerKv.test.ts | 48 ++++++++++++ .../cli/src/channels/channelBridgeServerKv.ts | 28 +++++-- .../channels/core/channelBridgeWorker.test.ts | 41 ++++++++++ .../src/channels/core/channelBridgeWorker.ts | 75 +++++++++++-------- .../channels/startChannelBridgeWorker.test.ts | 68 ++++++++++++++++- .../src/channels/startChannelBridgeWorker.ts | 14 ++-- apps/cli/src/cli/commands/bridge.ts | 2 +- 7 files changed, 230 insertions(+), 46 deletions(-) diff --git a/apps/cli/src/channels/channelBridgeServerKv.test.ts b/apps/cli/src/channels/channelBridgeServerKv.test.ts index 25ad63252f..4535e5586b 100644 --- a/apps/cli/src/channels/channelBridgeServerKv.test.ts +++ b/apps/cli/src/channels/channelBridgeServerKv.test.ts @@ -302,6 +302,54 @@ describe('channelBridgeServerKv', () => { })).rejects.toThrow('Invalid telegram.webhook.host update payload'); }); + it('rejects malformed persisted webhook payloads during read', async () => { + const kv = createInMemoryKvClient(); + + const malformedWebhookPayload = Buffer.from(JSON.stringify({ + schemaVersion: 1, + telegram: { + webhook: 'not-an-object', + }, + updatedAtMs: 1, + }), 'utf8').toString('base64'); + + await replaceChannelBridgeTelegramConfigRawInKv({ + kv, + serverId: 'local-3005', + accountId: 'acct-1', + valueBase64: malformedWebhookPayload, + }); + + await expect(readChannelBridgeTelegramConfigFromKv({ + kv, + serverId: 'local-3005', + accountId: 'acct-1', + })).rejects.toThrow('Invalid telegram.webhook payload'); + + const emptyWebhookHostPayload = Buffer.from(JSON.stringify({ + schemaVersion: 1, + telegram: { + webhook: { + host: ' ', + }, + }, + updatedAtMs: 2, + }), 'utf8').toString('base64'); + + await replaceChannelBridgeTelegramConfigRawInKv({ + kv, + serverId: 'local-3005', + accountId: 'acct-1', + valueBase64: emptyWebhookHostPayload, + }); + + await expect(readChannelBridgeTelegramConfigFromKv({ + kv, + serverId: 'local-3005', + accountId: 'acct-1', + })).rejects.toThrow('Invalid telegram.webhook.host payload'); + }); + it('replaces raw telegram config bytes in KV without schema parsing', async () => { const kv = createInMemoryKvClient(); diff --git a/apps/cli/src/channels/channelBridgeServerKv.ts b/apps/cli/src/channels/channelBridgeServerKv.ts index c163fed80b..cca16cbd79 100644 --- a/apps/cli/src/channels/channelBridgeServerKv.ts +++ b/apps/cli/src/channels/channelBridgeServerKv.ts @@ -205,19 +205,26 @@ function parseTelegramConfigRecord(value: unknown): ChannelBridgeServerTelegramC out.telegram.requireTopics = telegram.requireTopics; } - const webhook = asRecord(telegram.webhook); - if (webhook) { + if (telegram.webhook !== undefined) { + const webhook = asRecord(telegram.webhook); + if (!webhook) { + throw new ChannelBridgeBadPayloadError('Invalid telegram.webhook payload'); + } + const outWebhook: { enabled?: boolean; host?: string; port?: number } = {}; if (typeof webhook.enabled === 'boolean') outWebhook.enabled = webhook.enabled; - if (typeof webhook.host === 'string') { + + if (webhook.host !== undefined) { + if (typeof webhook.host !== 'string') { + throw new ChannelBridgeBadPayloadError('Invalid telegram.webhook.host payload'); + } const trimmedHost = webhook.host.trim(); - if (trimmedHost.length > 0) { - if (!isLoopbackHost(trimmedHost)) { - throw new ChannelBridgeBadPayloadError('Invalid telegram.webhook.host payload'); - } - outWebhook.host = trimmedHost; + if (trimmedHost.length === 0 || !isLoopbackHost(trimmedHost)) { + throw new ChannelBridgeBadPayloadError('Invalid telegram.webhook.host payload'); } + outWebhook.host = trimmedHost; } + if (webhook.port !== undefined) { if ( typeof webhook.port !== 'number' @@ -230,6 +237,11 @@ function parseTelegramConfigRecord(value: unknown): ChannelBridgeServerTelegramC } outWebhook.port = webhook.port; } + + if (Object.keys(outWebhook).length === 0) { + throw new ChannelBridgeBadPayloadError('Invalid telegram.webhook payload'); + } + out.telegram.webhook = outWebhook; } diff --git a/apps/cli/src/channels/core/channelBridgeWorker.test.ts b/apps/cli/src/channels/core/channelBridgeWorker.test.ts index ef39332d95..acd73dbc9a 100644 --- a/apps/cli/src/channels/core/channelBridgeWorker.test.ts +++ b/apps/cli/src/channels/core/channelBridgeWorker.test.ts @@ -185,6 +185,47 @@ describe('createInMemoryChannelBindingStore', () => { const updated = await store.getBinding(ref); expect(updated?.lastForwardedSeq).toBe(7); }); + + it('normalizes binding keys the same way as server-backed store', async () => { + const store = createInMemoryChannelBindingStore(); + + await store.upsertBinding({ + providerId: ' telegram ', + conversationId: ' room-1 ', + threadId: ' ', + sessionId: ' sess-1 ', + lastForwardedSeq: 1, + }); + + const found = await store.getBinding({ + providerId: 'telegram', + conversationId: 'room-1', + threadId: null, + }); + expect(found).toMatchObject({ + providerId: 'telegram', + conversationId: 'room-1', + threadId: null, + sessionId: 'sess-1', + }); + + const advanced = await store.updateLastForwardedSeq({ + providerId: ' telegram ', + conversationId: ' room-1 ', + threadId: ' ', + }, { + expectedSessionId: ' sess-1 ', + seq: 2, + }); + expect(advanced).toBe(true); + + const removed = await store.removeBinding({ + providerId: 'telegram', + conversationId: 'room-1', + threadId: null, + }); + expect(removed).toBe(true); + }); }); describe('executeChannelBridgeTick', () => { diff --git a/apps/cli/src/channels/core/channelBridgeWorker.ts b/apps/cli/src/channels/core/channelBridgeWorker.ts index 846730b6ef..997bf0f314 100644 --- a/apps/cli/src/channels/core/channelBridgeWorker.ts +++ b/apps/cli/src/channels/core/channelBridgeWorker.ts @@ -312,22 +312,35 @@ function normalizeAgentFetchResult( */ export function createInMemoryChannelBindingStore(now: () => number = () => Date.now()): ChannelBindingStore { const byKey = new Map(); + const normalizeRef = (ref: Readonly<{ + providerId: string; + conversationId: string; + threadId: string | null; + }>): ChannelBridgeConversationRef => { + const providerId = ref.providerId.trim(); + const conversationId = ref.conversationId.trim(); + const threadIdRaw = typeof ref.threadId === 'string' ? ref.threadId.trim() : ''; + return { + providerId, + conversationId, + threadId: threadIdRaw.length > 0 ? threadIdRaw : null, + }; + }; return { listBindings: async () => Array.from(byKey.values()).map((binding) => ({ ...binding })), getBinding: async (ref) => { - const found = byKey.get(bindingKey(ref)); + const found = byKey.get(bindingKey(normalizeRef(ref))); return found ? { ...found } : null; }, upsertBinding: async (binding) => { - const key = bindingKey(binding); + const normalizedRef = normalizeRef(binding); + const key = bindingKey(normalizedRef); const existing = byKey.get(key); const normalizedLastForwardedSeq = toNonNegativeInt(binding.lastForwardedSeq) ?? 0; const next: ChannelSessionBinding = { - providerId: binding.providerId, - conversationId: binding.conversationId, - threadId: binding.threadId, - sessionId: binding.sessionId, + ...normalizedRef, + sessionId: binding.sessionId.trim(), lastForwardedSeq: normalizedLastForwardedSeq, createdAtMs: existing?.createdAtMs ?? now(), updatedAtMs: now(), @@ -336,10 +349,10 @@ export function createInMemoryChannelBindingStore(now: () => number = () => Date return { ...next }; }, updateLastForwardedSeq: async (ref, params) => { - const key = bindingKey(ref); + const key = bindingKey(normalizeRef(ref)); const existing = byKey.get(key); if (!existing) return false; - if (existing.sessionId !== params.expectedSessionId) return false; + if (existing.sessionId !== params.expectedSessionId.trim()) return false; const parsedSeq = toNonNegativeInt(params.seq); if (parsedSeq === null) return false; const nextSeq = Math.max(existing.lastForwardedSeq, parsedSeq); @@ -353,7 +366,7 @@ export function createInMemoryChannelBindingStore(now: () => number = () => Date }); return true; }, - removeBinding: async (ref) => byKey.delete(bindingKey(ref)), + removeBinding: async (ref) => byKey.delete(bindingKey(normalizeRef(ref))), }; } @@ -451,7 +464,7 @@ async function handleCommand(params: Readonly<{ adapter: ChannelBridgeAdapter; store: ChannelBindingStore; deps: ChannelBridgeDeps; -}>): Promise { +}>): Promise { const { command, event, adapter, store, deps } = params; const ref: ChannelBridgeConversationRef = { providerId: event.providerId, @@ -478,7 +491,7 @@ async function handleCommand(params: Readonly<{ }); if (!authz.allowed) { await replyForCommand(authz.message ?? 'You are not authorized to run this command here.'); - return true; + return; } } @@ -494,7 +507,7 @@ async function handleCommand(params: Readonly<{ '/start - alias for /help', ].join('\n'), ); - return true; + return; } if (command.name === 'sessions') { @@ -508,10 +521,10 @@ async function handleCommand(params: Readonly<{ } catch (error) { deps.onWarning?.('Failed to list sessions for /sessions command', error); await replyForCommand('Failed to retrieve sessions. Please try again later.'); - return true; + return; } await replyForCommand(formatSessionsMessage(sessions)); - return true; + return; } if (command.name === 'session') { @@ -525,22 +538,22 @@ async function handleCommand(params: Readonly<{ } catch (error) { deps.onWarning?.('Failed to read binding for /session command', error); await replyForCommand('Failed to read current session binding. Please try again later.'); - return true; + return; } if (!existing) { await replyForCommand('No session is attached here. Use /attach .'); - return true; + return; } await replyForCommand(`Attached session: ${existing.sessionId}`); - return true; + return; } if (command.name === 'attach') { const idOrPrefix = String(command.args[0] ?? '').trim(); if (!idOrPrefix) { await replyForCommand('Usage: /attach '); - return true; + return; } let resolved: ResolveSessionIdResult; @@ -555,7 +568,7 @@ async function handleCommand(params: Readonly<{ await replyForCommand( `Failed to attach to session ${idOrPrefix}: unable to resolve session identifier.`, ); - return true; + return; } if (!resolved.ok) { @@ -564,19 +577,19 @@ async function handleCommand(params: Readonly<{ await replyForCommand( `Ambiguous session prefix. Candidates:\n${resolved.candidates.map((id) => `• ${id}`).join('\n')}`, ); - return true; + return; } await replyForCommand('Ambiguous session prefix. Use /sessions to list active sessions.'); - return true; + return; } if (resolved.code === 'unsupported') { await replyForCommand('Attaching by session ID or prefix is not supported in this environment.'); - return true; + return; } await replyForCommand('Session not found. Use /sessions to list recent sessions.'); - return true; + return; } let latestSeq: number; @@ -593,7 +606,7 @@ async function handleCommand(params: Readonly<{ await replyForCommand( `Failed to attach to session ${resolved.sessionId}: unable to resolve latest sequence cursor.`, ); - return true; + return; } latestSeq = resolvedSeq; } catch (error) { @@ -601,7 +614,7 @@ async function handleCommand(params: Readonly<{ await replyForCommand( `Failed to attach to session ${resolved.sessionId}: unable to resolve latest sequence cursor.`, ); - return true; + return; } let previousBinding: ChannelSessionBinding | null; @@ -614,7 +627,7 @@ async function handleCommand(params: Readonly<{ } catch (error) { deps.onWarning?.('Failed to read existing binding during /attach', error); await replyForCommand('Failed to read current binding before attach. Please try again later.'); - return true; + return; } const previousSessionId = previousBinding?.sessionId ?? null; @@ -633,7 +646,7 @@ async function handleCommand(params: Readonly<{ } catch (error) { deps.onWarning?.('Failed to persist binding during /attach', error); await replyForCommand(`Failed to attach to session ${resolved.sessionId}: unable to persist binding.`); - return true; + return; } const switchedFrom = @@ -641,7 +654,7 @@ async function handleCommand(params: Readonly<{ ? ` (replaced previous session ${previousSessionId})` : ''; await replyForCommand(`Attached this conversation to session ${resolved.sessionId}${switchedFrom}.`); - return true; + return; } if (command.name === 'detach') { @@ -655,7 +668,7 @@ async function handleCommand(params: Readonly<{ } catch (error) { deps.onWarning?.('Failed to remove binding for /detach command', error); await replyForCommand('Failed to detach current session binding. Please try again later.'); - return true; + return; } if (removed) { @@ -663,11 +676,11 @@ async function handleCommand(params: Readonly<{ } else { await replyForCommand('No session was attached here.'); } - return true; + return; } await replyForCommand(`Unknown command: /${command.name}. Use /help for supported commands.`); - return true; + return; } /** diff --git a/apps/cli/src/channels/startChannelBridgeWorker.test.ts b/apps/cli/src/channels/startChannelBridgeWorker.test.ts index 4321e5b81b..6a92c08980 100644 --- a/apps/cli/src/channels/startChannelBridgeWorker.test.ts +++ b/apps/cli/src/channels/startChannelBridgeWorker.test.ts @@ -497,14 +497,33 @@ describe('startChannelBridgeFromEnv', () => { threadId: '42', messageId: 'msg-2', }); + await capturedDeps!.sendUserMessageToSession({ + sessionId: 'sess-local-id-1', + text: 'fallback without message id', + sentFrom: 'telegram', + providerId: 'telegram', + conversationId: '-100local', + threadId: '42', + }); + await capturedDeps!.sendUserMessageToSession({ + sessionId: 'sess-local-id-1', + text: 'fallback without message id', + sentFrom: 'telegram', + providerId: 'telegram', + conversationId: '-100local', + threadId: '42', + }); - expect(sendCommitted).toHaveBeenCalledTimes(3); + expect(sendCommitted).toHaveBeenCalledTimes(5); const firstLocalId = sendCommitted.mock.calls[0]?.[0]?.localId; const secondLocalId = sendCommitted.mock.calls[1]?.[0]?.localId; const thirdLocalId = sendCommitted.mock.calls[2]?.[0]?.localId; + const fourthLocalId = sendCommitted.mock.calls[3]?.[0]?.localId; + const fifthLocalId = sendCommitted.mock.calls[4]?.[0]?.localId; expect(firstLocalId).toBe(secondLocalId); expect(thirdLocalId).not.toBe(firstLocalId); + expect(fourthLocalId).toBe(fifthLocalId); await handle?.stop(); }); @@ -894,4 +913,51 @@ describe('startChannelBridgeFromEnv', () => { expect(relayStop).toHaveBeenCalledTimes(1); expect(startWorker).not.toHaveBeenCalled(); }); + + it('fails fast when only one of serverId/accountId is provided', async () => { + vi.resetModules(); + + const startWorker = vi.fn(); + + vi.doMock('./core/channelBridgeWorker', () => ({ + createInMemoryChannelBindingStore: vi.fn(() => ({ + listBindings: async () => [], + getBinding: async () => null, + upsertBinding: async () => ({ + providerId: 'telegram', + conversationId: 'conv-1', + threadId: null, + sessionId: 'sess-1', + lastForwardedSeq: 0, + createdAtMs: Date.now(), + updatedAtMs: Date.now(), + }), + updateLastForwardedSeq: async () => true, + removeBinding: async () => false, + })), + startChannelBridgeWorker: startWorker, + })); + + vi.doMock('./telegram/telegramAdapter', () => ({ + createTelegramChannelAdapter: vi.fn(() => ({ + providerId: 'telegram', + pullInboundMessages: async () => [], + sendMessage: async () => undefined, + enqueueWebhookUpdate: vi.fn(), + stop: async () => undefined, + })), + })); + + const { startChannelBridgeFromEnv } = await import('./startChannelBridgeWorker'); + + await expect(startChannelBridgeFromEnv({ + credentials, + serverId: 'server-only', + env: { + HAPPIER_TELEGRAM_BOT_TOKEN: 'bot-token', + } as NodeJS.ProcessEnv, + })).rejects.toThrow('require both serverId and accountId'); + + expect(startWorker).not.toHaveBeenCalled(); + }); }); diff --git a/apps/cli/src/channels/startChannelBridgeWorker.ts b/apps/cli/src/channels/startChannelBridgeWorker.ts index 99c2151867..9ed71930c7 100644 --- a/apps/cli/src/channels/startChannelBridgeWorker.ts +++ b/apps/cli/src/channels/startChannelBridgeWorker.ts @@ -1,4 +1,4 @@ -import { createHash, randomUUID } from 'node:crypto'; +import { createHash } from 'node:crypto'; import { serializeAxiosErrorForLog } from '@/api/client/serializeAxiosErrorForLog'; import { fetchEncryptedTranscriptPageAfterSeq, fetchEncryptedTranscriptPageLatest } from '@/api/session/fetchEncryptedTranscriptWindow'; @@ -53,12 +53,11 @@ function createStableBridgeLocalId(params: Readonly<{ providerId: string; conversationId: string; threadId: string | null; + sessionId: string; + text: string; messageId?: string; }>): string { const normalizedMessageId = typeof params.messageId === 'string' ? params.messageId.trim() : ''; - if (normalizedMessageId.length === 0) { - return randomUUID(); - } const digest = createHash('sha256') .update(params.providerId) @@ -67,7 +66,7 @@ function createStableBridgeLocalId(params: Readonly<{ .update('\u0000') .update(params.threadId ?? '') .update('\u0000') - .update(normalizedMessageId) + .update(normalizedMessageId.length > 0 ? normalizedMessageId : `fallback:${params.sessionId}:${params.text.trim()}`) .digest('hex'); return `bridge-${digest}`; } @@ -269,6 +268,8 @@ function createDefaultChannelBridgeDeps(credentials: Credentials): DefaultChanne providerId: params.providerId, conversationId: params.conversationId, threadId: params.threadId, + sessionId: params.sessionId, + text: params.text, messageId: params.messageId, }), sentFrom: params.sentFrom, @@ -442,6 +443,9 @@ export async function startChannelBridgeFromEnv(params: Readonly<{ if (!store) { const serverId = typeof params.serverId === 'string' ? params.serverId.trim() : ''; const accountId = typeof params.accountId === 'string' ? params.accountId.trim() : ''; + if ((serverId.length > 0) !== (accountId.length > 0)) { + throw new Error('Channel bridge server-backed bindings require both serverId and accountId'); + } if (serverId && accountId) { const kv = createAxiosChannelBridgeKvClient({ token: params.credentials.token }); store = createServerBackedChannelBindingStore({ diff --git a/apps/cli/src/cli/commands/bridge.ts b/apps/cli/src/cli/commands/bridge.ts index 2a286712e8..e8dd6db591 100644 --- a/apps/cli/src/cli/commands/bridge.ts +++ b/apps/cli/src/cli/commands/bridge.ts @@ -104,7 +104,7 @@ ${chalk.bold('happier bridge')} - Channel bridge configuration (account-scoped) ${chalk.bold('Usage:')} happier bridge list - happier bridge telegram set [--bot-token ] [--allowed-chat-ids |--allow-all] [--require-topics ] [--tick-ms ] [--webhook-enabled ] [--webhook-secret ] [--webhook-host ] [--webhook-port ] + happier bridge telegram set [--bot-token ] [--allowed-chat-ids |--allow-all] [--require-topics ] [--tick-ms ] [--webhook-enabled ] [--webhook-secret ] [--webhook-host ] [--webhook-port (default: 8787)] happier bridge telegram clear ${chalk.bold('Notes:')} From b40be4d09501d82acab075acf402942723e466b1 Mon Sep 17 00:00:00 2001 From: Chris Denneen Date: Fri, 6 Mar 2026 23:39:42 -0500 Subject: [PATCH 29/81] fix(cli): validate env webhook secret token at config load --- .../src/channels/channelBridgeConfig.test.ts | 21 +++++++++++++++++++ apps/cli/src/channels/channelBridgeConfig.ts | 15 +++++++++++-- 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/apps/cli/src/channels/channelBridgeConfig.test.ts b/apps/cli/src/channels/channelBridgeConfig.test.ts index 7a9a03a5d4..92f8cc92be 100644 --- a/apps/cli/src/channels/channelBridgeConfig.test.ts +++ b/apps/cli/src/channels/channelBridgeConfig.test.ts @@ -264,6 +264,27 @@ describe('resolveChannelBridgeRuntimeConfig', () => { expect(config.telegram.webhookSecret).toBe('server-secret'); }); + it('falls back to settings webhook secret when env secret token is invalid', () => { + const config = resolveChannelBridgeRuntimeConfig({ + env: { + HAPPIER_TELEGRAM_WEBHOOK_SECRET: 'bad token!', + }, + settings: { + channelBridge: { + providers: { + telegram: { + webhook: { + secret: 'settings-secret', + }, + }, + }, + }, + }, + }); + + expect(config.telegram.webhookSecret).toBe('settings-secret'); + }); + it('ignores non-loopback env webhook host and keeps settings host', () => { const config = resolveChannelBridgeRuntimeConfig({ env: { diff --git a/apps/cli/src/channels/channelBridgeConfig.ts b/apps/cli/src/channels/channelBridgeConfig.ts index 310a854079..c1bae28f4f 100644 --- a/apps/cli/src/channels/channelBridgeConfig.ts +++ b/apps/cli/src/channels/channelBridgeConfig.ts @@ -69,6 +69,15 @@ function readTrimmedString(value: unknown): string | null { return trimmed.length > 0 ? trimmed : null; } +const TELEGRAM_WEBHOOK_SECRET_TOKEN_PATTERN = /^[A-Za-z0-9_-]+$/; + +function readWebhookSecretToken(value: unknown): string | null { + const token = readTrimmedString(value); + if (!token) return null; + if (!TELEGRAM_WEBHOOK_SECRET_TOKEN_PATTERN.test(token)) return null; + return token; +} + function firstParsed(values: readonly unknown[], parse: (value: unknown) => T | null): T | null { for (const value of values) { const parsed = parse(value); @@ -214,10 +223,12 @@ export function resolveChannelBridgeRuntimeConfig(params: Readonly<{ webhookGlobal?.secret, ], readTrimmedString) ?? ''; - const webhookSecret = + const envWebhookSecretRaw = typeof env.HAPPIER_TELEGRAM_WEBHOOK_SECRET === 'string' ? env.HAPPIER_TELEGRAM_WEBHOOK_SECRET.trim() - : settingsWebhookSecret; + : null; + const envWebhookSecret = readWebhookSecretToken(envWebhookSecretRaw); + const webhookSecret = envWebhookSecret ?? settingsWebhookSecret; const settingsWebhookHostRaw = firstParsed( From 38dcc37e1d0ca692a5e977957253670f1fb8383b Mon Sep 17 00:00:00 2001 From: Chris Denneen Date: Fri, 6 Mar 2026 23:53:23 -0500 Subject: [PATCH 30/81] test(cli): harden bridge startup tests and enforce message ids --- .../channels/startChannelBridgeWorker.test.ts | 83 ++++++++++++++----- .../src/channels/startChannelBridgeWorker.ts | 5 +- 2 files changed, 68 insertions(+), 20 deletions(-) diff --git a/apps/cli/src/channels/startChannelBridgeWorker.test.ts b/apps/cli/src/channels/startChannelBridgeWorker.test.ts index 6a92c08980..d962184345 100644 --- a/apps/cli/src/channels/startChannelBridgeWorker.test.ts +++ b/apps/cli/src/channels/startChannelBridgeWorker.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it, vi } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { startChannelBridgeFromEnv } from './startChannelBridgeWorker'; @@ -10,6 +10,26 @@ const credentials = { }, }; +afterEach(() => { + vi.clearAllMocks(); + vi.restoreAllMocks(); + vi.resetModules(); + + for (const modulePath of [ + '@/sessionControl/sessionsHttp', + '@/api/session/fetchEncryptedTranscriptWindow', + '@/sessionControl/sessionSocketSendMessage', + '@/session/replay/decryptTranscriptRows', + '@/ui/logger', + './core/channelBridgeWorker', + './telegram/telegramAdapter', + './telegram/telegramWebhookRelay', + './channelBridgeServerKv', + ]) { + vi.doUnmock(modulePath); + } +}); + describe('startChannelBridgeFromEnv', () => { it('returns null when telegram token is not configured and no custom adapters are provided', async () => { const handle = await startChannelBridgeFromEnv({ @@ -237,6 +257,7 @@ describe('startChannelBridgeFromEnv', () => { providerId: 'telegram', conversationId: '-100111', threadId: null, + messageId: 'msg-ctx-1', }); await capturedDeps!.fetchAgentMessagesAfterSeq({ sessionId: 'sess-ctx-1', @@ -388,7 +409,7 @@ describe('startChannelBridgeFromEnv', () => { await handle?.stop(); }); - it('uses deterministic localId for retried channel messages with stable message ids', async () => { + it('uses deterministic localId for retried channel messages and rejects missing message ids', async () => { vi.resetModules(); const fetchSessionById = vi.fn(async () => ({ @@ -497,33 +518,33 @@ describe('startChannelBridgeFromEnv', () => { threadId: '42', messageId: 'msg-2', }); - await capturedDeps!.sendUserMessageToSession({ + + await expect(capturedDeps!.sendUserMessageToSession({ sessionId: 'sess-local-id-1', - text: 'fallback without message id', + text: 'missing message id', sentFrom: 'telegram', providerId: 'telegram', conversationId: '-100local', threadId: '42', - }); - await capturedDeps!.sendUserMessageToSession({ + })).rejects.toThrow('inbound messageId is required'); + + await expect(capturedDeps!.sendUserMessageToSession({ sessionId: 'sess-local-id-1', - text: 'fallback without message id', + text: 'blank message id', sentFrom: 'telegram', providerId: 'telegram', conversationId: '-100local', threadId: '42', - }); + messageId: ' ', + })).rejects.toThrow('inbound messageId is required'); - expect(sendCommitted).toHaveBeenCalledTimes(5); + expect(sendCommitted).toHaveBeenCalledTimes(3); const firstLocalId = sendCommitted.mock.calls[0]?.[0]?.localId; const secondLocalId = sendCommitted.mock.calls[1]?.[0]?.localId; const thirdLocalId = sendCommitted.mock.calls[2]?.[0]?.localId; - const fourthLocalId = sendCommitted.mock.calls[3]?.[0]?.localId; - const fifthLocalId = sendCommitted.mock.calls[4]?.[0]?.localId; expect(firstLocalId).toBe(secondLocalId); expect(thirdLocalId).not.toBe(firstLocalId); - expect(fourthLocalId).toBe(fifthLocalId); await handle?.stop(); }); @@ -738,6 +759,7 @@ describe('startChannelBridgeFromEnv', () => { providerId: 'telegram', conversationId: '-100111', threadId: null, + messageId: `msg-${sessionId}`, }); } @@ -748,6 +770,7 @@ describe('startChannelBridgeFromEnv', () => { providerId: 'telegram', conversationId: '-100111', threadId: null, + messageId: 'msg-sess-lru-0-refresh', }); await capturedDeps!.sendUserMessageToSession({ @@ -757,6 +780,7 @@ describe('startChannelBridgeFromEnv', () => { providerId: 'telegram', conversationId: '-100111', threadId: null, + messageId: 'msg-sess-lru-200-evict', }); await capturedDeps!.sendUserMessageToSession({ @@ -766,6 +790,7 @@ describe('startChannelBridgeFromEnv', () => { providerId: 'telegram', conversationId: '-100111', threadId: null, + messageId: 'msg-sess-lru-0-stay', }); expect(fetchSessionById).toHaveBeenCalledTimes(201); @@ -777,6 +802,14 @@ describe('startChannelBridgeFromEnv', () => { vi.resetModules(); const warnSpy = vi.fn(); + const startRelay = vi.fn(); + const createTelegramChannelAdapter = vi.fn(() => ({ + providerId: 'telegram', + pullInboundMessages: async () => [], + sendMessage: async () => undefined, + enqueueWebhookUpdate: vi.fn(), + stop: async () => undefined, + })); vi.doMock('@/ui/logger', () => ({ logger: { @@ -809,15 +842,17 @@ describe('startChannelBridgeFromEnv', () => { })); vi.doMock('./telegram/telegramAdapter', () => ({ - createTelegramChannelAdapter: vi.fn(() => ({ - providerId: 'telegram', - pullInboundMessages: async () => [], - sendMessage: async () => undefined, - enqueueWebhookUpdate: vi.fn(), - stop: async () => undefined, - })), + createTelegramChannelAdapter, })); + vi.doMock('./telegram/telegramWebhookRelay', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + startTelegramWebhookRelay: startRelay, + }; + }); + const { startChannelBridgeFromEnv } = await import('./startChannelBridgeWorker'); const handle = await startChannelBridgeFromEnv({ @@ -832,6 +867,8 @@ describe('startChannelBridgeFromEnv', () => { expect(warnSpy).toHaveBeenCalledWith( '[channelBridge] Telegram webhook.enabled=true but webhook.secret is missing; falling back to polling mode', ); + expect(startRelay).not.toHaveBeenCalled(); + expect(createTelegramChannelAdapter).toHaveBeenCalledWith(expect.objectContaining({ webhookMode: false })); await handle?.stop(); }); @@ -958,6 +995,14 @@ describe('startChannelBridgeFromEnv', () => { } as NodeJS.ProcessEnv, })).rejects.toThrow('require both serverId and accountId'); + await expect(startChannelBridgeFromEnv({ + credentials, + accountId: 'account-only', + env: { + HAPPIER_TELEGRAM_BOT_TOKEN: 'bot-token', + } as NodeJS.ProcessEnv, + })).rejects.toThrow('require both serverId and accountId'); + expect(startWorker).not.toHaveBeenCalled(); }); }); diff --git a/apps/cli/src/channels/startChannelBridgeWorker.ts b/apps/cli/src/channels/startChannelBridgeWorker.ts index 9ed71930c7..978cea807e 100644 --- a/apps/cli/src/channels/startChannelBridgeWorker.ts +++ b/apps/cli/src/channels/startChannelBridgeWorker.ts @@ -58,6 +58,9 @@ function createStableBridgeLocalId(params: Readonly<{ messageId?: string; }>): string { const normalizedMessageId = typeof params.messageId === 'string' ? params.messageId.trim() : ''; + if (normalizedMessageId.length === 0) { + throw new Error('Channel bridge inbound messageId is required for stable idempotency'); + } const digest = createHash('sha256') .update(params.providerId) @@ -66,7 +69,7 @@ function createStableBridgeLocalId(params: Readonly<{ .update('\u0000') .update(params.threadId ?? '') .update('\u0000') - .update(normalizedMessageId.length > 0 ? normalizedMessageId : `fallback:${params.sessionId}:${params.text.trim()}`) + .update(normalizedMessageId) .digest('hex'); return `bridge-${digest}`; } From a3dc2d994eef48b44262f716c018ded1f8ce24f3 Mon Sep 17 00:00:00 2001 From: Chris Denneen Date: Sat, 7 Mar 2026 00:59:53 -0500 Subject: [PATCH 31/81] fix(cli): tighten telegram KV chat-id validation and add module header --- .../channels/channelBridgeServerKv.test.ts | 70 +++++++++++++++++++ .../cli/src/channels/channelBridgeServerKv.ts | 16 ++++- .../src/channels/startChannelBridgeWorker.ts | 18 +++++ 3 files changed, 102 insertions(+), 2 deletions(-) diff --git a/apps/cli/src/channels/channelBridgeServerKv.test.ts b/apps/cli/src/channels/channelBridgeServerKv.test.ts index 4535e5586b..44e17bde71 100644 --- a/apps/cli/src/channels/channelBridgeServerKv.test.ts +++ b/apps/cli/src/channels/channelBridgeServerKv.test.ts @@ -254,6 +254,49 @@ describe('channelBridgeServerKv', () => { })).rejects.toThrow('Invalid telegram.allowedChatIds update payload'); }); + it('rejects mixed valid/invalid allowedChatIds updates', async () => { + const kv = createInMemoryKvClient(); + + await expect(upsertChannelBridgeTelegramConfigInKv({ + kv, + serverId: 'local-3005', + accountId: 'acct-1', + update: { + allowedChatIds: ['-100111', ' '], + }, + })).rejects.toThrow('Invalid telegram.allowedChatIds update payload'); + }); + + it('allows explicit allow-all updates with empty allowedChatIds', async () => { + const kv = createInMemoryKvClient(); + + await upsertChannelBridgeTelegramConfigInKv({ + kv, + serverId: 'local-3005', + accountId: 'acct-1', + update: { + allowedChatIds: ['-100111'], + }, + }); + + await upsertChannelBridgeTelegramConfigInKv({ + kv, + serverId: 'local-3005', + accountId: 'acct-1', + update: { + allowedChatIds: [], + }, + }); + + const config = await readChannelBridgeTelegramConfigFromKv({ + kv, + serverId: 'local-3005', + accountId: 'acct-1', + }); + + expect(config.record?.telegram.allowedChatIds).toEqual([]); + }); + it('rejects invalid webhook port updates', async () => { const kv = createInMemoryKvClient(); @@ -968,6 +1011,33 @@ describe('channelBridgeServerKv', () => { })).rejects.toThrow('Invalid telegram.allowedChatIds payload'); }); + it('throws when telegram config payload contains mixed valid/invalid allowedChatIds', async () => { + const mixedAllowedChatIdsValueBase64 = Buffer.from(JSON.stringify({ + schemaVersion: 1, + telegram: { + allowedChatIds: ['-100123', ' ', 123], + }, + updatedAtMs: 123, + }), 'utf8').toString('base64'); + const kv: ChannelBridgeKvClient = { + get: async () => ({ + status: 200, + body: { + key: 'k', + value: mixedAllowedChatIdsValueBase64, + version: 3, + }, + }), + mutate: async () => ({ status: 200, body: { success: true, results: [] } }), + }; + + await expect(readChannelBridgeTelegramConfigFromKv({ + kv, + serverId: 'local-3005', + accountId: 'acct-1', + })).rejects.toThrow('Invalid telegram.allowedChatIds payload'); + }); + it('throws when telegram config payload contains non-array allowedChatIds', async () => { const malformedAllowedChatIdsTypeValueBase64 = Buffer.from(JSON.stringify({ schemaVersion: 1, diff --git a/apps/cli/src/channels/channelBridgeServerKv.ts b/apps/cli/src/channels/channelBridgeServerKv.ts index cca16cbd79..a58ff8538c 100644 --- a/apps/cli/src/channels/channelBridgeServerKv.ts +++ b/apps/cli/src/channels/channelBridgeServerKv.ts @@ -117,6 +117,10 @@ function normalizeStringArray(values: readonly unknown[]): string[] { .filter((value) => value.length > 0); } +function hasOnlyNonEmptyStringValues(values: readonly unknown[]): boolean { + return values.every((value) => typeof value === 'string' && value.trim().length > 0); +} + function parseKvEntry(value: unknown): Readonly<{ valueBase64: string; version: number }> | null { const record = asRecord(value); if (!record) return null; @@ -196,7 +200,11 @@ function parseTelegramConfigRecord(value: unknown): ChannelBridgeServerTelegramC throw new ChannelBridgeBadPayloadError('Invalid telegram.allowedChatIds payload'); } const normalizedAllowedChatIds = normalizeStringArray(telegram.allowedChatIds); - if (telegram.allowedChatIds.length > 0 && normalizedAllowedChatIds.length === 0) { + if ( + telegram.allowedChatIds.length > 0 + && (!hasOnlyNonEmptyStringValues(telegram.allowedChatIds) + || normalizedAllowedChatIds.length !== telegram.allowedChatIds.length) + ) { throw new ChannelBridgeBadPayloadError('Invalid telegram.allowedChatIds payload'); } out.telegram.allowedChatIds = normalizedAllowedChatIds; @@ -570,7 +578,11 @@ function applyTelegramConfigUpdate(current: ChannelBridgeServerTelegramConfigRec if (Array.isArray(update.allowedChatIds)) { const normalizedAllowedChatIds = normalizeStringArray(update.allowedChatIds); - if (update.allowedChatIds.length > 0 && normalizedAllowedChatIds.length === 0) { + if ( + update.allowedChatIds.length > 0 + && (!hasOnlyNonEmptyStringValues(update.allowedChatIds) + || normalizedAllowedChatIds.length !== update.allowedChatIds.length) + ) { throw new ChannelBridgeBadPayloadError('Invalid telegram.allowedChatIds update payload'); } next.telegram.allowedChatIds = normalizedAllowedChatIds; diff --git a/apps/cli/src/channels/startChannelBridgeWorker.ts b/apps/cli/src/channels/startChannelBridgeWorker.ts index 978cea807e..3fe08fbbed 100644 --- a/apps/cli/src/channels/startChannelBridgeWorker.ts +++ b/apps/cli/src/channels/startChannelBridgeWorker.ts @@ -1,3 +1,21 @@ +/** + * Channel bridge runtime wiring for the CLI daemon. + * + * Responsibilities: + * - resolve effective bridge runtime config from env/settings/account scope + * - construct provider adapters (Telegram today) and optional webhook relay + * - choose binding store implementation (in-memory or server-backed KV) + * - build default bridge deps that map channel events <-> session transcript I/O + * - start and return the core channel bridge worker lifecycle handle + * + * Public exports: + * - `ChannelBridgeRuntimeHandle` + * - `startChannelBridgeFromEnv` + * + * Runtime constraints: + * - designed for daemon/CLI runtime usage + * - webhook relay is loopback-bound and optional; polling remains supported + */ import { createHash } from 'node:crypto'; import { serializeAxiosErrorForLog } from '@/api/client/serializeAxiosErrorForLog'; From 7d1de5979676b34637326433a67c356c5632a97b Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Thu, 26 Mar 2026 15:45:08 +0100 Subject: [PATCH 32/81] feat(protocol): add channel bridge feature ids --- packages/protocol/src/features/catalog.test.ts | 5 +++++ packages/protocol/src/features/catalog.ts | 12 ++++++++++++ 2 files changed, 17 insertions(+) diff --git a/packages/protocol/src/features/catalog.test.ts b/packages/protocol/src/features/catalog.test.ts index 6b69215420..d2b0f085ab 100644 --- a/packages/protocol/src/features/catalog.test.ts +++ b/packages/protocol/src/features/catalog.test.ts @@ -27,6 +27,11 @@ describe('feature catalog', () => { expect(isFeatureId('connectedServices.quotas')).toBe(true); }); + it('includes channel bridge feature ids', () => { + expect(isFeatureId('channelBridges')).toBe(true); + expect(isFeatureId('channelBridges.telegram')).toBe(true); + }); + it('includes OTA updates feature id', () => { expect(isFeatureId('updates.ota')).toBe(true); }); diff --git a/packages/protocol/src/features/catalog.ts b/packages/protocol/src/features/catalog.ts index 5dc4764d71..30ed262cb9 100644 --- a/packages/protocol/src/features/catalog.ts +++ b/packages/protocol/src/features/catalog.ts @@ -51,6 +51,18 @@ const FEATURE_CATALOG_DEFINITION = { dependencies: ['connectedServices'], representation: 'server', }, + channelBridges: { + description: 'Channel bridge integrations (Telegram/Discord/etc).', + defaultFailMode: 'fail_closed', + dependencies: [], + representation: 'server', + }, + 'channelBridges.telegram': { + description: 'Telegram channel bridge provider.', + defaultFailMode: 'fail_closed', + dependencies: ['channelBridges'], + representation: 'server', + }, 'updates.ota': { description: 'Expo over-the-air update checks and apply flows.', defaultFailMode: 'fail_closed', From cdd4f5fc051c4fb130d2bba9accd2bb6c882efa7 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Thu, 26 Mar 2026 15:48:46 +0100 Subject: [PATCH 33/81] feat(protocol): add channel bridges gates payload --- .../payload/featureGatesSchema.test.ts | 22 +++++++++++++++++++ .../features/payload/featureGatesSchema.ts | 7 ++++++ 2 files changed, 29 insertions(+) create mode 100644 packages/protocol/src/features/payload/featureGatesSchema.test.ts diff --git a/packages/protocol/src/features/payload/featureGatesSchema.test.ts b/packages/protocol/src/features/payload/featureGatesSchema.test.ts new file mode 100644 index 0000000000..b3452c83fc --- /dev/null +++ b/packages/protocol/src/features/payload/featureGatesSchema.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; + +import { readServerEnabledBit } from '../serverEnabledBit.js'; +import { FeaturesResponseSchema } from './featuresResponseSchema.js'; + +describe('FeatureGatesSchema', () => { + it('preserves channel bridge gates', () => { + const parsed = FeaturesResponseSchema.parse({ + features: { + channelBridges: { + enabled: true, + telegram: { enabled: true }, + }, + }, + capabilities: {}, + }); + + expect(readServerEnabledBit(parsed, 'channelBridges')).toBe(true); + expect(readServerEnabledBit(parsed, 'channelBridges.telegram')).toBe(true); + }); +}); + diff --git a/packages/protocol/src/features/payload/featureGatesSchema.ts b/packages/protocol/src/features/payload/featureGatesSchema.ts index b12fc6a8a4..5f67c1563a 100644 --- a/packages/protocol/src/features/payload/featureGatesSchema.ts +++ b/packages/protocol/src/features/payload/featureGatesSchema.ts @@ -44,6 +44,13 @@ export const FeatureGatesSchema = z.object({ }) .optional() .default({ enabled: false, quotas: DEFAULT_GATE_DISABLED }), + channelBridges: z + .object({ + enabled: z.boolean(), + telegram: FeatureGateSchema.optional().default(DEFAULT_GATE_DISABLED), + }) + .optional() + .default({ enabled: false, telegram: DEFAULT_GATE_DISABLED }), updates: z .object({ ota: FeatureGateSchema.optional().default(DEFAULT_GATE_DISABLED), From 2f464a731cb8b396a9108ffc459c6f938330c643 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Thu, 26 Mar 2026 15:51:38 +0100 Subject: [PATCH 34/81] feat(server): add channel bridges feature gates --- .../app/features/catalog/featureEnvSchema.ts | 3 +++ .../features/catalog/readFeatureEnv.test.ts | 15 ++++++++++++++ .../app/features/catalog/readFeatureEnv.ts | 12 +++++++++++ .../resolveServerFeaturePayload.spec.ts | 7 +++++++ .../features/catalog/serverFeatureRegistry.ts | 2 ++ .../app/features/channelBridgesFeature.ts | 20 +++++++++++++++++++ 6 files changed, 59 insertions(+) create mode 100644 apps/server/sources/app/features/channelBridgesFeature.ts diff --git a/apps/server/sources/app/features/catalog/featureEnvSchema.ts b/apps/server/sources/app/features/catalog/featureEnvSchema.ts index 280404e6c3..f7a4ab078b 100644 --- a/apps/server/sources/app/features/catalog/featureEnvSchema.ts +++ b/apps/server/sources/app/features/catalog/featureEnvSchema.ts @@ -15,6 +15,9 @@ export const FEATURE_ENV_KEYS = Object.freeze({ connectedServicesEnabled: 'HAPPIER_FEATURE_CONNECTED_SERVICES__ENABLED', connectedServicesQuotasEnabled: 'HAPPIER_FEATURE_CONNECTED_SERVICES_QUOTAS__ENABLED', + channelBridgesEnabled: 'HAPPIER_FEATURE_CHANNEL_BRIDGES__ENABLED', + channelBridgesTelegramEnabled: 'HAPPIER_FEATURE_CHANNEL_BRIDGES_TELEGRAM__ENABLED', + updatesOtaEnabled: 'HAPPIER_FEATURE_UPDATES_OTA__ENABLED', attachmentsUploadsEnabled: 'HAPPIER_FEATURE_ATTACHMENTS_UPLOADS__ENABLED', diff --git a/apps/server/sources/app/features/catalog/readFeatureEnv.test.ts b/apps/server/sources/app/features/catalog/readFeatureEnv.test.ts index a340dfb656..b6bae6a71e 100644 --- a/apps/server/sources/app/features/catalog/readFeatureEnv.test.ts +++ b/apps/server/sources/app/features/catalog/readFeatureEnv.test.ts @@ -2,6 +2,7 @@ import { describe, expect, it } from 'vitest'; import { readAuthFeatureEnv, + readChannelBridgesFeatureEnv, readConnectedServicesFeatureEnv, readMachineTransferFeatureEnv, readSessionHandoffFeatureEnv, @@ -16,6 +17,20 @@ describe('readConnectedServicesFeatureEnv', () => { }); }); +describe('readChannelBridgesFeatureEnv', () => { + it('defaults enabled to true when env is unset', () => { + const env: NodeJS.ProcessEnv = {}; + const res = readChannelBridgesFeatureEnv(env); + expect(res.enabled).toBe(true); + }); + + it('defaults telegramEnabled to true when env is unset', () => { + const env: NodeJS.ProcessEnv = {}; + const res = readChannelBridgesFeatureEnv(env); + expect(res.telegramEnabled).toBe(true); + }); +}); + describe('readAuthFeatureEnv', () => { it('falls back to legacy AUTH_UI_* env vars for auto-redirect', () => { const env: NodeJS.ProcessEnv = { diff --git a/apps/server/sources/app/features/catalog/readFeatureEnv.ts b/apps/server/sources/app/features/catalog/readFeatureEnv.ts index 07117f8f1d..189e045404 100644 --- a/apps/server/sources/app/features/catalog/readFeatureEnv.ts +++ b/apps/server/sources/app/features/catalog/readFeatureEnv.ts @@ -26,6 +26,11 @@ export type ConnectedServicesFeatureEnv = Readonly<{ quotasEnabled: boolean; }>; +export type ChannelBridgesFeatureEnv = Readonly<{ + enabled: boolean; + telegramEnabled: boolean; +}>; + export type UpdatesFeatureEnv = Readonly<{ otaEnabled: boolean; }>; @@ -213,6 +218,13 @@ export function readConnectedServicesFeatureEnv(env: NodeJS.ProcessEnv): Connect }; } +export function readChannelBridgesFeatureEnv(env: NodeJS.ProcessEnv): ChannelBridgesFeatureEnv { + return { + enabled: parseBooleanEnv(env[FEATURE_ENV_KEYS.channelBridgesEnabled], true), + telegramEnabled: parseBooleanEnv(env[FEATURE_ENV_KEYS.channelBridgesTelegramEnabled], true), + }; +} + export function readUpdatesFeatureEnv(env: NodeJS.ProcessEnv): UpdatesFeatureEnv { return { otaEnabled: parseBooleanEnv(env[FEATURE_ENV_KEYS.updatesOtaEnabled], true), diff --git a/apps/server/sources/app/features/catalog/resolveServerFeaturePayload.spec.ts b/apps/server/sources/app/features/catalog/resolveServerFeaturePayload.spec.ts index f819c1f792..233a890b12 100644 --- a/apps/server/sources/app/features/catalog/resolveServerFeaturePayload.spec.ts +++ b/apps/server/sources/app/features/catalog/resolveServerFeaturePayload.spec.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from "vitest"; import { resolveMachineTransferFeature } from "../machineTransferFeature"; +import { resolveChannelBridgesFeature } from "../channelBridgesFeature"; import { resolveSessionHandoffFeature } from "../sessionHandoffFeature"; import { resolveTerminalFeature } from "../terminalFeature"; import { resolveServerFeaturePayload } from "./resolveServerFeaturePayload"; @@ -125,6 +126,12 @@ describe("resolveServerFeaturePayload", () => { expect(payload.capabilities.machines.transfer.serverRouted.maxBytes).toBe(2 * 1024 * 1024 * 1024); }); + it("enables channel bridges by default so the experimental UI toggle can appear", () => { + const payload = resolveServerFeaturePayload({} as NodeJS.ProcessEnv, [resolveChannelBridgesFeature]); + expect(payload.features.channelBridges.enabled).toBe(true); + expect(payload.features.channelBridges.telegram.enabled).toBe(true); + }); + it("disables only generic server-routed transfer when the env toggle is off", () => { const payload = resolveServerFeaturePayload({ HAPPIER_FEATURE_MACHINES_TRANSFER_SERVER_ROUTED__ENABLED: "0", diff --git a/apps/server/sources/app/features/catalog/serverFeatureRegistry.ts b/apps/server/sources/app/features/catalog/serverFeatureRegistry.ts index 5ea0fb109f..32177031a4 100644 --- a/apps/server/sources/app/features/catalog/serverFeatureRegistry.ts +++ b/apps/server/sources/app/features/catalog/serverFeatureRegistry.ts @@ -8,6 +8,7 @@ import { resolveFriendsFeature } from '../friendsFeature'; import { resolveOAuthFeature } from '../oauthFeature'; import { resolveAuthFeature } from '../authFeature'; import { resolveConnectedServicesFeature } from '../connectedServicesFeature'; +import { resolveChannelBridgesFeature } from '../channelBridgesFeature'; import { resolveUpdatesFeature } from '../updatesFeature'; import { resolveAttachmentsUploadsFeature } from '../attachmentsUploadsFeature'; import { resolveMachineTransferFeature } from '../machineTransferFeature'; @@ -28,6 +29,7 @@ export const serverFeatureRegistry: readonly ServerFeatureResolver[] = Object.fr (_env) => resolveSharingFeature(), (env) => resolveVoiceFeature(env), (env) => resolveConnectedServicesFeature(env), + (env) => resolveChannelBridgesFeature(env), (env) => resolveUpdatesFeature(env), (env) => resolveAttachmentsUploadsFeature(env), (env) => resolveMachineTransferFeature(env), diff --git a/apps/server/sources/app/features/channelBridgesFeature.ts b/apps/server/sources/app/features/channelBridgesFeature.ts new file mode 100644 index 0000000000..809674c862 --- /dev/null +++ b/apps/server/sources/app/features/channelBridgesFeature.ts @@ -0,0 +1,20 @@ +import type { FeaturesPayloadDelta } from "./types"; + +import { readChannelBridgesFeatureEnv } from "./catalog/readFeatureEnv"; + +export function resolveChannelBridgesFeature(env: NodeJS.ProcessEnv): FeaturesPayloadDelta { + const featureEnv = readChannelBridgesFeatureEnv(env); + + const enabled = featureEnv.enabled; + const telegramEnabled = enabled && featureEnv.telegramEnabled; + + return { + features: { + channelBridges: { + enabled, + telegram: { enabled: telegramEnabled }, + }, + }, + }; +} + From ba4004a1933ffefe6e0c684dbd474f0db9daecf8 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Thu, 26 Mar 2026 15:57:08 +0100 Subject: [PATCH 35/81] feat(ui): add channel bridges experimental toggle --- .../(app)/settings/features.gating.spec.tsx | 26 +++++++++++++++++++ .../domains/features/featureLocalPolicy.ts | 1 + .../features/registry/uiFeatureRegistry.ts | 13 ++++++++++ apps/ui/sources/text/translations/ca.ts | 2 ++ apps/ui/sources/text/translations/en.ts | 2 ++ apps/ui/sources/text/translations/es.ts | 3 +++ apps/ui/sources/text/translations/it.ts | 3 +++ apps/ui/sources/text/translations/ja.ts | 3 +++ apps/ui/sources/text/translations/pl.ts | 3 +++ apps/ui/sources/text/translations/pt.ts | 3 +++ apps/ui/sources/text/translations/ru.ts | 2 ++ apps/ui/sources/text/translations/zh-Hans.ts | 2 ++ apps/ui/sources/text/translations/zh-Hant.ts | 2 ++ 13 files changed, 65 insertions(+) diff --git a/apps/ui/sources/__tests__/routes/(app)/settings/features.gating.spec.tsx b/apps/ui/sources/__tests__/routes/(app)/settings/features.gating.spec.tsx index 5cd845bad1..f2bdcf557a 100644 --- a/apps/ui/sources/__tests__/routes/(app)/settings/features.gating.spec.tsx +++ b/apps/ui/sources/__tests__/routes/(app)/settings/features.gating.spec.tsx @@ -182,6 +182,32 @@ describe('FeaturesSettingsScreen gating', () => { expect(voiceAgentItem).toBeTruthy(); }); + it('shows channel bridges toggle when server supports it', async () => { + process.env.EXPO_PUBLIC_HAPPIER_BUILD_FEATURES_ALLOW = 'channelBridges'; + + useEffectiveServerSelectionMock.mockReturnValue({ serverIds: ['server-1'] }); + useServerFeaturesMainSelectionSnapshotMock.mockReturnValue({ + status: 'ready', + serverIds: ['server-1'], + snapshotsByServerId: { + 'server-1': { + status: 'ready', + features: createRootLayoutFeaturesResponse({ + features: { + channelBridges: { enabled: true, telegram: { enabled: true } }, + }, + }), + }, + }, + }); + + const { default: FeaturesSettingsScreen } = await import('@/app/(app)/settings/features'); + + const screen = await renderSettingsView(React.createElement(FeaturesSettingsScreen)); + const bridgeItem = screen.findRowByTitle('settingsFeatures.expChannelBridges'); + expect(bridgeItem).toBeTruthy(); + }); + it('turning off connectedServices also disables connectedServices.quotas', async () => { vi.resetModules(); const setFeatureToggles = vi.fn(); diff --git a/apps/ui/sources/sync/domains/features/featureLocalPolicy.ts b/apps/ui/sources/sync/domains/features/featureLocalPolicy.ts index 4cf610f883..afcc8bd794 100644 --- a/apps/ui/sources/sync/domains/features/featureLocalPolicy.ts +++ b/apps/ui/sources/sync/domains/features/featureLocalPolicy.ts @@ -13,6 +13,7 @@ const LOCAL_POLICY_BY_FEATURE: Readonly resolveUiFeatureToggleEnabled(settings, 'connectedServices.quotas'), + channelBridges: (settings) => resolveUiFeatureToggleEnabled(settings, 'channelBridges'), 'updates.ota': () => parseBooleanEnv(process.env.EXPO_PUBLIC_HAPPIER_FEATURE_UPDATES_OTA__ENABLED, true), 'attachments.uploads': (settings) => resolveUiFeatureToggleEnabled(settings, 'attachments.uploads'), 'social.friends': (settings) => resolveUiFeatureToggleEnabled(settings, 'social.friends'), diff --git a/apps/ui/sources/sync/domains/features/registry/uiFeatureRegistry.ts b/apps/ui/sources/sync/domains/features/registry/uiFeatureRegistry.ts index 9dc437c551..5df6ecd041 100644 --- a/apps/ui/sources/sync/domains/features/registry/uiFeatureRegistry.ts +++ b/apps/ui/sources/sync/domains/features/registry/uiFeatureRegistry.ts @@ -89,6 +89,19 @@ export const UI_FEATURE_REGISTRY = { icon: { ioniconName: 'analytics-outline', color: '#34C759' }, }, }, + channelBridges: { + settingsToggle: { + showInSettings: true, + isExperimental: true, + defaultEnabled: false, + titleKey: 'settingsFeatures.expChannelBridges', + subtitleKey: 'settingsFeatures.expChannelBridgesSubtitle', + icon: { ioniconName: 'swap-horizontal-outline', color: '#FF9500' }, + }, + }, + 'channelBridges.telegram': { + settingsToggle: undefined, + }, 'updates.ota': { settingsToggle: undefined, }, diff --git a/apps/ui/sources/text/translations/ca.ts b/apps/ui/sources/text/translations/ca.ts index 93bcc02e01..8440fa8b69 100644 --- a/apps/ui/sources/text/translations/ca.ts +++ b/apps/ui/sources/text/translations/ca.ts @@ -2475,6 +2475,8 @@ export const ca: TranslationStructure = { expConnectedServicesSubtitle: 'Activa la configuració de serveis connectats i els vincles de sessió', expConnectedServicesQuotas: 'Quotes de serveis connectats', expConnectedServicesQuotasSubtitle: 'Mostra insígnies de quota i mesuradors d’ús per als serveis connectats', + expChannelBridges: 'Ponts de canals', + expChannelBridgesSubtitle: 'Connecta Telegram i altres canals de xat a sessions de Happier (experimental)', expMemorySearch: 'Cerca de memòria', expMemorySearchSubtitle: 'Activa pantalles i ajustos de cerca de memòria local', expSessionsDirect: 'Sessions directes', diff --git a/apps/ui/sources/text/translations/en.ts b/apps/ui/sources/text/translations/en.ts index 0a47d92549..54d0493b0c 100644 --- a/apps/ui/sources/text/translations/en.ts +++ b/apps/ui/sources/text/translations/en.ts @@ -2570,6 +2570,8 @@ export const en = { expConnectedServicesSubtitle: 'Enable connected services settings and session bindings', expConnectedServicesQuotas: 'Connected services quotas', expConnectedServicesQuotasSubtitle: 'Show quota badges and usage meters for connected services', + expChannelBridges: 'Channel bridges', + expChannelBridgesSubtitle: 'Connect Telegram and other chat channels to Happier sessions (experimental)', expMemorySearch: 'Memory search', expMemorySearchSubtitle: 'Enable local memory search screens and settings', expSessionsDirect: 'Direct sessions', diff --git a/apps/ui/sources/text/translations/es.ts b/apps/ui/sources/text/translations/es.ts index dee4e32271..a031abd379 100644 --- a/apps/ui/sources/text/translations/es.ts +++ b/apps/ui/sources/text/translations/es.ts @@ -2664,6 +2664,9 @@ export const es: TranslationStructure = { expConnectedServicesQuotas: "Cuotas de servicios conectados", expConnectedServicesQuotasSubtitle: "Mostrar insignias de cuota y medidores de uso para servicios conectados", + expChannelBridges: "Puentes de canal", + expChannelBridgesSubtitle: + "Conecta Telegram y otros canales de chat a sesiones de Happier (experimental)", expMemorySearch: "Búsqueda de memoria", expMemorySearchSubtitle: "Habilitar pantallas y ajustes de búsqueda de memoria local", diff --git a/apps/ui/sources/text/translations/it.ts b/apps/ui/sources/text/translations/it.ts index 94f1efe137..781450baf5 100644 --- a/apps/ui/sources/text/translations/it.ts +++ b/apps/ui/sources/text/translations/it.ts @@ -3004,6 +3004,9 @@ export const it: TranslationStructure = { expConnectedServicesQuotas: "Quote servizi connessi", expConnectedServicesQuotasSubtitle: "Mostra badge quota e indicatori di utilizzo per i servizi connessi", + expChannelBridges: "Bridge di canale", + expChannelBridgesSubtitle: + "Collega Telegram e altri canali di chat alle sessioni Happier (sperimentale)", expMemorySearch: "Ricerca memoria", expMemorySearchSubtitle: "Abilita schermate e impostazioni di ricerca memoria locale", diff --git a/apps/ui/sources/text/translations/ja.ts b/apps/ui/sources/text/translations/ja.ts index 050c9095dd..4b3416da07 100644 --- a/apps/ui/sources/text/translations/ja.ts +++ b/apps/ui/sources/text/translations/ja.ts @@ -2943,6 +2943,9 @@ export const ja: TranslationStructure = { expConnectedServicesQuotas: "連携サービスのクォータ", expConnectedServicesQuotasSubtitle: "連携サービスのクォータバッジと使用量メーターを表示", + expChannelBridges: "チャンネルブリッジ", + expChannelBridgesSubtitle: + "Telegram などのチャットチャンネルを Happier セッションに接続(実験的)", expMemorySearch: "メモリ検索", expMemorySearchSubtitle: "ローカルメモリ検索の画面と設定を有効化", expSessionsDirect: "ダイレクトセッション", diff --git a/apps/ui/sources/text/translations/pl.ts b/apps/ui/sources/text/translations/pl.ts index cc9df6978a..d54e1853de 100644 --- a/apps/ui/sources/text/translations/pl.ts +++ b/apps/ui/sources/text/translations/pl.ts @@ -2697,6 +2697,9 @@ export const pl: TranslationStructure = { expConnectedServicesQuotas: "Limity połączonych usług", expConnectedServicesQuotasSubtitle: "Pokaż odznaki limitów i wskaźniki użycia dla połączonych usług", + expChannelBridges: "Mosty kanałów", + expChannelBridgesSubtitle: + "Połącz Telegram i inne kanały czatu z sesjami Happier (eksperymentalne)", expMemorySearch: "Wyszukiwanie pamięci", expMemorySearchSubtitle: "Włącz ekrany i ustawienia lokalnego wyszukiwania pamięci", diff --git a/apps/ui/sources/text/translations/pt.ts b/apps/ui/sources/text/translations/pt.ts index fc5552521c..3ef5280778 100644 --- a/apps/ui/sources/text/translations/pt.ts +++ b/apps/ui/sources/text/translations/pt.ts @@ -2889,6 +2889,9 @@ export const pt: TranslationStructure = { expConnectedServicesQuotas: "Cotas de serviços conectados", expConnectedServicesQuotasSubtitle: "Mostrar badges de cota e medidores de uso para serviços conectados", + expChannelBridges: "Pontes de canal", + expChannelBridgesSubtitle: + "Conecte Telegram e outros canais de chat a sessões do Happier (experimental)", expMemorySearch: "Busca de memória", expMemorySearchSubtitle: "Ativar telas e configurações de busca de memória local", diff --git a/apps/ui/sources/text/translations/ru.ts b/apps/ui/sources/text/translations/ru.ts index 14ddc907af..836744df34 100644 --- a/apps/ui/sources/text/translations/ru.ts +++ b/apps/ui/sources/text/translations/ru.ts @@ -2642,6 +2642,8 @@ export const ru: TranslationStructure = { expConnectedServicesSubtitle: "Включить настройки подключённых сервисов и привязку к сессиям", expConnectedServicesQuotas: "Квоты подключённых сервисов", expConnectedServicesQuotasSubtitle: "Показывать бейджи квот и счётчики использования подключённых сервисов", + expChannelBridges: "Мосты каналов", + expChannelBridgesSubtitle: "Подключайте Telegram и другие чаты к сессиям Happier (экспериментально)", expMemorySearch: "Поиск по памяти", expMemorySearchSubtitle: "Включить экраны и настройки локального поиска по памяти", expSessionsDirect: "Прямые сессии", diff --git a/apps/ui/sources/text/translations/zh-Hans.ts b/apps/ui/sources/text/translations/zh-Hans.ts index e64b80b4c7..afa32f1428 100644 --- a/apps/ui/sources/text/translations/zh-Hans.ts +++ b/apps/ui/sources/text/translations/zh-Hans.ts @@ -2543,6 +2543,8 @@ export const zhHans: TranslationStructure = { expConnectedServicesSubtitle: "启用已连接服务设置与会话绑定", expConnectedServicesQuotas: "已连接服务配额", expConnectedServicesQuotasSubtitle: "显示已连接服务的配额徽标与用量仪表", + expChannelBridges: "频道桥接", + expChannelBridgesSubtitle: "将 Telegram 等聊天渠道连接到 Happier 会话(实验性)", expMemorySearch: "记忆搜索", expMemorySearchSubtitle: "启用本地记忆搜索页面与设置", expSessionsDirect: "直连会话", diff --git a/apps/ui/sources/text/translations/zh-Hant.ts b/apps/ui/sources/text/translations/zh-Hant.ts index 6e11185f09..cc50c1fcfd 100644 --- a/apps/ui/sources/text/translations/zh-Hant.ts +++ b/apps/ui/sources/text/translations/zh-Hant.ts @@ -2246,6 +2246,8 @@ const zhHantOverrides: DeepPartial = { expConnectedServicesSubtitle: '啟用已連結服務設定與工作階段綁定', expConnectedServicesQuotas: '已連結服務配額', expConnectedServicesQuotasSubtitle: '顯示已連結服務的配額徽章與用量儀表', + expChannelBridges: '頻道橋接', + expChannelBridgesSubtitle: '將 Telegram 等聊天頻道連接到 Happier 工作階段(實驗性)', expMemorySearch: '記憶搜尋', expMemorySearchSubtitle: '啟用本機記憶搜尋頁面與設定', expSessionsDirect: '直接工作階段', From c72f70f3ad3f42e34f62416b4e5e1c60ea27dff2 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Thu, 26 Mar 2026 16:22:12 +0100 Subject: [PATCH 36/81] chore(cli): migrate channel bridge session transport imports --- .../cli/src/channels/channelBridgeServerKv.ts | 2 +- .../src/channels/core/channelBridgeWorker.ts | 6 +- .../channels/startChannelBridgeWorker.test.ts | 78 +++++++------------ .../src/channels/startChannelBridgeWorker.ts | 8 +- 4 files changed, 40 insertions(+), 54 deletions(-) diff --git a/apps/cli/src/channels/channelBridgeServerKv.ts b/apps/cli/src/channels/channelBridgeServerKv.ts index a58ff8538c..980d123eba 100644 --- a/apps/cli/src/channels/channelBridgeServerKv.ts +++ b/apps/cli/src/channels/channelBridgeServerKv.ts @@ -9,7 +9,7 @@ */ import axios from 'axios'; -import { resolveServerHttpBaseUrl } from '@/sessionControl/serverHttpBaseUrl'; +import { resolveServerHttpBaseUrl } from '@/session/transport/http/serverHttpBaseUrl'; import { isLoopbackHost } from '@/channels/telegram/telegramWebhookRelay'; import type { ScopedTelegramBridgeUpdate } from './channelBridgeAccountConfig'; diff --git a/apps/cli/src/channels/core/channelBridgeWorker.ts b/apps/cli/src/channels/core/channelBridgeWorker.ts index 997bf0f314..c5bb2a93d2 100644 --- a/apps/cli/src/channels/core/channelBridgeWorker.ts +++ b/apps/cli/src/channels/core/channelBridgeWorker.ts @@ -292,7 +292,11 @@ export function createChannelBridgeInboundDeduper(now: () => number = () => Date function normalizeAgentFetchResult( fetched: readonly ChannelBridgeAgentMessageRow[] | ChannelBridgeAgentFetchResult, ): Readonly<{ messages: readonly ChannelBridgeAgentMessageRow[]; highestSeenSeq: number | null }> { - if (Array.isArray(fetched)) { + const isMessageRowArray = ( + value: readonly ChannelBridgeAgentMessageRow[] | ChannelBridgeAgentFetchResult, + ): value is readonly ChannelBridgeAgentMessageRow[] => Array.isArray(value); + + if (isMessageRowArray(fetched)) { return { messages: fetched, highestSeenSeq: null, diff --git a/apps/cli/src/channels/startChannelBridgeWorker.test.ts b/apps/cli/src/channels/startChannelBridgeWorker.test.ts index d962184345..352905ce8b 100644 --- a/apps/cli/src/channels/startChannelBridgeWorker.test.ts +++ b/apps/cli/src/channels/startChannelBridgeWorker.test.ts @@ -1,6 +1,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { startChannelBridgeFromEnv } from './startChannelBridgeWorker'; +import type { ChannelBridgeDeps } from './core/channelBridgeWorker'; + +type SendCommittedFn = typeof import('@/session/transport/socket/sessionSocketSendMessage').sendSessionMessageViaSocketCommitted; const credentials = { token: 'token-1', @@ -16,9 +19,9 @@ afterEach(() => { vi.resetModules(); for (const modulePath of [ - '@/sessionControl/sessionsHttp', + '@/session/transport/http/sessionsHttp', '@/api/session/fetchEncryptedTranscriptWindow', - '@/sessionControl/sessionSocketSendMessage', + '@/session/transport/socket/sessionSocketSendMessage', '@/session/replay/decryptTranscriptRows', '@/ui/logger', './core/channelBridgeWorker', @@ -87,12 +90,10 @@ describe('startChannelBridgeFromEnv', () => { hasNext: false, }); - let capturedDeps: { - listSessions: () => Promise>; - } | null = null; + let capturedDeps: ChannelBridgeDeps | null = null; - vi.doMock('@/sessionControl/sessionsHttp', async (importOriginal) => { - const actual = await importOriginal(); + vi.doMock('@/session/transport/http/sessionsHttp', async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, fetchSessionsPage, @@ -115,8 +116,8 @@ describe('startChannelBridgeFromEnv', () => { updateLastForwardedSeq: async () => true, removeBinding: async () => false, })), - startChannelBridgeWorker: vi.fn((params: { deps: typeof capturedDeps }) => { - capturedDeps = params.deps as typeof capturedDeps; + startChannelBridgeWorker: vi.fn((params: { deps: ChannelBridgeDeps }) => { + capturedDeps = params.deps; return { trigger: vi.fn(), stop: vi.fn(async () => undefined), @@ -169,22 +170,12 @@ describe('startChannelBridgeFromEnv', () => { dataEncryptionKey: null, })); const fetchAfterSeq = vi.fn(async () => []); - const sendCommitted = vi.fn(async () => undefined); + const sendCommitted = vi.fn(async (_params) => undefined); - let capturedDeps: { - sendUserMessageToSession: (params: { - sessionId: string; - text: string; - sentFrom: string; - providerId: string; - conversationId: string; - threadId: string | null; - }) => Promise; - fetchAgentMessagesAfterSeq: (params: { sessionId: string; afterSeq: number }) => Promise; - } | null = null; + let capturedDeps: ChannelBridgeDeps | null = null; - vi.doMock('@/sessionControl/sessionsHttp', async (importOriginal) => { - const actual = await importOriginal(); + vi.doMock('@/session/transport/http/sessionsHttp', async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, fetchSessionById, @@ -199,7 +190,7 @@ describe('startChannelBridgeFromEnv', () => { }; }); - vi.doMock('@/sessionControl/sessionSocketSendMessage', () => ({ + vi.doMock('@/session/transport/socket/sessionSocketSendMessage', () => ({ sendSessionMessageViaSocketCommitted: sendCommitted, })); @@ -323,8 +314,8 @@ describe('startChannelBridgeFromEnv', () => { fetchAgentMessagesAfterSeq: (params: { sessionId: string; afterSeq: number }) => Promise; } | null = null; - vi.doMock('@/sessionControl/sessionsHttp', async (importOriginal) => { - const actual = await importOriginal(); + vi.doMock('@/session/transport/http/sessionsHttp', async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, fetchSessionById, @@ -418,7 +409,7 @@ describe('startChannelBridgeFromEnv', () => { metadata: null, dataEncryptionKey: null, })); - const sendCommitted = vi.fn(async () => undefined); + const sendCommitted = vi.fn(async (_params) => undefined); let capturedDeps: { sendUserMessageToSession: (params: { @@ -432,15 +423,15 @@ describe('startChannelBridgeFromEnv', () => { }) => Promise; } | null = null; - vi.doMock('@/sessionControl/sessionsHttp', async (importOriginal) => { - const actual = await importOriginal(); + vi.doMock('@/session/transport/http/sessionsHttp', async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, fetchSessionById, }; }); - vi.doMock('@/sessionControl/sessionSocketSendMessage', () => ({ + vi.doMock('@/session/transport/socket/sessionSocketSendMessage', () => ({ sendSessionMessageViaSocketCommitted: sendCommitted, })); @@ -590,8 +581,8 @@ describe('startChannelBridgeFromEnv', () => { }, })); - vi.doMock('@/sessionControl/sessionsHttp', async (importOriginal) => { - const actual = await importOriginal(); + vi.doMock('@/session/transport/http/sessionsHttp', async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, fetchSessionById, @@ -678,28 +669,19 @@ describe('startChannelBridgeFromEnv', () => { metadata: null, dataEncryptionKey: null, })); - const sendCommitted = vi.fn(async () => undefined); + const sendCommitted = vi.fn(async (_params) => undefined); - let capturedDeps: { - sendUserMessageToSession: (params: { - sessionId: string; - text: string; - sentFrom: string; - providerId: string; - conversationId: string; - threadId: string | null; - }) => Promise; - } | null = null; + let capturedDeps: ChannelBridgeDeps | null = null; - vi.doMock('@/sessionControl/sessionsHttp', async (importOriginal) => { - const actual = await importOriginal(); + vi.doMock('@/session/transport/http/sessionsHttp', async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, fetchSessionById, }; }); - vi.doMock('@/sessionControl/sessionSocketSendMessage', () => ({ + vi.doMock('@/session/transport/socket/sessionSocketSendMessage', () => ({ sendSessionMessageViaSocketCommitted: sendCommitted, })); @@ -719,8 +701,8 @@ describe('startChannelBridgeFromEnv', () => { updateLastForwardedSeq: async () => true, removeBinding: async () => false, })), - startChannelBridgeWorker: vi.fn((params: { deps: typeof capturedDeps }) => { - capturedDeps = params.deps as typeof capturedDeps; + startChannelBridgeWorker: vi.fn((params: { deps: ChannelBridgeDeps }) => { + capturedDeps = params.deps; return { trigger: vi.fn(), stop: vi.fn(async () => undefined), diff --git a/apps/cli/src/channels/startChannelBridgeWorker.ts b/apps/cli/src/channels/startChannelBridgeWorker.ts index 3fe08fbbed..4c7527238b 100644 --- a/apps/cli/src/channels/startChannelBridgeWorker.ts +++ b/apps/cli/src/channels/startChannelBridgeWorker.ts @@ -22,16 +22,16 @@ import { serializeAxiosErrorForLog } from '@/api/client/serializeAxiosErrorForLo import { fetchEncryptedTranscriptPageAfterSeq, fetchEncryptedTranscriptPageLatest } from '@/api/session/fetchEncryptedTranscriptWindow'; import type { Credentials } from '@/persistence'; import { decryptTranscriptRows } from '@/session/replay/decryptTranscriptRows'; -import { resolveSessionIdOrPrefix } from '@/sessionControl/resolveSessionId'; +import { resolveSessionIdOrPrefix } from '@/session/query/resolveSessionId'; import { encryptSessionPayload, resolveSessionEncryptionContextFromCredentials, resolveSessionStoredContentEncryptionMode, tryDecryptSessionMetadata, type SessionEncryptionContext, -} from '@/sessionControl/sessionEncryptionContext'; -import { sendSessionMessageViaSocketCommitted } from '@/sessionControl/sessionSocketSendMessage'; -import { fetchSessionById, fetchSessionsPage, type RawSessionListRow } from '@/sessionControl/sessionsHttp'; +} from '@/session/transport/encryption/sessionEncryptionContext'; +import { sendSessionMessageViaSocketCommitted } from '@/session/transport/socket/sessionSocketSendMessage'; +import { fetchSessionById, fetchSessionsPage, type RawSessionListRow } from '@/session/transport/http/sessionsHttp'; import { logger } from '@/ui/logger'; import { From bbdfd7c7e31da0261d4914acc3c4eb56e7200d88 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Thu, 26 Mar 2026 16:22:21 +0100 Subject: [PATCH 37/81] feat(cli): gate channel bridge daemon startup --- ...sDaemonEnabled.feat.channelBridges.test.ts | 112 ++++++++++++++++++ .../resolveChannelBridgesDaemonEnabled.ts | 17 +++ ...startDaemon.automation.integration.test.ts | 38 +++++- apps/cli/src/daemon/startDaemon.ts | 26 +++- apps/cli/src/features/featureLocalPolicy.ts | 2 + 5 files changed, 184 insertions(+), 11 deletions(-) create mode 100644 apps/cli/src/daemon/channels/resolveChannelBridgesDaemonEnabled.feat.channelBridges.test.ts create mode 100644 apps/cli/src/daemon/channels/resolveChannelBridgesDaemonEnabled.ts diff --git a/apps/cli/src/daemon/channels/resolveChannelBridgesDaemonEnabled.feat.channelBridges.test.ts b/apps/cli/src/daemon/channels/resolveChannelBridgesDaemonEnabled.feat.channelBridges.test.ts new file mode 100644 index 0000000000..309a8ead9e --- /dev/null +++ b/apps/cli/src/daemon/channels/resolveChannelBridgesDaemonEnabled.feat.channelBridges.test.ts @@ -0,0 +1,112 @@ +import { describe, expect, it, vi } from 'vitest'; +import { FeaturesResponseSchema } from '@happier-dev/protocol'; + +import { resolveChannelBridgesDaemonEnabled } from './resolveChannelBridgesDaemonEnabled'; + +describe('resolveChannelBridgesDaemonEnabled', () => { + it('returns false when the server reports channel bridges disabled', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => + FeaturesResponseSchema.parse({ + features: { + channelBridges: { enabled: false, telegram: { enabled: true } }, + }, + capabilities: {}, + }), + })) as unknown as typeof fetch, + ); + + const enabled = await resolveChannelBridgesDaemonEnabled({ + env: { HAPPIER_FEATURE_CHANNEL_BRIDGES__ENABLED: '1', HAPPIER_FEATURE_CHANNEL_BRIDGES_TELEGRAM__ENABLED: '1' }, + serverUrl: 'https://api.example.test', + timeoutMs: 100, + }); + + expect(enabled).toBe(false); + }); + + it('returns true when server reports telegram bridge enabled and build policy does not deny it', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => + FeaturesResponseSchema.parse({ + features: { + channelBridges: { enabled: true, telegram: { enabled: true } }, + }, + capabilities: {}, + }), + })) as unknown as typeof fetch, + ); + + const enabled = await resolveChannelBridgesDaemonEnabled({ + env: { HAPPIER_FEATURE_CHANNEL_BRIDGES__ENABLED: '1', HAPPIER_FEATURE_CHANNEL_BRIDGES_TELEGRAM__ENABLED: '1' }, + serverUrl: 'https://api.example.test', + timeoutMs: 100, + }); + + expect(enabled).toBe(true); + }); + + it('does not enable channel bridges when build policy denies the feature', async () => { + const fetchMock = vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => + FeaturesResponseSchema.parse({ + features: { + channelBridges: { enabled: true, telegram: { enabled: true } }, + }, + capabilities: {}, + }), + })); + vi.stubGlobal('fetch', fetchMock as any); + + const enabled = await resolveChannelBridgesDaemonEnabled({ + env: { + HAPPIER_BUILD_FEATURES_DENY: 'channelBridges.telegram', + HAPPIER_FEATURE_CHANNEL_BRIDGES__ENABLED: '1', + HAPPIER_FEATURE_CHANNEL_BRIDGES_TELEGRAM__ENABLED: '1', + }, + serverUrl: 'https://api.example.test', + timeoutMs: 100, + }); + + expect(enabled).toBe(false); + expect(fetchMock).not.toHaveBeenCalled(); + }); + + it('does not probe the server when local policy disables channel bridges', async () => { + const fetchMock = vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => + FeaturesResponseSchema.parse({ + features: { + channelBridges: { enabled: true, telegram: { enabled: true } }, + }, + capabilities: {}, + }), + })); + vi.stubGlobal('fetch', fetchMock as any); + + const enabled = await resolveChannelBridgesDaemonEnabled({ + env: { + HAPPIER_FEATURE_CHANNEL_BRIDGES__ENABLED: '1', + HAPPIER_FEATURE_CHANNEL_BRIDGES_TELEGRAM__ENABLED: '0', + }, + serverUrl: 'https://api.example.test', + timeoutMs: 100, + }); + + expect(enabled).toBe(false); + expect(fetchMock).not.toHaveBeenCalled(); + }); +}); + diff --git a/apps/cli/src/daemon/channels/resolveChannelBridgesDaemonEnabled.ts b/apps/cli/src/daemon/channels/resolveChannelBridgesDaemonEnabled.ts new file mode 100644 index 0000000000..2e6e408980 --- /dev/null +++ b/apps/cli/src/daemon/channels/resolveChannelBridgesDaemonEnabled.ts @@ -0,0 +1,17 @@ +import { resolveCliFeatureDecisionForServer } from '@/features/featureDecisionService'; + +export async function resolveChannelBridgesDaemonEnabled(params: { + env: NodeJS.ProcessEnv; + serverUrl: string; + timeoutMs?: number; +}): Promise { + const resolved = await resolveCliFeatureDecisionForServer({ + featureId: 'channelBridges.telegram', + env: params.env, + serverUrl: params.serverUrl, + timeoutMs: params.timeoutMs, + }); + + return resolved.decision.state === 'enabled'; +} + diff --git a/apps/cli/src/daemon/startDaemon.automation.integration.test.ts b/apps/cli/src/daemon/startDaemon.automation.integration.test.ts index 21f921a1be..08cad683f7 100644 --- a/apps/cli/src/daemon/startDaemon.automation.integration.test.ts +++ b/apps/cli/src/daemon/startDaemon.automation.integration.test.ts @@ -37,6 +37,8 @@ const harness = vi.hoisted(() => { resume: connectedServiceQuotasResume, })); + const resolveChannelBridgesDaemonEnabled = vi.fn(async () => true); + const apiMachine = { setRPCHandlers: vi.fn(), onUpdate: vi.fn(), @@ -88,11 +90,15 @@ const harness = vi.hoisted(() => { connectedServiceQuotasResume, connectedServiceQuotasStop, createDaemonShutdownController, +<<<<<<< HEAD +======= + startChannelBridgeFromEnv, + resolveChannelBridgesDaemonEnabled, +>>>>>>> ed44ae300 (feat(cli): gate channel bridge daemon startup) emitMachineConnectionState: (state: any) => machineConnectionStateListener?.(state), setAutoShutdownAfterAutomationStart: (value: boolean) => { autoShutdownAfterAutomationStart = value; }, - startChannelBridgeFromEnv, requestShutdown: (source: ShutdownSource) => requestShutdownRef?.(source), }; }); @@ -101,6 +107,10 @@ vi.mock('@/channels/startChannelBridgeWorker', () => ({ startChannelBridgeFromEnv: harness.startChannelBridgeFromEnv, })); +vi.mock('./channels/resolveChannelBridgesDaemonEnabled', () => ({ + resolveChannelBridgesDaemonEnabled: harness.resolveChannelBridgesDaemonEnabled, +})); + vi.mock('@/api/api', () => ({ ApiClient: { create: vi.fn(async () => ({ @@ -486,15 +496,31 @@ describe('startDaemon automation wiring (integration)', () => { } }); + it('does not start channel bridge worker when channel bridges are disabled', async () => { + const exitSpy = vi.spyOn(process, 'exit').mockImplementation((() => undefined) as never); + harness.resolveChannelBridgesDaemonEnabled.mockResolvedValueOnce(false); + + try { + const { startDaemon } = await import('./startDaemon'); + await startDaemon(); + + expect(harness.startChannelBridgeFromEnv).not.toHaveBeenCalled(); + expect(exitSpy).toHaveBeenCalledWith(0); + } finally { + exitSpy.mockRestore(); + } + }); + it('stops a bridge worker that resolves after shutdown has already started', async () => { vi.useRealTimers(); const delayedWorkerStop = vi.fn(async () => {}); - let resolveBridgeStartup: (() => void) | null = null; + let resolveBridgeStartup!: () => void; + const bridgeStartup = new Promise((resolve) => { + resolveBridgeStartup = resolve; + }); harness.startChannelBridgeFromEnv.mockImplementationOnce(async () => { - await new Promise((resolve) => { - resolveBridgeStartup = resolve; - }); + await bridgeStartup; return { stop: delayedWorkerStop, trigger: vi.fn(), @@ -509,7 +535,7 @@ describe('startDaemon automation wiring (integration)', () => { await new Promise((resolve) => setTimeout(resolve, 0)); harness.requestShutdown('happier-cli'); - if (resolveBridgeStartup) resolveBridgeStartup(); + resolveBridgeStartup(); await run; expect(harness.startChannelBridgeFromEnv).toHaveBeenCalledTimes(1); diff --git a/apps/cli/src/daemon/startDaemon.ts b/apps/cli/src/daemon/startDaemon.ts index 3dc2287cef..cd4f6798f8 100644 --- a/apps/cli/src/daemon/startDaemon.ts +++ b/apps/cli/src/daemon/startDaemon.ts @@ -101,6 +101,7 @@ import { ConnectedServiceQuotasCoordinator } from './connectedServices/quotas/Co import { createConnectedServiceQuotaFetchers } from './connectedServices/quotas/createConnectedServiceQuotaFetchers'; import { resolveConnectedServiceQuotasDaemonOptions } from './connectedServices/quotas/resolveConnectedServiceQuotasDaemonOptions'; import { resolveConnectedServicesQuotasDaemonEnabled } from './connectedServices/quotas/resolveConnectedServicesQuotasDaemonEnabled'; +import { resolveChannelBridgesDaemonEnabled } from './channels/resolveChannelBridgesDaemonEnabled'; import { startConnectedServiceQuotasLoop, type ConnectedServiceQuotasLoopHandle } from './connectedServices/quotas/startConnectedServiceQuotasLoop'; import { HAPPIER_DAEMON_INITIAL_PROMPT_ENV_KEY, @@ -1527,11 +1528,26 @@ export async function startDaemon(): Promise { // Do machine bootstrap in the background so shutdown requests are not blocked by /v1/machines latency. void (async () => { - const startChannelBridgeWorkerBestEffort = async (): Promise => { - const bridgeSettings = await readSettings().catch((error) => { - logger.warn( - '[DAEMON RUN] Failed to read settings for channel bridge startup; using env-only defaults', - error instanceof Error ? error.message : String(error), + const startChannelBridgeWorkerBestEffort = async (): Promise => { + const channelBridgesEnabled = await resolveChannelBridgesDaemonEnabled({ + env: process.env, + serverUrl: configuration.serverUrl, + timeoutMs: 1500, + }).catch((error) => { + logger.warn( + '[DAEMON RUN] Failed to resolve channel bridge feature gating; skipping channel bridge startup', + serializeAxiosErrorForLog(error), + ); + return false; + }); + if (!channelBridgesEnabled || shutdownInitiated) { + return; + } + + const bridgeSettings = await readSettings().catch((error) => { + logger.warn( + '[DAEMON RUN] Failed to read settings for channel bridge startup; using env-only defaults', + error instanceof Error ? error.message : String(error), ); return null; }); diff --git a/apps/cli/src/features/featureLocalPolicy.ts b/apps/cli/src/features/featureLocalPolicy.ts index 2718913cce..a9bd0b3e3f 100644 --- a/apps/cli/src/features/featureLocalPolicy.ts +++ b/apps/cli/src/features/featureLocalPolicy.ts @@ -9,6 +9,8 @@ const LOCAL_POLICY_BY_FEATURE: Readonly parseBooleanEnv(env.HAPPIER_FEATURE_VOICE__ENABLED, true), connectedServices: (env) => parseBooleanEnv(env.HAPPIER_FEATURE_CONNECTED_SERVICES__ENABLED, true), 'connectedServices.quotas': (env) => parseBooleanEnv(env.HAPPIER_FEATURE_CONNECTED_SERVICES_QUOTAS__ENABLED, true), + channelBridges: (env) => parseBooleanEnv(env.HAPPIER_FEATURE_CHANNEL_BRIDGES__ENABLED, true), + 'channelBridges.telegram': (env) => parseBooleanEnv(env.HAPPIER_FEATURE_CHANNEL_BRIDGES_TELEGRAM__ENABLED, true), }; export function resolveCliLocalFeaturePolicyEnabled(featureId: FeatureId, env: NodeJS.ProcessEnv): boolean { From c3a1bf527a2da7eeb29a2a8bdf4e3b6148b741d9 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Thu, 26 Mar 2026 18:52:20 +0100 Subject: [PATCH 38/81] refactor(cli): unify channel bridges runtime and local bindings --- .../client/serializeAxiosErrorForLog.test.ts | 14 +- .../api/client/serializeAxiosErrorForLog.ts | 19 +- .../channelBindingStore.server.test.ts | 689 ---------- .../channels/channelBindingStore.server.ts | 287 ---- .../channels/channelBridgeAccountConfig.ts | 16 + .../src/channels/channelBridgeConfig.test.ts | 14 + apps/cli/src/channels/channelBridgeConfig.ts | 20 +- .../channelBridgeServerConfigOverlay.test.ts | 109 -- .../channelBridgeServerConfigOverlay.ts | 50 - .../channels/channelBridgeServerKv.test.ts | 1191 ----------------- .../cli/src/channels/channelBridgeServerKv.ts | 768 ----------- .../channels/core/channelBridgeWorker.test.ts | 240 +++- .../src/channels/core/channelBridgeWorker.ts | 249 +++- .../channelBridgeProviderRegistry.ts | 26 + .../telegramChannelBridgeProvider.ts | 103 ++ .../src/channels/providers/_registry/types.ts | 17 + .../telegram/telegramAdapter.test.ts | 81 ++ .../telegram/telegramAdapter.ts | 32 +- .../telegram/telegramWebhookRelay.test.ts | 0 .../telegram/telegramWebhookRelay.ts | 19 +- .../runtime/startChannelBridgeRuntime.ts | 76 ++ ...ChannelBridgeWorker.startupFailure.test.ts | 43 +- .../channels/startChannelBridgeWorker.test.ts | 256 ++-- .../src/channels/startChannelBridgeWorker.ts | 397 +++--- .../channels/state/localBindingStore.test.ts | 97 ++ .../src/channels/state/localBindingStore.ts | 309 +++++ apps/cli/src/cli/commands/bridge.test.ts | 294 +--- apps/cli/src/cli/commands/bridge.ts | 270 ++-- ...sDaemonEnabled.feat.channelBridges.test.ts | 33 +- .../resolveChannelBridgesDaemonEnabled.ts | 13 +- apps/cli/src/daemon/startDaemon.ts | 70 +- .../src/features/settingsFeatureToggles.ts | 38 + .../server/serverUrlClassification.test.ts | 12 + apps/cli/src/ui/doctor.ts | 174 +-- .../cli/src/ui/doctor/channelBridgesDoctor.ts | 137 ++ 35 files changed, 1980 insertions(+), 4183 deletions(-) delete mode 100644 apps/cli/src/channels/channelBindingStore.server.test.ts delete mode 100644 apps/cli/src/channels/channelBindingStore.server.ts delete mode 100644 apps/cli/src/channels/channelBridgeServerConfigOverlay.test.ts delete mode 100644 apps/cli/src/channels/channelBridgeServerConfigOverlay.ts delete mode 100644 apps/cli/src/channels/channelBridgeServerKv.test.ts delete mode 100644 apps/cli/src/channels/channelBridgeServerKv.ts create mode 100644 apps/cli/src/channels/providers/_registry/channelBridgeProviderRegistry.ts create mode 100644 apps/cli/src/channels/providers/_registry/telegramChannelBridgeProvider.ts create mode 100644 apps/cli/src/channels/providers/_registry/types.ts rename apps/cli/src/channels/{ => providers}/telegram/telegramAdapter.test.ts (84%) rename apps/cli/src/channels/{ => providers}/telegram/telegramAdapter.ts (93%) rename apps/cli/src/channels/{ => providers}/telegram/telegramWebhookRelay.test.ts (100%) rename apps/cli/src/channels/{ => providers}/telegram/telegramWebhookRelay.ts (89%) create mode 100644 apps/cli/src/channels/runtime/startChannelBridgeRuntime.ts create mode 100644 apps/cli/src/channels/state/localBindingStore.test.ts create mode 100644 apps/cli/src/channels/state/localBindingStore.ts create mode 100644 apps/cli/src/features/settingsFeatureToggles.ts create mode 100644 apps/cli/src/ui/doctor/channelBridgesDoctor.ts diff --git a/apps/cli/src/api/client/serializeAxiosErrorForLog.test.ts b/apps/cli/src/api/client/serializeAxiosErrorForLog.test.ts index 785d7b951b..61ffdea49a 100644 --- a/apps/cli/src/api/client/serializeAxiosErrorForLog.test.ts +++ b/apps/cli/src/api/client/serializeAxiosErrorForLog.test.ts @@ -24,5 +24,17 @@ describe('serializeAxiosErrorForLog', () => { expect(serialized).not.toHaveProperty('headers'); expect(serialized).not.toHaveProperty('data'); }); -}); + it('redacts Telegram bot tokens embedded in URL paths', () => { + const err = new AxiosError('boom', 'ECONNABORTED', { + method: 'get', + url: 'https://api.telegram.org/bot123456789:AAABBBccc___-123/getUpdates?offset=123', + headers: { Authorization: 'Bearer SECRET', 'Content-Type': 'application/json' }, + } as any); + + const serialized = serializeAxiosErrorForLog(err); + expect(serialized).toEqual(expect.objectContaining({ + url: 'https://api.telegram.org/bot/getUpdates', + })); + }); +}); diff --git a/apps/cli/src/api/client/serializeAxiosErrorForLog.ts b/apps/cli/src/api/client/serializeAxiosErrorForLog.ts index de2e3af0d5..415eb9389a 100644 --- a/apps/cli/src/api/client/serializeAxiosErrorForLog.ts +++ b/apps/cli/src/api/client/serializeAxiosErrorForLog.ts @@ -1,17 +1,33 @@ import axios from 'axios'; +function redactTelegramBotTokenPathname(pathname: string): string { + const raw = String(pathname ?? ''); + if (!raw) return raw; + const parts = raw.split('/'); + const redacted = parts.map((part) => { + const segment = String(part ?? ''); + if (segment.startsWith('bot') && segment.length > 3) { + return 'bot'; + } + return segment; + }); + return redacted.join('/'); +} + function redactUrlForLog(raw: unknown): string | undefined { if (typeof raw !== 'string') return undefined; const value = raw.trim(); if (!value) return undefined; try { const parsed = new URL(value); + parsed.pathname = redactTelegramBotTokenPathname(parsed.pathname); parsed.search = ''; parsed.hash = ''; return parsed.toString(); } catch { // Best-effort: strip query/hash to avoid leaking secrets in URLs. - return value.split('?')[0]?.split('#')[0]; + const withoutQuery = value.split('?')[0]?.split('#')[0] ?? value; + return withoutQuery.replace(/\/bot[^/]+\//g, '/bot/'); } } @@ -34,4 +50,3 @@ export function serializeAxiosErrorForLog(error: unknown): Record number; -}> { - const byKey = new Map(); - let mutateCalls = 0; - - const kv: ChannelBridgeKvClient = { - get: async (key) => { - const row = byKey.get(key); - if (!row || row.value === null) { - return { status: 404, body: { error: 'Key not found' } }; - } - return { - status: 200, - body: { - key, - value: row.value, - version: row.version, - }, - }; - }, - mutate: async (mutations) => { - mutateCalls += 1; - const errors: Array<{ key: string; error: 'version-mismatch'; version: number; value: string | null }> = []; - for (const mutation of mutations) { - const row = byKey.get(mutation.key); - const currentVersion = row?.version ?? -1; - if (currentVersion !== mutation.version) { - errors.push({ - key: mutation.key, - error: 'version-mismatch', - version: currentVersion, - value: row?.value ?? null, - }); - } - } - - if (errors.length > 0) { - return { status: 409, body: { success: false, errors } }; - } - - const results: Array<{ key: string; version: number }> = []; - for (const mutation of mutations) { - const row = byKey.get(mutation.key); - const nextVersion = (row?.version ?? -1) + 1; - byKey.set(mutation.key, { - value: mutation.value, - version: nextVersion, - }); - results.push({ key: mutation.key, version: nextVersion }); - } - - return { status: 200, body: { success: true, results } }; - }, - }; - - return { - kv, - mutateCallCount: () => mutateCalls, - }; -} - -function createInMemoryKvClient(): ChannelBridgeKvClient { - return createStandardInMemoryKvState().kv; -} - -function createCountingKvClient(): Readonly<{ - kv: ChannelBridgeKvClient; - mutateCallCount: () => number; -}> { - return createStandardInMemoryKvState(); -} - -function createConflictPayloadKvClient(): Readonly<{ - kv: ChannelBridgeKvClient; - mutateCallCount: () => number; - getCallCount: () => number; -}> { - let getCalls = 0; - let mutateCalls = 0; - let currentVersion = -1; - let storedValue: string | null = null; - - const kv: ChannelBridgeKvClient = { - get: async (key) => { - getCalls += 1; - if (storedValue === null) { - return { status: 404, body: { error: 'Key not found' } }; - } - return { - status: 200, - body: { - key, - value: storedValue, - version: currentVersion, - }, - }; - }, - mutate: async (mutations) => { - mutateCalls += 1; - const [mutation] = mutations; - if (!mutation) { - return { status: 400, body: { success: false, errors: [{ key: 'missing', error: 'version-mismatch', version: currentVersion, value: storedValue }] } }; - } - - if (mutateCalls === 1) { - return { - status: 409, - body: { - success: false, - errors: [ - { - key: mutation.key, - error: 'version-mismatch', - version: currentVersion, - value: 'this-is-not-valid-base64', - }, - ], - }, - }; - } - - currentVersion += 1; - storedValue = mutation.value; - return { - status: 200, - body: { - success: true, - results: [ - { - key: mutation.key, - version: currentVersion, - }, - ], - }, - }; - }, - }; - - return { - kv, - getCallCount: () => getCalls, - mutateCallCount: () => mutateCalls, - }; -} - -function createValidConflictPayloadKvClient(): Readonly<{ - kv: ChannelBridgeKvClient; - mutateCallCount: () => number; - getCallCount: () => number; -}> { - let getCalls = 0; - let mutateCalls = 0; - let currentVersion = 0; - let storedValue = Buffer.from(JSON.stringify({ - schemaVersion: 1, - bindings: [], - }), 'utf8').toString('base64'); - - const conflictValue = Buffer.from(JSON.stringify({ - schemaVersion: 1, - bindings: [], - }), 'utf8').toString('base64'); - - const kv: ChannelBridgeKvClient = { - get: async (key) => { - getCalls += 1; - return { - status: 200, - body: { - key, - value: storedValue, - version: currentVersion, - }, - }; - }, - mutate: async (mutations) => { - mutateCalls += 1; - const [mutation] = mutations; - if (!mutation) { - return { - status: 400, - body: { - success: false, - errors: [{ key: 'missing', error: 'version-mismatch', version: currentVersion, value: storedValue }], - }, - }; - } - - if (mutateCalls === 1) { - currentVersion = 1; - storedValue = conflictValue; - return { - status: 409, - body: { - success: false, - errors: [{ key: mutation.key, error: 'version-mismatch', version: currentVersion, value: storedValue }], - }, - }; - } - - currentVersion += 1; - storedValue = mutation.value ?? storedValue; - return { - status: 200, - body: { - success: true, - results: [{ key: mutation.key, version: currentVersion }], - }, - }; - }, - }; - - return { - kv, - getCallCount: () => getCalls, - mutateCallCount: () => mutateCalls, - }; -} - -function createMalformedPrimaryReadKvClient(): ChannelBridgeKvClient { - const keySuffix = ':bindings'; - const malformedValue = Buffer.from(JSON.stringify({ - schemaVersion: 1, - bindings: [ - { - providerId: 'telegram', - conversationId: '-100bad', - sessionId: 'sess-bad', - lastForwardedSeq: 'oops', - }, - ], - }), 'utf8').toString('base64'); - - const byKey = new Map(); - - const kv: ChannelBridgeKvClient = { - get: async (key) => { - if (!byKey.has(key)) { - byKey.set(key, { value: malformedValue, version: 6 }); - } - const row = byKey.get(key); - if (!row || row.value === null) { - return { status: 404, body: { error: 'Key not found' } }; - } - - return { - status: 200, - body: { - key, - value: row.value, - version: row.version, - }, - }; - }, - mutate: async (mutations) => { - const errors: Array<{ key: string; error: 'version-mismatch'; version: number; value: string | null }> = []; - for (const mutation of mutations) { - const row = byKey.get(mutation.key); - const currentVersion = row?.version ?? -1; - if (currentVersion !== mutation.version) { - errors.push({ - key: mutation.key, - error: 'version-mismatch', - version: currentVersion, - value: row?.value ?? null, - }); - } - } - - if (errors.length > 0) { - return { status: 409, body: { success: false, errors } }; - } - - const results: Array<{ key: string; version: number }> = []; - for (const mutation of mutations) { - if (!mutation.key.endsWith(keySuffix)) continue; - const row = byKey.get(mutation.key); - const nextVersion = (row?.version ?? -1) + 1; - byKey.set(mutation.key, { - value: mutation.value, - version: nextVersion, - }); - results.push({ key: mutation.key, version: nextVersion }); - } - - return { status: 200, body: { success: true, results } }; - }, - }; - - return kv; -} - -describe('createServerBackedChannelBindingStore', () => { - it('persists bindings to server KV and reloads them', async () => { - const kv = createInMemoryKvClient(); - const storeA = createServerBackedChannelBindingStore({ - kv, - serverId: 'local-3005', - accountId: 'acct-1', - }); - - const storeB = createServerBackedChannelBindingStore({ - kv, - serverId: 'local-3005', - accountId: 'acct-1', - }); - - await storeA.upsertBinding({ - providerId: 'telegram', - conversationId: '-100111', - threadId: '22', - sessionId: 'sess-1', - lastForwardedSeq: 7, - }); - - const reloaded = await storeB.getBinding({ - providerId: 'telegram', - conversationId: '-100111', - threadId: '22', - }); - - expect(reloaded?.sessionId).toBe('sess-1'); - expect(reloaded?.lastForwardedSeq).toBe(7); - }); - - it('updates forwarded seq and removes bindings', async () => { - const kv = createInMemoryKvClient(); - const store = createServerBackedChannelBindingStore({ - kv, - serverId: 'local-3005', - accountId: 'acct-1', - }); - - await store.upsertBinding({ - providerId: 'telegram', - conversationId: '-100111', - threadId: null, - sessionId: 'sess-1', - lastForwardedSeq: 3, - }); - - await store.updateLastForwardedSeq({ - providerId: 'telegram', - conversationId: '-100111', - threadId: null, - }, { - expectedSessionId: 'sess-1', - seq: 10, - }); - - const updated = await store.getBinding({ - providerId: 'telegram', - conversationId: '-100111', - threadId: null, - }); - expect(updated?.lastForwardedSeq).toBe(10); - - const removed = await store.removeBinding({ - providerId: 'telegram', - conversationId: '-100111', - threadId: null, - }); - expect(removed).toBe(true); - - const after = await store.getBinding({ - providerId: 'telegram', - conversationId: '-100111', - threadId: null, - }); - expect(after).toBeNull(); - }); - - it('does not write to KV for no-op seq updates or missing removes', async () => { - const counting = createCountingKvClient(); - const store = createServerBackedChannelBindingStore({ - kv: counting.kv, - serverId: 'local-3005', - accountId: 'acct-1', - }); - - await store.upsertBinding({ - providerId: 'telegram', - conversationId: '-100222', - threadId: null, - sessionId: 'sess-2', - lastForwardedSeq: 12, - }); - const initialMutations = counting.mutateCallCount(); - - await store.updateLastForwardedSeq({ - providerId: 'telegram', - conversationId: '-100222', - threadId: null, - }, { - expectedSessionId: 'sess-2', - seq: 10, - }); - await store.updateLastForwardedSeq({ - providerId: 'telegram', - conversationId: '-100-missing', - threadId: null, - }, { - expectedSessionId: 'sess-missing', - seq: 1, - }); - const missingRemoved = await store.removeBinding({ - providerId: 'telegram', - conversationId: '-100-missing', - threadId: null, - }); - - expect(missingRemoved).toBe(false); - expect(counting.mutateCallCount()).toBe(initialMutations); - }); - - it('does not advance cursor when expected session id does not match', async () => { - const counting = createCountingKvClient(); - const store = createServerBackedChannelBindingStore({ - kv: counting.kv, - serverId: 'local-3005', - accountId: 'acct-1', - }); - - await store.upsertBinding({ - providerId: 'telegram', - conversationId: '-100session-guard', - threadId: null, - sessionId: 'sess-current', - lastForwardedSeq: 5, - }); - - const beforeMutations = counting.mutateCallCount(); - const advanced = await store.updateLastForwardedSeq({ - providerId: 'telegram', - conversationId: '-100session-guard', - threadId: null, - }, { - expectedSessionId: 'sess-stale', - seq: 99, - }); - - expect(advanced).toBe(false); - expect(counting.mutateCallCount()).toBe(beforeMutations); - - const binding = await store.getBinding({ - providerId: 'telegram', - conversationId: '-100session-guard', - threadId: null, - }); - expect(binding?.sessionId).toBe('sess-current'); - expect(binding?.lastForwardedSeq).toBe(5); - }); - - it('rejects invalid identity/cursor values and skips invalid seq writes', async () => { - const counting = createCountingKvClient(); - const store = createServerBackedChannelBindingStore({ - kv: counting.kv, - serverId: 'local-3005', - accountId: 'acct-1', - }); - - await expect(store.upsertBinding({ - providerId: 'telegram', - conversationId: '-100nf', - threadId: null, - sessionId: 'sess-nf', - lastForwardedSeq: Number.NaN, - })).rejects.toThrow('Invalid channel binding input'); - - await expect(store.upsertBinding({ - providerId: ' ', - conversationId: '-100nf', - threadId: null, - sessionId: 'sess-nf', - lastForwardedSeq: 1, - })).rejects.toThrow('Invalid channel binding input'); - - expect(counting.mutateCallCount()).toBe(0); - - await store.upsertBinding({ - providerId: ' telegram ', - conversationId: ' -100nf ', - threadId: ' 11 ', - sessionId: ' sess-nf ', - lastForwardedSeq: 2, - }); - - const saved = await store.getBinding({ - providerId: 'telegram', - conversationId: '-100nf', - threadId: '11', - }); - expect(saved).toMatchObject({ - providerId: 'telegram', - conversationId: '-100nf', - threadId: '11', - sessionId: 'sess-nf', - lastForwardedSeq: 2, - }); - - const initialMutations = counting.mutateCallCount(); - - await store.updateLastForwardedSeq({ - providerId: 'telegram', - conversationId: '-100nf', - threadId: '11', - }, { - expectedSessionId: 'sess-nf', - seq: Number.POSITIVE_INFINITY, - }); - - await store.updateLastForwardedSeq({ - providerId: 'telegram', - conversationId: '-100nf', - threadId: '11', - }, { - expectedSessionId: 'sess-nf', - seq: Number.NaN, - }); - - expect(counting.mutateCallCount()).toBe(initialMutations); - - const afterInvalidUpdates = await store.getBinding({ - providerId: 'telegram', - conversationId: '-100nf', - threadId: '11', - }); - expect(afterInvalidUpdates?.lastForwardedSeq).toBe(2); - }); - - it('fails fast when conflict payload cannot be decoded', async () => { - const conflict = createConflictPayloadKvClient(); - const store = createServerBackedChannelBindingStore({ - kv: conflict.kv, - serverId: 'local-3005', - accountId: 'acct-1', - maxWriteRetries: 3, - }); - - await expect(store.upsertBinding({ - providerId: 'telegram', - conversationId: '-100333', - threadId: null, - sessionId: 'sess-3', - lastForwardedSeq: 4, - })).rejects.toThrow('Conflict payload decode failed'); - - const binding = await store.getBinding({ - providerId: 'telegram', - conversationId: '-100333', - threadId: null, - }); - - expect(binding).toBeNull(); - expect(conflict.mutateCallCount()).toBe(1); - }); - - it('fails fast when malformed primary KV payload is encountered before cache warm-up', async () => { - const kv = createMalformedPrimaryReadKvClient(); - const store = createServerBackedChannelBindingStore({ - kv, - serverId: 'local-3005', - accountId: 'acct-1', - maxWriteRetries: 3, - }); - - await expect(store.listBindings()).rejects.toThrow('Invalid channel bridge binding lastForwardedSeq at index 0'); - - await expect(store.upsertBinding({ - providerId: 'telegram', - conversationId: '-100444', - threadId: null, - sessionId: 'sess-4', - lastForwardedSeq: 1, - })).rejects.toThrow('Invalid channel bridge binding lastForwardedSeq at index 0'); - - await expect(store.getBinding({ - providerId: 'telegram', - conversationId: '-100444', - threadId: null, - })).rejects.toThrow('Invalid channel bridge binding lastForwardedSeq at index 0'); - }); - - it('returns last known-good bindings when a recoverable decode error occurs after cache warm-up', async () => { - const validValue = Buffer.from(JSON.stringify({ - schemaVersion: 1, - bindings: [ - { - providerId: 'telegram', - conversationId: '-100cache', - threadId: null, - sessionId: 'sess-cache', - lastForwardedSeq: 7, - createdAtMs: 10, - updatedAtMs: 11, - }, - ], - }), 'utf8').toString('base64'); - - const malformedValue = Buffer.from(JSON.stringify({ - schemaVersion: 1, - bindings: [ - { - providerId: 'telegram', - conversationId: '-100cache', - threadId: null, - sessionId: 'sess-cache', - lastForwardedSeq: 'oops', - }, - ], - }), 'utf8').toString('base64'); - - let getCalls = 0; - const kv: ChannelBridgeKvClient = { - get: async (key) => { - getCalls += 1; - return { - status: 200, - body: { - key, - version: 4, - value: getCalls === 1 ? validValue : malformedValue, - }, - }; - }, - mutate: async () => ({ - status: 200, - body: { - success: true, - results: [], - }, - }), - }; - - const store = createServerBackedChannelBindingStore({ - kv, - serverId: 'local-3005', - accountId: 'acct-1', - cacheTtlMs: 0, - maxWriteRetries: 3, - }); - - const first = await store.listBindings(); - expect(first).toEqual([ - { - providerId: 'telegram', - conversationId: '-100cache', - threadId: null, - sessionId: 'sess-cache', - lastForwardedSeq: 7, - createdAtMs: 10, - updatedAtMs: 11, - }, - ]); - - await new Promise((resolve) => setTimeout(resolve, 10)); - const second = await store.listBindings(); - expect(second).toEqual(first); - expect(getCalls).toBeGreaterThanOrEqual(2); - }); - - it('uses conflict payload state for retry without extra primary read', async () => { - const conflict = createValidConflictPayloadKvClient(); - const store = createServerBackedChannelBindingStore({ - kv: conflict.kv, - serverId: 'local-3005', - accountId: 'acct-1', - maxWriteRetries: 3, - }); - - await store.upsertBinding({ - providerId: 'telegram', - conversationId: '-100555', - threadId: null, - sessionId: 'sess-5', - lastForwardedSeq: 2, - }); - - expect(conflict.mutateCallCount()).toBe(2); - expect(conflict.getCallCount()).toBe(1); - }); -}); diff --git a/apps/cli/src/channels/channelBindingStore.server.ts b/apps/cli/src/channels/channelBindingStore.server.ts deleted file mode 100644 index 20697984f4..0000000000 --- a/apps/cli/src/channels/channelBindingStore.server.ts +++ /dev/null @@ -1,287 +0,0 @@ -/** - * Server-backed channel binding store. - * - * Responsibilities: - * - read/write channel binding state from server KV - * - keep optimistic concurrency behavior safe across concurrent machines - * - preserve local cache coherence and avoid silently clobbering remote data - */ -import type { ChannelBindingStore, ChannelBridgeConversationRef, ChannelSessionBinding } from '@/channels/core/channelBridgeWorker'; -import { - ChannelBridgeBadPayloadError, - ChannelBridgeKvVersionMismatchError, - decodeChannelBridgeBindingsDocFromBase64, - readChannelBridgeBindingsFromKv, - type ChannelBridgeKvClient, - type ChannelBridgeServerBindingsDocument, - writeChannelBridgeBindingsToKv, -} from '@/channels/channelBridgeServerKv'; -import { logger } from '@/ui/logger'; - -type BindingCache = Readonly<{ - version: number; - bindings: ChannelSessionBinding[]; - fetchedAtMs: number; -}>; - -function bindingKey(ref: ChannelBridgeConversationRef): string { - return JSON.stringify([ref.providerId, ref.conversationId, ref.threadId]); -} - -function cloneBinding(binding: ChannelSessionBinding): ChannelSessionBinding { - return { - providerId: binding.providerId, - conversationId: binding.conversationId, - threadId: binding.threadId, - sessionId: binding.sessionId, - lastForwardedSeq: binding.lastForwardedSeq, - createdAtMs: binding.createdAtMs, - updatedAtMs: binding.updatedAtMs, - }; -} - -function cloneBindings(bindings: readonly ChannelSessionBinding[]): ChannelSessionBinding[] { - return bindings.map((binding) => cloneBinding(binding)); -} - -function toNonNegativeInt(value: unknown): number | null { - if (typeof value !== 'number' || !Number.isFinite(value)) return null; - const truncated = Math.trunc(value); - if (truncated < 0) return null; - return truncated; -} - -function toServerDocument(bindings: readonly ChannelSessionBinding[]): ChannelBridgeServerBindingsDocument { - return { - schemaVersion: 1, - bindings: bindings.map((binding) => ({ - providerId: binding.providerId, - conversationId: binding.conversationId, - threadId: binding.threadId, - sessionId: binding.sessionId, - lastForwardedSeq: binding.lastForwardedSeq, - createdAtMs: binding.createdAtMs, - updatedAtMs: binding.updatedAtMs, - })), - }; -} - -function fromServerDocument(doc: ChannelBridgeServerBindingsDocument): ChannelSessionBinding[] { - return doc.bindings.map((binding) => ({ - providerId: binding.providerId, - conversationId: binding.conversationId, - threadId: binding.threadId, - sessionId: binding.sessionId, - lastForwardedSeq: toNonNegativeInt(binding.lastForwardedSeq) ?? 0, - createdAtMs: Math.trunc(binding.createdAtMs), - updatedAtMs: Math.trunc(binding.updatedAtMs), - })); -} - -function isRecoverableBindingsReadError(error: unknown): boolean { - if (error instanceof SyntaxError) return true; - return error instanceof ChannelBridgeBadPayloadError; -} - -export function createServerBackedChannelBindingStore(params: Readonly<{ - kv: ChannelBridgeKvClient; - serverId: string; - accountId: string; - cacheTtlMs?: number; - maxWriteRetries?: number; -}>): ChannelBindingStore { - const cacheTtlMs = typeof params.cacheTtlMs === 'number' ? Math.max(0, Math.trunc(params.cacheTtlMs)) : 1_000; - const maxWriteRetries = typeof params.maxWriteRetries === 'number' ? Math.max(1, Math.trunc(params.maxWriteRetries)) : 4; - - let cache: BindingCache | null = null; - - async function load(forceRefresh: boolean): Promise { - if (!forceRefresh && cache && Date.now() - cache.fetchedAtMs <= cacheTtlMs) { - return cache; - } - - try { - const fetched = await readChannelBridgeBindingsFromKv({ - kv: params.kv, - serverId: params.serverId, - accountId: params.accountId, - }); - const next: BindingCache = { - version: fetched.version, - bindings: fromServerDocument(fetched.doc), - fetchedAtMs: Date.now(), - }; - cache = next; - return next; - } catch (error) { - if (!isRecoverableBindingsReadError(error)) { - throw error; - } - - logger.warn('[channelBindingStore] Failed to decode primary KV payload', error); - if (!cache) { - throw error; - } - - const next: BindingCache = { - ...cache, - fetchedAtMs: Date.now(), - }; - cache = next; - return next; - } - } - - function setCache(version: number, bindings: readonly ChannelSessionBinding[]): void { - cache = { - version, - bindings: cloneBindings(bindings), - fetchedAtMs: Date.now(), - }; - } - - async function withOptimisticWrite(operation: (currentBindings: ChannelSessionBinding[]) => Readonly<{ - nextBindings: ChannelSessionBinding[]; - result: T; - changed?: boolean; - }>): Promise { - let retryCurrent: BindingCache | null = null; - - for (let attempt = 0; attempt < maxWriteRetries; attempt += 1) { - const current = retryCurrent ?? await load(true); - retryCurrent = null; - const op = operation(cloneBindings(current.bindings)); - const changed = op.changed ?? true; - if (!changed) { - setCache(current.version, current.bindings); - return op.result; - } - - try { - const version = await writeChannelBridgeBindingsToKv({ - kv: params.kv, - serverId: params.serverId, - accountId: params.accountId, - expectedVersion: current.version, - doc: toServerDocument(op.nextBindings), - }); - - setCache(version, op.nextBindings); - return op.result; - } catch (error) { - if (!(error instanceof ChannelBridgeKvVersionMismatchError)) { - throw error; - } - - let doc: ChannelBridgeServerBindingsDocument; - try { - doc = decodeChannelBridgeBindingsDocFromBase64(error.currentValueBase64); - } catch (decodeError) { - logger.warn('[channelBindingStore] Failed to decode conflict payload; aborting optimistic retry to avoid clobbering remote bindings', decodeError); - throw new Error( - '[channelBindingStore] Conflict payload decode failed; aborting optimistic retry to avoid clobbering remote bindings', - ); - } - const conflictBindings = fromServerDocument(doc); - setCache(error.currentVersion, conflictBindings); - retryCurrent = { - version: error.currentVersion, - bindings: cloneBindings(conflictBindings), - fetchedAtMs: Date.now(), - }; - } - } - - throw new Error('Failed to persist channel bridge bindings after retries'); - } - - return { - listBindings: async () => { - const current = await load(false); - return cloneBindings(current.bindings); - }, - getBinding: async (ref) => { - const current = await load(false); - const key = bindingKey(ref); - const found = current.bindings.find((binding) => bindingKey(binding) === key); - return found ? cloneBinding(found) : null; - }, - upsertBinding: async (binding) => { - return await withOptimisticWrite((currentBindings) => { - const nowMs = Date.now(); - const providerId = binding.providerId.trim(); - const conversationId = binding.conversationId.trim(); - const sessionId = binding.sessionId.trim(); - const threadIdRaw = typeof binding.threadId === 'string' ? binding.threadId.trim() : ''; - const threadId = threadIdRaw.length > 0 ? threadIdRaw : null; - const normalizedLastForwardedSeq = toNonNegativeInt(binding.lastForwardedSeq); - - if (!providerId || !conversationId || !sessionId || normalizedLastForwardedSeq === null) { - throw new Error('Invalid channel binding input'); - } - - const key = bindingKey({ providerId, conversationId, threadId }); - const existing = currentBindings.find((row) => bindingKey(row) === key) ?? null; - const nextBinding: ChannelSessionBinding = { - providerId, - conversationId, - threadId, - sessionId, - lastForwardedSeq: normalizedLastForwardedSeq, - createdAtMs: existing?.createdAtMs ?? nowMs, - updatedAtMs: nowMs, - }; - - const nextBindings = currentBindings - .filter((row) => bindingKey(row) !== key) - .concat([nextBinding]); - - return { - nextBindings, - result: cloneBinding(nextBinding), - }; - }); - }, - updateLastForwardedSeq: async (ref, params) => { - return await withOptimisticWrite((currentBindings) => { - const key = bindingKey(ref); - const parsedSeq = toNonNegativeInt(params.seq); - if (parsedSeq === null) { - return { - nextBindings: currentBindings, - result: false, - changed: false, - }; - } - let changed = false; - const nextBindings = currentBindings.map((binding) => { - if (bindingKey(binding) !== key) return binding; - if (binding.sessionId !== params.expectedSessionId) return binding; - if (parsedSeq <= binding.lastForwardedSeq) return binding; - changed = true; - return { - ...binding, - lastForwardedSeq: parsedSeq, - updatedAtMs: Date.now(), - }; - }); - return { - nextBindings, - result: changed, - changed, - }; - }); - }, - removeBinding: async (ref) => { - return await withOptimisticWrite((currentBindings) => { - const key = bindingKey(ref); - const nextBindings = currentBindings.filter((binding) => bindingKey(binding) !== key); - return { - nextBindings, - result: nextBindings.length !== currentBindings.length, - changed: nextBindings.length !== currentBindings.length, - }; - }); - }, - }; -} diff --git a/apps/cli/src/channels/channelBridgeAccountConfig.ts b/apps/cli/src/channels/channelBridgeAccountConfig.ts index 1975eca457..fa45bd2ce3 100644 --- a/apps/cli/src/channels/channelBridgeAccountConfig.ts +++ b/apps/cli/src/channels/channelBridgeAccountConfig.ts @@ -47,6 +47,7 @@ export type ScopedTelegramBridgeUpdate = Readonly<{ tickMs?: number; botToken?: string; allowedChatIds?: string[]; + allowAllSharedChats?: boolean; requireTopics?: boolean; webhookEnabled?: boolean; webhookSecret?: string; @@ -57,6 +58,7 @@ export type ScopedTelegramBridgeUpdate = Readonly<{ export type SharedTelegramBridgeUpdate = Readonly<{ tickMs?: number; allowedChatIds?: string[]; + allowAllSharedChats?: boolean; requireTopics?: boolean; webhookEnabled?: boolean; webhookHost?: string; @@ -75,6 +77,7 @@ export function splitScopedTelegramBridgeUpdate(params: Readonly<{ tickMs?: number; botToken?: string; allowedChatIds?: string[]; + allowAllSharedChats?: boolean; requireTopics?: boolean; webhookEnabled?: boolean; webhookSecret?: string; @@ -84,6 +87,7 @@ export function splitScopedTelegramBridgeUpdate(params: Readonly<{ const sharedUpdate: { tickMs?: number; allowedChatIds?: string[]; + allowAllSharedChats?: boolean; requireTopics?: boolean; webhookEnabled?: boolean; webhookHost?: string; @@ -102,6 +106,10 @@ export function splitScopedTelegramBridgeUpdate(params: Readonly<{ localUpdate.allowedChatIds = [...source.allowedChatIds]; sharedUpdate.allowedChatIds = [...source.allowedChatIds]; } + if (typeof source.allowAllSharedChats === 'boolean') { + localUpdate.allowAllSharedChats = source.allowAllSharedChats; + sharedUpdate.allowAllSharedChats = source.allowAllSharedChats; + } if (typeof source.requireTopics === 'boolean') { localUpdate.requireTopics = source.requireTopics; sharedUpdate.requireTopics = source.requireTopics; @@ -183,6 +191,10 @@ export function readScopedTelegramBridgeConfig(params: Readonly<{ normalized.requireTopics = telegram.requireTopics; } + if (typeof telegram.allowAllSharedChats === 'boolean') { + normalized.allowAllSharedChats = telegram.allowAllSharedChats; + } + const normalizedWebhook: RecordLike = {}; if (typeof webhook?.enabled === 'boolean') { normalizedWebhook.enabled = webhook.enabled; @@ -248,6 +260,10 @@ export function upsertScopedTelegramBridgeConfig(params: Readonly<{ const telegram = ensureTelegramScope(); telegram.allowedChatIds = parseStringArray(params.update.allowedChatIds); } + if (typeof params.update.allowAllSharedChats === 'boolean') { + const telegram = ensureTelegramScope(); + telegram.allowAllSharedChats = params.update.allowAllSharedChats; + } if (typeof params.update.requireTopics === 'boolean') { const telegram = ensureTelegramScope(); telegram.requireTopics = params.update.requireTopics; diff --git a/apps/cli/src/channels/channelBridgeConfig.test.ts b/apps/cli/src/channels/channelBridgeConfig.test.ts index 92f8cc92be..c277ddd7fa 100644 --- a/apps/cli/src/channels/channelBridgeConfig.test.ts +++ b/apps/cli/src/channels/channelBridgeConfig.test.ts @@ -29,6 +29,7 @@ describe('resolveChannelBridgeRuntimeConfig', () => { expect(config.tickMs).toBe(3_100); expect(config.telegram.botToken).toBe('settings-bot-token'); expect(config.telegram.allowedChatIds).toEqual(['-100111', '-100222']); + expect(config.telegram.allowAllSharedChats).toBe(false); expect(config.telegram.requireTopics).toBe(true); expect(config.telegram.webhookEnabled).toBe(true); expect(config.telegram.webhookSecret).toBe('settings-secret'); @@ -63,6 +64,7 @@ describe('resolveChannelBridgeRuntimeConfig', () => { expect(config.telegram.botToken).toBe('secret-bot-token'); expect(config.telegram.webhookSecret).toBe('secret-webhook-token'); expect(config.telegram.allowedChatIds).toEqual(['-100111']); + expect(config.telegram.allowAllSharedChats).toBe(false); expect(config.telegram.requireTopics).toBe(true); }); @@ -101,6 +103,7 @@ describe('resolveChannelBridgeRuntimeConfig', () => { expect(config.tickMs).toBe(700); expect(config.telegram.botToken).toBe('env-token'); expect(config.telegram.allowedChatIds).toEqual(['-100333', '-100444']); + expect(config.telegram.allowAllSharedChats).toBe(false); expect(config.telegram.requireTopics).toBe(false); expect(config.telegram.webhookEnabled).toBe(true); expect(config.telegram.webhookSecret).toBe('env-secret'); @@ -129,6 +132,17 @@ describe('resolveChannelBridgeRuntimeConfig', () => { expect(config.telegram.webhookPort).toBe(8_877); }); + it('reads allowAllSharedChats from env', () => { + const config = resolveChannelBridgeRuntimeConfig({ + env: { + HAPPIER_TELEGRAM_ALLOW_ALL_SHARED_CHATS: '1', + }, + settings: {}, + }); + + expect(config.telegram.allowAllSharedChats).toBe(true); + }); + it('falls back to settings allowedChatIds when env CSV is effectively empty', () => { const config = resolveChannelBridgeRuntimeConfig({ env: { diff --git a/apps/cli/src/channels/channelBridgeConfig.ts b/apps/cli/src/channels/channelBridgeConfig.ts index c1bae28f4f..a51cd97b4e 100644 --- a/apps/cli/src/channels/channelBridgeConfig.ts +++ b/apps/cli/src/channels/channelBridgeConfig.ts @@ -1,4 +1,4 @@ -import { isLoopbackHost } from '@/channels/telegram/telegramWebhookRelay'; +import { isLoopbackHostname } from '@/server/serverUrlClassification'; type RecordLike = Record; @@ -91,6 +91,7 @@ function firstParsed(values: readonly unknown[], parse: (value: unknown) => T type TelegramChannelBridgeRuntimeConfig = Readonly<{ botToken: string; allowedChatIds: string[]; + allowAllSharedChats: boolean; requireTopics: boolean; webhookEnabled: boolean; webhookSecret: string; @@ -189,6 +190,18 @@ export function resolveChannelBridgeRuntimeConfig(params: Readonly<{ ? parsedEnvAllowedChatIds : settingsAllowedChatIds; + const settingsAllowAllSharedChats = + firstParsed( + [telegramAccount?.allowAllSharedChats, telegramServer?.allowAllSharedChats, telegramGlobal?.allowAllSharedChats], + parseBoolean, + ) + ?? false; + const envAllowAllSharedChats = + typeof env.HAPPIER_TELEGRAM_ALLOW_ALL_SHARED_CHATS === 'string' + ? parseBoolean(env.HAPPIER_TELEGRAM_ALLOW_ALL_SHARED_CHATS) + : null; + const allowAllSharedChats = envAllowAllSharedChats ?? settingsAllowAllSharedChats; + const settingsRequireTopics = firstParsed( [telegramAccount?.requireTopics, telegramServer?.requireTopics, telegramGlobal?.requireTopics], @@ -236,13 +249,13 @@ export function resolveChannelBridgeRuntimeConfig(params: Readonly<{ readTrimmedString, ) || '127.0.0.1'; - const settingsWebhookHost = isLoopbackHost(settingsWebhookHostRaw) ? settingsWebhookHostRaw : '127.0.0.1'; + const settingsWebhookHost = isLoopbackHostname(settingsWebhookHostRaw) ? settingsWebhookHostRaw : '127.0.0.1'; const envWebhookHostRaw = typeof env.HAPPIER_TELEGRAM_WEBHOOK_HOST === 'string' ? env.HAPPIER_TELEGRAM_WEBHOOK_HOST.trim() : null; const envWebhookHost = - envWebhookHostRaw && isLoopbackHost(envWebhookHostRaw) + envWebhookHostRaw && isLoopbackHostname(envWebhookHostRaw) ? envWebhookHostRaw : null; const webhookHost = envWebhookHost ?? settingsWebhookHost; @@ -264,6 +277,7 @@ export function resolveChannelBridgeRuntimeConfig(params: Readonly<{ telegram: { botToken, allowedChatIds, + allowAllSharedChats, requireTopics, webhookEnabled, webhookSecret, diff --git a/apps/cli/src/channels/channelBridgeServerConfigOverlay.test.ts b/apps/cli/src/channels/channelBridgeServerConfigOverlay.test.ts deleted file mode 100644 index 8f87ab970d..0000000000 --- a/apps/cli/src/channels/channelBridgeServerConfigOverlay.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { describe, expect, it } from 'vitest'; - -import { resolveChannelBridgeRuntimeConfig } from './channelBridgeConfig'; -import { overlayServerKvTelegramConfigInSettings } from './channelBridgeServerConfigOverlay'; - -describe('overlayServerKvTelegramConfigInSettings', () => { - it('overlays non-secret server KV telegram config into scoped runtime settings', () => { - const merged = overlayServerKvTelegramConfigInSettings({ - settings: { - channelBridge: { - byServerId: { - 'local-3005': { - byAccountId: { - acct_123: { - providers: { - telegram: { - secrets: { - botToken: 'local-secret-token', - webhookSecret: 'local-secret-webhook', - }, - }, - }, - }, - }, - }, - }, - }, - }, - serverId: 'local-3005', - accountId: 'acct_123', - record: { - schemaVersion: 1, - tickMs: 3100, - telegram: { - allowedChatIds: ['-100111', '-100222'], - requireTopics: true, - webhook: { - enabled: true, - host: '0.0.0.0', - port: 9001, - }, - }, - updatedAtMs: Date.now(), - }, - }); - - const runtime = resolveChannelBridgeRuntimeConfig({ - env: {}, - settings: merged, - serverId: 'local-3005', - accountId: 'acct_123', - }); - - expect(runtime.tickMs).toBe(3100); - expect(runtime.telegram.botToken).toBe('local-secret-token'); - expect(runtime.telegram.allowedChatIds).toEqual(['-100111', '-100222']); - expect(runtime.telegram.requireTopics).toBe(true); - expect(runtime.telegram.webhookEnabled).toBe(true); - expect(runtime.telegram.webhookSecret).toBe('local-secret-webhook'); - expect(runtime.telegram.webhookHost).toBe('0.0.0.0'); - expect(runtime.telegram.webhookPort).toBe(9001); - }); - - it('returns original settings when no record is available', () => { - const settings = { - channelBridge: { - byServerId: { - 'local-3005': { - byAccountId: { - acct_123: { - providers: { - telegram: { - botToken: 'local-token', - }, - }, - }, - }, - }, - }, - }, - }; - - const merged = overlayServerKvTelegramConfigInSettings({ - settings, - serverId: 'local-3005', - accountId: 'acct_123', - record: null, - }); - - expect(merged).toBe(settings); - }); - - it('does not create empty scoped config when record has no usable fields', () => { - const settings = { schemaVersion: 7 }; - - const merged = overlayServerKvTelegramConfigInSettings({ - settings, - serverId: 'local-3005', - accountId: 'acct_123', - record: { - schemaVersion: 1, - telegram: {}, - updatedAtMs: Date.now(), - }, - }); - - expect(merged).toBe(settings); - }); -}); diff --git a/apps/cli/src/channels/channelBridgeServerConfigOverlay.ts b/apps/cli/src/channels/channelBridgeServerConfigOverlay.ts deleted file mode 100644 index 4ddafa7482..0000000000 --- a/apps/cli/src/channels/channelBridgeServerConfigOverlay.ts +++ /dev/null @@ -1,50 +0,0 @@ -import { - upsertScopedTelegramBridgeConfig, - type ScopedTelegramBridgeUpdate, -} from './channelBridgeAccountConfig'; -import type { ChannelBridgeServerTelegramConfigRecord } from './channelBridgeServerKv'; - -function hasUpdateFields(update: ScopedTelegramBridgeUpdate): boolean { - return ( - typeof update.tickMs === 'number' - || Array.isArray(update.allowedChatIds) - || typeof update.requireTopics === 'boolean' - || typeof update.webhookEnabled === 'boolean' - || typeof update.webhookHost === 'string' - || typeof update.webhookPort === 'number' - ); -} - -export function overlayServerKvTelegramConfigInSettings(params: Readonly<{ - settings: unknown; - serverId: string; - accountId: string; - record: ChannelBridgeServerTelegramConfigRecord | null; -}>): unknown { - if (!params.record) { - return params.settings; - } - - const telegram = params.record.telegram; - const webhook = telegram.webhook; - - const update: ScopedTelegramBridgeUpdate = { - ...(typeof params.record.tickMs === 'number' ? { tickMs: Math.trunc(params.record.tickMs) } : {}), - ...(Array.isArray(telegram.allowedChatIds) ? { allowedChatIds: [...telegram.allowedChatIds] } : {}), - ...(typeof telegram.requireTopics === 'boolean' ? { requireTopics: telegram.requireTopics } : {}), - ...(webhook && typeof webhook.enabled === 'boolean' ? { webhookEnabled: webhook.enabled } : {}), - ...(webhook && typeof webhook.host === 'string' ? { webhookHost: webhook.host } : {}), - ...(webhook && typeof webhook.port === 'number' ? { webhookPort: Math.trunc(webhook.port) } : {}), - }; - - if (!hasUpdateFields(update)) { - return params.settings; - } - - return upsertScopedTelegramBridgeConfig({ - settings: params.settings, - serverId: params.serverId, - accountId: params.accountId, - update, - }); -} diff --git a/apps/cli/src/channels/channelBridgeServerKv.test.ts b/apps/cli/src/channels/channelBridgeServerKv.test.ts deleted file mode 100644 index 44e17bde71..0000000000 --- a/apps/cli/src/channels/channelBridgeServerKv.test.ts +++ /dev/null @@ -1,1191 +0,0 @@ -import { describe, expect, it } from 'vitest'; -import { reloadConfiguration } from '@/configuration'; - -import { - type ChannelBridgeKvClient, - createAxiosChannelBridgeKvClient, - clearChannelBridgeTelegramConfigInKv, - replaceChannelBridgeTelegramConfigRawInKv, - readChannelBridgeBindingsFromKv, - readChannelBridgeTelegramConfigFromKv, - upsertChannelBridgeTelegramConfigInKv, - writeChannelBridgeBindingsToKv, -} from './channelBridgeServerKv'; - -function createInMemoryKvClient(): ChannelBridgeKvClient { - const byKey = new Map(); - - return { - get: async (key) => { - const row = byKey.get(key); - if (!row || row.value === null) { - return { status: 404, body: { error: 'Key not found' } }; - } - return { - status: 200, - body: { - key, - value: row.value, - version: row.version, - }, - }; - }, - mutate: async (mutations) => { - const errors: Array<{ key: string; error: 'version-mismatch'; version: number; value: string | null }> = []; - for (const mutation of mutations) { - const row = byKey.get(mutation.key); - const currentVersion = row?.version ?? -1; - if (currentVersion !== mutation.version) { - errors.push({ - key: mutation.key, - error: 'version-mismatch', - version: currentVersion, - value: row?.value ?? null, - }); - } - } - - if (errors.length > 0) { - return { status: 409, body: { success: false, errors } }; - } - - const results: Array<{ key: string; version: number }> = []; - for (const mutation of mutations) { - const row = byKey.get(mutation.key); - const nextVersion = (row?.version ?? -1) + 1; - byKey.set(mutation.key, { - value: mutation.value, - version: nextVersion, - }); - results.push({ key: mutation.key, version: nextVersion }); - } - - return { status: 200, body: { success: true, results } }; - }, - }; -} - -describe('channelBridgeServerKv', () => { - it('rejects insecure non-loopback http KV base URLs', () => { - const prevServerUrl = process.env.HAPPIER_SERVER_URL; - try { - process.env.HAPPIER_SERVER_URL = 'http://example.com:3005'; - reloadConfiguration(); - - expect(() => createAxiosChannelBridgeKvClient({ token: 'token-1' })).toThrow( - 'Insecure channel bridge KV base URL', - ); - } finally { - if (prevServerUrl === undefined) { - delete process.env.HAPPIER_SERVER_URL; - } else { - process.env.HAPPIER_SERVER_URL = prevServerUrl; - } - reloadConfiguration(); - } - }); - - it('allows loopback http KV base URLs for local development', () => { - const prevServerUrl = process.env.HAPPIER_SERVER_URL; - try { - process.env.HAPPIER_SERVER_URL = 'http://127.0.0.1:3005'; - reloadConfiguration(); - - expect(() => createAxiosChannelBridgeKvClient({ token: 'token-1' })).not.toThrow(); - } finally { - if (prevServerUrl === undefined) { - delete process.env.HAPPIER_SERVER_URL; - } else { - process.env.HAPPIER_SERVER_URL = prevServerUrl; - } - reloadConfiguration(); - } - }); - - it('allows full 127.0.0.0/8 loopback range for local development', () => { - const prevServerUrl = process.env.HAPPIER_SERVER_URL; - try { - process.env.HAPPIER_SERVER_URL = 'http://127.0.0.2:3005'; - reloadConfiguration(); - - expect(() => createAxiosChannelBridgeKvClient({ token: 'token-1' })).not.toThrow(); - } finally { - if (prevServerUrl === undefined) { - delete process.env.HAPPIER_SERVER_URL; - } else { - process.env.HAPPIER_SERVER_URL = prevServerUrl; - } - reloadConfiguration(); - } - }); - - it('upserts and reads scoped telegram non-secret config from KV', async () => { - const kv = createInMemoryKvClient(); - - await upsertChannelBridgeTelegramConfigInKv({ - kv, - serverId: 'local-3005', - accountId: 'acct-1', - update: { - tickMs: 2400, - botToken: 'bot-token-1', - webhookSecret: 'secret-1', - allowedChatIds: ['-100111'], - requireTopics: true, - }, - }); - - const config = await readChannelBridgeTelegramConfigFromKv({ - kv, - serverId: 'local-3005', - accountId: 'acct-1', - }); - - expect(config.record).toMatchObject({ - tickMs: 2400, - telegram: { - allowedChatIds: ['-100111'], - requireTopics: true, - }, - }); - const telegram = config.record?.telegram as { botToken?: string } | undefined; - expect(telegram?.botToken).toBeUndefined(); - expect(config.version).toBe(0); - }); - - it('isolates telegram config by account scope on the same server', async () => { - const kv = createInMemoryKvClient(); - - await upsertChannelBridgeTelegramConfigInKv({ - kv, - serverId: 'local-3005', - accountId: 'acct-1', - update: { - requireTopics: true, - }, - }); - - await upsertChannelBridgeTelegramConfigInKv({ - kv, - serverId: 'local-3005', - accountId: 'acct-2', - update: { - allowedChatIds: ['-100222'], - }, - }); - - const acct1 = await readChannelBridgeTelegramConfigFromKv({ - kv, - serverId: 'local-3005', - accountId: 'acct-1', - }); - const acct2 = await readChannelBridgeTelegramConfigFromKv({ - kv, - serverId: 'local-3005', - accountId: 'acct-2', - }); - - expect(acct1.record?.telegram.requireTopics).toBe(true); - expect(acct1.record?.telegram.allowedChatIds).toBeUndefined(); - expect(acct2.record?.telegram.allowedChatIds).toEqual(['-100222']); - expect(acct2.record?.telegram.requireTopics).toBeUndefined(); - }); - - it('ignores secret-only updates and leaves KV untouched', async () => { - const kv = createInMemoryKvClient(); - - await upsertChannelBridgeTelegramConfigInKv({ - kv, - serverId: 'local-3005', - accountId: 'acct-1', - update: { - botToken: 'bot-token-1', - webhookSecret: 'secret-1', - }, - }); - - const config = await readChannelBridgeTelegramConfigFromKv({ - kv, - serverId: 'local-3005', - accountId: 'acct-1', - }); - - expect(config.record).toBeNull(); - expect(config.version).toBe(-1); - }); - - it('clears scoped telegram config from KV', async () => { - const kv = createInMemoryKvClient(); - - await upsertChannelBridgeTelegramConfigInKv({ - kv, - serverId: 'local-3005', - accountId: 'acct-1', - update: { - allowedChatIds: ['-100111'], - }, - }); - - await clearChannelBridgeTelegramConfigInKv({ - kv, - serverId: 'local-3005', - accountId: 'acct-1', - }); - - const config = await readChannelBridgeTelegramConfigFromKv({ - kv, - serverId: 'local-3005', - accountId: 'acct-1', - }); - - expect(config.record).toBeNull(); - }); - - it('rejects malformed non-empty allowedChatIds updates', async () => { - const kv = createInMemoryKvClient(); - - await expect(upsertChannelBridgeTelegramConfigInKv({ - kv, - serverId: 'local-3005', - accountId: 'acct-1', - update: { - allowedChatIds: [' '], - }, - })).rejects.toThrow('Invalid telegram.allowedChatIds update payload'); - }); - - it('rejects mixed valid/invalid allowedChatIds updates', async () => { - const kv = createInMemoryKvClient(); - - await expect(upsertChannelBridgeTelegramConfigInKv({ - kv, - serverId: 'local-3005', - accountId: 'acct-1', - update: { - allowedChatIds: ['-100111', ' '], - }, - })).rejects.toThrow('Invalid telegram.allowedChatIds update payload'); - }); - - it('allows explicit allow-all updates with empty allowedChatIds', async () => { - const kv = createInMemoryKvClient(); - - await upsertChannelBridgeTelegramConfigInKv({ - kv, - serverId: 'local-3005', - accountId: 'acct-1', - update: { - allowedChatIds: ['-100111'], - }, - }); - - await upsertChannelBridgeTelegramConfigInKv({ - kv, - serverId: 'local-3005', - accountId: 'acct-1', - update: { - allowedChatIds: [], - }, - }); - - const config = await readChannelBridgeTelegramConfigFromKv({ - kv, - serverId: 'local-3005', - accountId: 'acct-1', - }); - - expect(config.record?.telegram.allowedChatIds).toEqual([]); - }); - - it('rejects invalid webhook port updates', async () => { - const kv = createInMemoryKvClient(); - - await expect(upsertChannelBridgeTelegramConfigInKv({ - kv, - serverId: 'local-3005', - accountId: 'acct-1', - update: { - webhookPort: 0, - }, - })).rejects.toThrow('Invalid telegram.webhook.port update payload'); - }); - - it('rejects out-of-range tickMs updates', async () => { - const kv = createInMemoryKvClient(); - - await expect(upsertChannelBridgeTelegramConfigInKv({ - kv, - serverId: 'local-3005', - accountId: 'acct-1', - update: { - tickMs: 100, - }, - })).rejects.toThrow('Invalid tickMs update payload'); - - await expect(upsertChannelBridgeTelegramConfigInKv({ - kv, - serverId: 'local-3005', - accountId: 'acct-1', - update: { - tickMs: 120_000, - }, - })).rejects.toThrow('Invalid tickMs update payload'); - }); - - it('rejects non-loopback webhook host updates', async () => { - const kv = createInMemoryKvClient(); - - await expect(upsertChannelBridgeTelegramConfigInKv({ - kv, - serverId: 'local-3005', - accountId: 'acct-1', - update: { - webhookHost: '0.0.0.0', - }, - })).rejects.toThrow('Invalid telegram.webhook.host update payload'); - }); - - it('rejects malformed persisted webhook payloads during read', async () => { - const kv = createInMemoryKvClient(); - - const malformedWebhookPayload = Buffer.from(JSON.stringify({ - schemaVersion: 1, - telegram: { - webhook: 'not-an-object', - }, - updatedAtMs: 1, - }), 'utf8').toString('base64'); - - await replaceChannelBridgeTelegramConfigRawInKv({ - kv, - serverId: 'local-3005', - accountId: 'acct-1', - valueBase64: malformedWebhookPayload, - }); - - await expect(readChannelBridgeTelegramConfigFromKv({ - kv, - serverId: 'local-3005', - accountId: 'acct-1', - })).rejects.toThrow('Invalid telegram.webhook payload'); - - const emptyWebhookHostPayload = Buffer.from(JSON.stringify({ - schemaVersion: 1, - telegram: { - webhook: { - host: ' ', - }, - }, - updatedAtMs: 2, - }), 'utf8').toString('base64'); - - await replaceChannelBridgeTelegramConfigRawInKv({ - kv, - serverId: 'local-3005', - accountId: 'acct-1', - valueBase64: emptyWebhookHostPayload, - }); - - await expect(readChannelBridgeTelegramConfigFromKv({ - kv, - serverId: 'local-3005', - accountId: 'acct-1', - })).rejects.toThrow('Invalid telegram.webhook.host payload'); - }); - - it('replaces raw telegram config bytes in KV without schema parsing', async () => { - const kv = createInMemoryKvClient(); - - await upsertChannelBridgeTelegramConfigInKv({ - kv, - serverId: 'local-3005', - accountId: 'acct-1', - update: { - requireTopics: true, - }, - }); - - const rawUnsupported = Buffer.from(JSON.stringify({ - schemaVersion: 999, - unsupported: true, - }), 'utf8').toString('base64'); - - await replaceChannelBridgeTelegramConfigRawInKv({ - kv, - serverId: 'local-3005', - accountId: 'acct-1', - valueBase64: rawUnsupported, - }); - - const readWithUnsupported = await readChannelBridgeTelegramConfigFromKv({ - kv, - serverId: 'local-3005', - accountId: 'acct-1', - allowUnsupportedSchema: true, - }); - - expect(readWithUnsupported.record).toBeNull(); - expect(readWithUnsupported.rawValueBase64).toBe(rawUnsupported); - }); - - it('refuses raw replace when expected current version does not match', async () => { - const kv = createInMemoryKvClient(); - - await upsertChannelBridgeTelegramConfigInKv({ - kv, - serverId: 'local-3005', - accountId: 'acct-1', - update: { - requireTopics: true, - }, - }); - - const rawUnsupported = Buffer.from(JSON.stringify({ - schemaVersion: 999, - unsupported: true, - }), 'utf8').toString('base64'); - - await expect(replaceChannelBridgeTelegramConfigRawInKv({ - kv, - serverId: 'local-3005', - accountId: 'acct-1', - valueBase64: rawUnsupported, - expectedCurrentVersion: 999, - })).rejects.toThrow('KV version mismatch'); - }); - - it('upsert replaces unsupported telegram config schema instead of failing', async () => { - const unknownSchemaValueBase64 = Buffer.from(JSON.stringify({ - schemaVersion: 999, - telegram: { - allowedChatIds: ['-100legacy'], - }, - updatedAtMs: 111, - }), 'utf8').toString('base64'); - - const key = 'happier:channel-bridge:v1:server:local-3005:account:acct-1:telegram-config'; - const byKey = new Map(); - byKey.set(key, { - value: unknownSchemaValueBase64, - version: 4, - }); - - const kv: ChannelBridgeKvClient = { - get: async (requestedKey) => { - const row = byKey.get(requestedKey); - if (!row || row.value === null) { - return { status: 404, body: { error: 'Key not found' } }; - } - return { - status: 200, - body: { - key: requestedKey, - value: row.value, - version: row.version, - }, - }; - }, - mutate: async (mutations) => { - const errors: Array<{ key: string; error: 'version-mismatch'; version: number; value: string | null }> = []; - for (const mutation of mutations) { - const row = byKey.get(mutation.key); - const currentVersion = row?.version ?? -1; - if (currentVersion !== mutation.version) { - errors.push({ - key: mutation.key, - error: 'version-mismatch', - version: currentVersion, - value: row?.value ?? null, - }); - } - } - - if (errors.length > 0) { - return { status: 409, body: { success: false, errors } }; - } - - const results: Array<{ key: string; version: number }> = []; - for (const mutation of mutations) { - const row = byKey.get(mutation.key); - const nextVersion = (row?.version ?? -1) + 1; - byKey.set(mutation.key, { - value: mutation.value, - version: nextVersion, - }); - results.push({ key: mutation.key, version: nextVersion }); - } - - return { status: 200, body: { success: true, results } }; - }, - }; - - await expect(upsertChannelBridgeTelegramConfigInKv({ - kv, - serverId: 'local-3005', - accountId: 'acct-1', - update: { - allowedChatIds: ['-100111'], - }, - })).resolves.toBe(5); - - const config = await readChannelBridgeTelegramConfigFromKv({ - kv, - serverId: 'local-3005', - accountId: 'acct-1', - }); - - expect(config.record).toMatchObject({ - schemaVersion: 1, - telegram: { - allowedChatIds: ['-100111'], - }, - }); - expect(config.version).toBe(5); - }); - - it('retries upsert after version mismatch using conflict payload without extra read', async () => { - const key = 'happier:channel-bridge:v1:server:local-3005:account:acct-1:telegram-config'; - const initialValueBase64 = Buffer.from(JSON.stringify({ - schemaVersion: 1, - telegram: { - allowedChatIds: ['-100111'], - }, - updatedAtMs: 101, - }), 'utf8').toString('base64'); - const conflictValueBase64 = Buffer.from(JSON.stringify({ - schemaVersion: 1, - telegram: { - allowedChatIds: ['-100111'], - requireTopics: false, - }, - updatedAtMs: 202, - }), 'utf8').toString('base64'); - - let storedVersion = 0; - let storedValue = initialValueBase64; - let getCalls = 0; - let mutateCalls = 0; - - const kv: ChannelBridgeKvClient = { - get: async (requestedKey) => { - getCalls += 1; - return { - status: 200, - body: { - key: requestedKey, - value: storedValue, - version: storedVersion, - }, - }; - }, - mutate: async (mutations) => { - mutateCalls += 1; - const [mutation] = mutations; - if (!mutation) { - return { - status: 400, - body: { - success: false, - errors: [{ key, error: 'version-mismatch', version: storedVersion, value: storedValue }], - }, - }; - } - - if (mutateCalls === 1) { - storedVersion = 1; - storedValue = conflictValueBase64; - return { - status: 409, - body: { - success: false, - errors: [{ key: mutation.key, error: 'version-mismatch', version: storedVersion, value: storedValue }], - }, - }; - } - - storedVersion += 1; - storedValue = mutation.value ?? ''; - return { - status: 200, - body: { - success: true, - results: [{ key: mutation.key, version: storedVersion }], - }, - }; - }, - }; - - await upsertChannelBridgeTelegramConfigInKv({ - kv, - serverId: 'local-3005', - accountId: 'acct-1', - update: { - requireTopics: true, - }, - }); - - expect(getCalls).toBe(1); - expect(mutateCalls).toBe(2); - const finalParsed = JSON.parse(Buffer.from(storedValue, 'base64').toString('utf8')) as { - telegram?: { requireTopics?: boolean }; - }; - expect(finalParsed.telegram?.requireTopics).toBe(true); - }); - - it('retries clear after version mismatch without extra reread', async () => { - const key = 'happier:channel-bridge:v1:server:local-3005:account:acct-1:telegram-config'; - const initialValueBase64 = Buffer.from(JSON.stringify({ - schemaVersion: 1, - telegram: { - allowedChatIds: ['-100111'], - }, - updatedAtMs: 101, - }), 'utf8').toString('base64'); - const conflictValueBase64 = Buffer.from(JSON.stringify({ - schemaVersion: 1, - telegram: { - allowedChatIds: ['-100222'], - }, - updatedAtMs: 202, - }), 'utf8').toString('base64'); - - let storedVersion = 0; - let storedValue: string | null = initialValueBase64; - let getCalls = 0; - let mutateCalls = 0; - - const kv: ChannelBridgeKvClient = { - get: async (requestedKey) => { - getCalls += 1; - if (storedValue === null) { - return { status: 404, body: { error: 'Key not found' } }; - } - return { - status: 200, - body: { - key: requestedKey, - value: storedValue, - version: storedVersion, - }, - }; - }, - mutate: async (mutations) => { - mutateCalls += 1; - const [mutation] = mutations; - if (!mutation) { - return { - status: 400, - body: { - success: false, - errors: [{ key, error: 'version-mismatch', version: storedVersion, value: storedValue }], - }, - }; - } - - if (mutateCalls === 1) { - storedVersion = 1; - storedValue = conflictValueBase64; - return { - status: 409, - body: { - success: false, - errors: [{ key: mutation.key, error: 'version-mismatch', version: storedVersion, value: storedValue }], - }, - }; - } - - storedVersion += 1; - storedValue = mutation.value ?? null; - return { - status: 200, - body: { - success: true, - results: [{ key: mutation.key, version: storedVersion }], - }, - }; - }, - }; - - await clearChannelBridgeTelegramConfigInKv({ - kv, - serverId: 'local-3005', - accountId: 'acct-1', - }); - - expect(getCalls).toBe(1); - expect(mutateCalls).toBe(2); - expect(storedValue).toBeNull(); - }); - - it('writes and reads bindings document from KV', async () => { - const kv = createInMemoryKvClient(); - - const first = await readChannelBridgeBindingsFromKv({ - kv, - serverId: 'local-3005', - accountId: 'acct-1', - }); - expect(first.version).toBe(-1); - expect(first.doc.bindings).toEqual([]); - - const writtenVersion = await writeChannelBridgeBindingsToKv({ - kv, - serverId: 'local-3005', - accountId: 'acct-1', - expectedVersion: first.version, - doc: { - schemaVersion: 1, - bindings: [ - { - providerId: 'telegram', - conversationId: '-100111', - threadId: '12', - sessionId: 'sess-1', - lastForwardedSeq: 9, - createdAtMs: 111, - updatedAtMs: 222, - }, - ], - }, - }); - - expect(writtenVersion).toBe(0); - - const second = await readChannelBridgeBindingsFromKv({ - kv, - serverId: 'local-3005', - accountId: 'acct-1', - }); - expect(second.version).toBe(0); - expect(second.doc.bindings).toHaveLength(1); - expect(second.doc.bindings[0]?.sessionId).toBe('sess-1'); - }); - - it('isolates bindings documents by account scope on the same server', async () => { - const kv = createInMemoryKvClient(); - - await writeChannelBridgeBindingsToKv({ - kv, - serverId: 'local-3005', - accountId: 'acct-1', - expectedVersion: -1, - doc: { - schemaVersion: 1, - bindings: [ - { - providerId: 'telegram', - conversationId: '-100111', - threadId: null, - sessionId: 'sess-a', - lastForwardedSeq: 1, - createdAtMs: 1000, - updatedAtMs: 1000, - }, - ], - }, - }); - - const acct1 = await readChannelBridgeBindingsFromKv({ - kv, - serverId: 'local-3005', - accountId: 'acct-1', - }); - const acct2 = await readChannelBridgeBindingsFromKv({ - kv, - serverId: 'local-3005', - accountId: 'acct-2', - }); - - expect(acct1.doc.bindings).toHaveLength(1); - expect(acct1.doc.bindings[0]?.sessionId).toBe('sess-a'); - expect(acct2.doc.bindings).toEqual([]); - expect(acct2.version).toBe(-1); - }); - - it('throws when bindings payload is invalid/corrupt in KV', async () => { - const invalidValueBase64 = Buffer.from(JSON.stringify({ schemaVersion: 999, bindings: [] }), 'utf8').toString('base64'); - const kv: ChannelBridgeKvClient = { - get: async () => ({ - status: 200, - body: { - key: 'k', - value: invalidValueBase64, - version: 12, - }, - }), - mutate: async () => ({ status: 200, body: { success: true, results: [] } }), - }; - - await expect(readChannelBridgeBindingsFromKv({ - kv, - serverId: 'local-3005', - accountId: 'acct-1', - })).rejects.toThrow('Invalid or unsupported channel bridge bindings payload in KV'); - }); - - it('throws when bindings payload includes malformed numeric fields', async () => { - const malformedValueBase64 = Buffer.from(JSON.stringify({ - schemaVersion: 1, - bindings: [ - { - providerId: 'telegram', - conversationId: '-100111', - sessionId: 'sess-1', - lastForwardedSeq: 'oops', - }, - ], - }), 'utf8').toString('base64'); - - const kv: ChannelBridgeKvClient = { - get: async () => ({ - status: 200, - body: { - key: 'k', - value: malformedValueBase64, - version: 5, - }, - }), - mutate: async () => ({ status: 200, body: { success: true, results: [] } }), - }; - - await expect(readChannelBridgeBindingsFromKv({ - kv, - serverId: 'local-3005', - accountId: 'acct-1', - })).rejects.toThrow('Invalid channel bridge binding lastForwardedSeq at index 0'); - }); - - it('throws when bindings payload omits required persisted cursor/timestamp fields', async () => { - const malformedValueBase64 = Buffer.from(JSON.stringify({ - schemaVersion: 1, - bindings: [ - { - providerId: 'telegram', - conversationId: '-100111', - sessionId: 'sess-1', - lastForwardedSeq: 3, - createdAtMs: 111, - }, - ], - }), 'utf8').toString('base64'); - - const kv: ChannelBridgeKvClient = { - get: async () => ({ - status: 200, - body: { - key: 'k', - value: malformedValueBase64, - version: 5, - }, - }), - mutate: async () => ({ status: 200, body: { success: true, results: [] } }), - }; - - await expect(readChannelBridgeBindingsFromKv({ - kv, - serverId: 'local-3005', - accountId: 'acct-1', - })).rejects.toThrow('Invalid channel bridge binding updatedAtMs at index 0'); - }); - - it('throws when bindings payload includes malformed thread ids', async () => { - const malformedValueBase64 = Buffer.from(JSON.stringify({ - schemaVersion: 1, - bindings: [ - { - providerId: 'telegram', - conversationId: '-100111', - sessionId: 'sess-1', - lastForwardedSeq: 1, - createdAtMs: 100, - updatedAtMs: 101, - threadId: 123, - }, - ], - }), 'utf8').toString('base64'); - - const kv: ChannelBridgeKvClient = { - get: async () => ({ - status: 200, - body: { - key: 'k', - value: malformedValueBase64, - version: 5, - }, - }), - mutate: async () => ({ status: 200, body: { success: true, results: [] } }), - }; - - await expect(readChannelBridgeBindingsFromKv({ - kv, - serverId: 'local-3005', - accountId: 'acct-1', - })).rejects.toThrow('Invalid channel bridge binding threadId at index 0'); - }); - - it('throws when bindings payload includes malformed binding rows', async () => { - const malformedValueBase64 = Buffer.from(JSON.stringify({ - schemaVersion: 1, - bindings: [ - { - providerId: 'telegram', - conversationId: '-100111', - threadId: null, - sessionId: 'sess-1', - lastForwardedSeq: 9, - createdAtMs: 111, - updatedAtMs: 222, - }, - { - providerId: '', - conversationId: '-100222', - sessionId: 'sess-2', - }, - ], - }), 'utf8').toString('base64'); - - const kv: ChannelBridgeKvClient = { - get: async () => ({ - status: 200, - body: { - key: 'k', - value: malformedValueBase64, - version: 5, - }, - }), - mutate: async () => ({ status: 200, body: { success: true, results: [] } }), - }; - - await expect(readChannelBridgeBindingsFromKv({ - kv, - serverId: 'local-3005', - accountId: 'acct-1', - })).rejects.toThrow('Invalid channel bridge binding identity fields at index 1'); - }); - - it('throws when telegram config payload is invalid/corrupt in KV', async () => { - const invalidValueBase64 = Buffer.from(JSON.stringify({ schemaVersion: 999, telegram: {} }), 'utf8').toString('base64'); - const kv: ChannelBridgeKvClient = { - get: async () => ({ - status: 200, - body: { - key: 'k', - value: invalidValueBase64, - version: 3, - }, - }), - mutate: async () => ({ status: 200, body: { success: true, results: [] } }), - }; - - await expect(readChannelBridgeTelegramConfigFromKv({ - kv, - serverId: 'local-3005', - accountId: 'acct-1', - })).rejects.toThrow('Invalid or unsupported Telegram config schema'); - }); - - it('throws when telegram config payload contains malformed non-empty allowedChatIds', async () => { - const malformedAllowedChatIdsValueBase64 = Buffer.from(JSON.stringify({ - schemaVersion: 1, - telegram: { - allowedChatIds: [' ', 123, null], - }, - updatedAtMs: 123, - }), 'utf8').toString('base64'); - const kv: ChannelBridgeKvClient = { - get: async () => ({ - status: 200, - body: { - key: 'k', - value: malformedAllowedChatIdsValueBase64, - version: 3, - }, - }), - mutate: async () => ({ status: 200, body: { success: true, results: [] } }), - }; - - await expect(readChannelBridgeTelegramConfigFromKv({ - kv, - serverId: 'local-3005', - accountId: 'acct-1', - })).rejects.toThrow('Invalid telegram.allowedChatIds payload'); - }); - - it('throws when telegram config payload contains mixed valid/invalid allowedChatIds', async () => { - const mixedAllowedChatIdsValueBase64 = Buffer.from(JSON.stringify({ - schemaVersion: 1, - telegram: { - allowedChatIds: ['-100123', ' ', 123], - }, - updatedAtMs: 123, - }), 'utf8').toString('base64'); - const kv: ChannelBridgeKvClient = { - get: async () => ({ - status: 200, - body: { - key: 'k', - value: mixedAllowedChatIdsValueBase64, - version: 3, - }, - }), - mutate: async () => ({ status: 200, body: { success: true, results: [] } }), - }; - - await expect(readChannelBridgeTelegramConfigFromKv({ - kv, - serverId: 'local-3005', - accountId: 'acct-1', - })).rejects.toThrow('Invalid telegram.allowedChatIds payload'); - }); - - it('throws when telegram config payload contains non-array allowedChatIds', async () => { - const malformedAllowedChatIdsTypeValueBase64 = Buffer.from(JSON.stringify({ - schemaVersion: 1, - telegram: { - allowedChatIds: '-100123', - }, - updatedAtMs: 123, - }), 'utf8').toString('base64'); - const kv: ChannelBridgeKvClient = { - get: async () => ({ - status: 200, - body: { - key: 'k', - value: malformedAllowedChatIdsTypeValueBase64, - version: 3, - }, - }), - mutate: async () => ({ status: 200, body: { success: true, results: [] } }), - }; - - await expect(readChannelBridgeTelegramConfigFromKv({ - kv, - serverId: 'local-3005', - accountId: 'acct-1', - })).rejects.toThrow('Invalid telegram.allowedChatIds payload'); - }); - - it('throws when telegram config payload contains invalid webhook.port', async () => { - const invalidWebhookPortValueBase64 = Buffer.from(JSON.stringify({ - schemaVersion: 1, - telegram: { - webhook: { - enabled: true, - host: '127.0.0.1', - port: 70_000, - }, - }, - updatedAtMs: 123, - }), 'utf8').toString('base64'); - const kv: ChannelBridgeKvClient = { - get: async () => ({ - status: 200, - body: { - key: 'k', - value: invalidWebhookPortValueBase64, - version: 3, - }, - }), - mutate: async () => ({ status: 200, body: { success: true, results: [] } }), - }; - - await expect(readChannelBridgeTelegramConfigFromKv({ - kv, - serverId: 'local-3005', - accountId: 'acct-1', - })).rejects.toThrow('Invalid telegram.webhook.port payload'); - }); - - it('throws when telegram config payload contains non-loopback webhook.host', async () => { - const invalidWebhookHostValueBase64 = Buffer.from(JSON.stringify({ - schemaVersion: 1, - telegram: { - webhook: { - enabled: true, - host: '0.0.0.0', - port: 8787, - }, - }, - updatedAtMs: 123, - }), 'utf8').toString('base64'); - const kv: ChannelBridgeKvClient = { - get: async () => ({ - status: 200, - body: { - key: 'k', - value: invalidWebhookHostValueBase64, - version: 3, - }, - }), - mutate: async () => ({ status: 200, body: { success: true, results: [] } }), - }; - - await expect(readChannelBridgeTelegramConfigFromKv({ - kv, - serverId: 'local-3005', - accountId: 'acct-1', - })).rejects.toThrow('Invalid telegram.webhook.host payload'); - }); - - it('treats invalid webhook.host payload as recoverable when allowUnsupportedSchema=true', async () => { - const invalidWebhookHostValueBase64 = Buffer.from(JSON.stringify({ - schemaVersion: 1, - telegram: { - webhook: { - enabled: true, - host: '0.0.0.0', - port: 8787, - }, - }, - updatedAtMs: 123, - }), 'utf8').toString('base64'); - const kv: ChannelBridgeKvClient = { - get: async () => ({ - status: 200, - body: { - key: 'k', - value: invalidWebhookHostValueBase64, - version: 7, - }, - }), - mutate: async () => ({ status: 200, body: { success: true, results: [] } }), - }; - - await expect(readChannelBridgeTelegramConfigFromKv({ - kv, - serverId: 'local-3005', - accountId: 'acct-1', - allowUnsupportedSchema: true, - })).resolves.toEqual({ - record: null, - version: 7, - rawValueBase64: invalidWebhookHostValueBase64, - }); - }); - - it('treats malformed telegram config payload as recoverable when allowUnsupportedSchema=true', async () => { - const malformedValueBase64 = Buffer.from('not-json', 'utf8').toString('base64'); - const kv: ChannelBridgeKvClient = { - get: async () => ({ - status: 200, - body: { - key: 'k', - value: malformedValueBase64, - version: 3, - }, - }), - mutate: async () => ({ status: 200, body: { success: true, results: [] } }), - }; - - await expect(readChannelBridgeTelegramConfigFromKv({ - kv, - serverId: 'local-3005', - accountId: 'acct-1', - allowUnsupportedSchema: true, - })).resolves.toEqual({ - record: null, - version: 3, - rawValueBase64: malformedValueBase64, - }); - }); -}); diff --git a/apps/cli/src/channels/channelBridgeServerKv.ts b/apps/cli/src/channels/channelBridgeServerKv.ts deleted file mode 100644 index 980d123eba..0000000000 --- a/apps/cli/src/channels/channelBridgeServerKv.ts +++ /dev/null @@ -1,768 +0,0 @@ -/** - * Channel bridge server-KV transport and schema helpers. - * - * Responsibilities: - * - encode/decode scoped bridge documents stored in server KV - * - validate supported schema versions for telegram config and binding docs - * - provide optimistic-write friendly KV client helpers and conflict parsing - * - keep bridge server payloads free of local-only secret fields - */ -import axios from 'axios'; - -import { resolveServerHttpBaseUrl } from '@/session/transport/http/serverHttpBaseUrl'; -import { isLoopbackHost } from '@/channels/telegram/telegramWebhookRelay'; - -import type { ScopedTelegramBridgeUpdate } from './channelBridgeAccountConfig'; - -const CHANNEL_BRIDGE_KV_PREFIX = 'happier:channel-bridge:v1'; - -const TELEGRAM_CONFIG_SCHEMA_VERSION = 1; -const BINDINGS_SCHEMA_VERSION = 1; - -export type ChannelBridgeServerTelegramConfigRecord = Readonly<{ - schemaVersion: 1; - tickMs?: number; - telegram: Readonly<{ - allowedChatIds?: string[]; - requireTopics?: boolean; - webhook?: Readonly<{ - enabled?: boolean; - host?: string; - port?: number; - }>; - }>; - updatedAtMs: number; -}>; - -export type ChannelBridgeServerBindingRecord = Readonly<{ - providerId: string; - conversationId: string; - threadId: string | null; - sessionId: string; - lastForwardedSeq: number; - createdAtMs: number; - updatedAtMs: number; -}>; - -export type ChannelBridgeServerBindingsDocument = Readonly<{ - schemaVersion: 1; - bindings: ChannelBridgeServerBindingRecord[]; -}>; - -export type ChannelBridgeKvGetResponse = Readonly<{ - status: number; - body: unknown; -}>; - -export type ChannelBridgeKvMutateResponse = Readonly<{ - status: number; - body: unknown; -}>; - -export type ChannelBridgeKvMutation = Readonly<{ - key: string; - value: string | null; - version: number; -}>; - -export type ChannelBridgeKvClient = Readonly<{ - get: (key: string) => Promise; - mutate: (mutations: readonly ChannelBridgeKvMutation[]) => Promise; -}>; - -export class ChannelBridgeKvVersionMismatchError extends Error { - readonly currentVersion: number; - readonly currentValueBase64: string | null; - - constructor(params: Readonly<{ key: string; currentVersion: number; currentValueBase64: string | null }>) { - super(`KV version mismatch for key ${params.key}`); - this.name = 'ChannelBridgeKvVersionMismatchError'; - this.currentVersion = params.currentVersion; - this.currentValueBase64 = params.currentValueBase64; - } -} - -export class ChannelBridgeBadPayloadError extends Error { - constructor(message: string) { - super(message); - this.name = 'ChannelBridgeBadPayloadError'; - } -} - -function encodeJsonToBase64(value: unknown): string { - return Buffer.from(JSON.stringify(value), 'utf8').toString('base64'); -} - -function decodeBase64ToJson(valueBase64: string): unknown { - try { - const raw = Buffer.from(valueBase64, 'base64').toString('utf8'); - return JSON.parse(raw); - } catch (error) { - throw new ChannelBridgeBadPayloadError( - error instanceof Error - ? `Invalid channel bridge KV payload: ${error.message}` - : 'Invalid channel bridge KV payload', - ); - } -} - -function asRecord(value: unknown): Record | null { - if (!value || typeof value !== 'object' || Array.isArray(value)) return null; - return value as Record; -} - -function normalizeStringArray(values: readonly unknown[]): string[] { - return values - .map((value) => (typeof value === 'string' ? value.trim() : '')) - .filter((value) => value.length > 0); -} - -function hasOnlyNonEmptyStringValues(values: readonly unknown[]): boolean { - return values.every((value) => typeof value === 'string' && value.trim().length > 0); -} - -function parseKvEntry(value: unknown): Readonly<{ valueBase64: string; version: number }> | null { - const record = asRecord(value); - if (!record) return null; - if (typeof record.value !== 'string') return null; - if (typeof record.version !== 'number' || !Number.isFinite(record.version)) return null; - return { - valueBase64: record.value, - version: Math.trunc(record.version), - }; -} - -function parseVersionMismatchError(value: unknown): Readonly<{ currentVersion: number; currentValueBase64: string | null }> | null { - const record = asRecord(value); - if (!record) return null; - const errors = Array.isArray(record.errors) ? record.errors : null; - if (!errors || errors.length === 0) return null; - const first = asRecord(errors[0]); - if (!first) return null; - if (typeof first.version !== 'number' || !Number.isFinite(first.version)) return null; - const currentValueBase64 = typeof first.value === 'string' ? first.value : null; - return { - currentVersion: Math.trunc(first.version), - currentValueBase64, - }; -} - -function assertSecureChannelBridgeKvBaseUrl(baseUrl: string): void { - let parsed: URL; - try { - parsed = new URL(baseUrl); - } catch { - throw new Error(`Invalid channel bridge KV base URL: ${baseUrl}`); - } - - if (parsed.protocol === 'https:') return; - if (parsed.protocol === 'http:' && isLoopbackHost(parsed.hostname)) return; - - throw new Error( - `Insecure channel bridge KV base URL: ${baseUrl}. Use https or explicit loopback http for local development.`, - ); -} - -function parseTelegramConfigRecord(value: unknown): ChannelBridgeServerTelegramConfigRecord | null { - const record = asRecord(value); - if (!record) return null; - if (record.schemaVersion !== TELEGRAM_CONFIG_SCHEMA_VERSION) return null; - const telegram = asRecord(record.telegram); - if (!telegram) return null; - - const out: { - schemaVersion: 1; - tickMs?: number; - telegram: { - allowedChatIds?: string[]; - requireTopics?: boolean; - webhook?: { - enabled?: boolean; - host?: string; - port?: number; - }; - }; - updatedAtMs: number; - } = { - schemaVersion: 1, - telegram: {}, - updatedAtMs: typeof record.updatedAtMs === 'number' && Number.isFinite(record.updatedAtMs) - ? Math.trunc(record.updatedAtMs) - : Date.now(), - }; - - if (typeof record.tickMs === 'number' && Number.isFinite(record.tickMs)) { - out.tickMs = Math.trunc(record.tickMs); - } - - if (telegram.allowedChatIds !== undefined) { - if (!Array.isArray(telegram.allowedChatIds)) { - throw new ChannelBridgeBadPayloadError('Invalid telegram.allowedChatIds payload'); - } - const normalizedAllowedChatIds = normalizeStringArray(telegram.allowedChatIds); - if ( - telegram.allowedChatIds.length > 0 - && (!hasOnlyNonEmptyStringValues(telegram.allowedChatIds) - || normalizedAllowedChatIds.length !== telegram.allowedChatIds.length) - ) { - throw new ChannelBridgeBadPayloadError('Invalid telegram.allowedChatIds payload'); - } - out.telegram.allowedChatIds = normalizedAllowedChatIds; - } - if (typeof telegram.requireTopics === 'boolean') { - out.telegram.requireTopics = telegram.requireTopics; - } - - if (telegram.webhook !== undefined) { - const webhook = asRecord(telegram.webhook); - if (!webhook) { - throw new ChannelBridgeBadPayloadError('Invalid telegram.webhook payload'); - } - - const outWebhook: { enabled?: boolean; host?: string; port?: number } = {}; - if (typeof webhook.enabled === 'boolean') outWebhook.enabled = webhook.enabled; - - if (webhook.host !== undefined) { - if (typeof webhook.host !== 'string') { - throw new ChannelBridgeBadPayloadError('Invalid telegram.webhook.host payload'); - } - const trimmedHost = webhook.host.trim(); - if (trimmedHost.length === 0 || !isLoopbackHost(trimmedHost)) { - throw new ChannelBridgeBadPayloadError('Invalid telegram.webhook.host payload'); - } - outWebhook.host = trimmedHost; - } - - if (webhook.port !== undefined) { - if ( - typeof webhook.port !== 'number' - || !Number.isFinite(webhook.port) - || !Number.isInteger(webhook.port) - || webhook.port < 1 - || webhook.port > 65_535 - ) { - throw new ChannelBridgeBadPayloadError('Invalid telegram.webhook.port payload'); - } - outWebhook.port = webhook.port; - } - - if (Object.keys(outWebhook).length === 0) { - throw new ChannelBridgeBadPayloadError('Invalid telegram.webhook payload'); - } - - out.telegram.webhook = outWebhook; - } - - return out; -} - -function parseBindingsDocument(value: unknown): ChannelBridgeServerBindingsDocument | null { - const record = asRecord(value); - if (!record) return null; - if (record.schemaVersion !== BINDINGS_SCHEMA_VERSION) return null; - const rawBindings = Array.isArray(record.bindings) ? record.bindings : null; - if (!rawBindings) return null; - - const bindings: ChannelBridgeServerBindingRecord[] = []; - for (let index = 0; index < rawBindings.length; index += 1) { - const item = asRecord(rawBindings[index]); - if (!item) { - throw new ChannelBridgeBadPayloadError(`Invalid channel bridge binding entry at index ${index}`); - } - const providerId = typeof item.providerId === 'string' ? item.providerId.trim() : ''; - const conversationId = typeof item.conversationId === 'string' ? item.conversationId.trim() : ''; - const sessionId = typeof item.sessionId === 'string' ? item.sessionId.trim() : ''; - if (!providerId || !conversationId || !sessionId) { - throw new ChannelBridgeBadPayloadError(`Invalid channel bridge binding identity fields at index ${index}`); - } - - if ( - typeof item.lastForwardedSeq !== 'number' - || !Number.isFinite(item.lastForwardedSeq) - || item.lastForwardedSeq < 0 - ) { - throw new ChannelBridgeBadPayloadError(`Invalid channel bridge binding lastForwardedSeq at index ${index}`); - } - if (typeof item.createdAtMs !== 'number' || !Number.isFinite(item.createdAtMs)) { - throw new ChannelBridgeBadPayloadError(`Invalid channel bridge binding createdAtMs at index ${index}`); - } - if (typeof item.updatedAtMs !== 'number' || !Number.isFinite(item.updatedAtMs)) { - throw new ChannelBridgeBadPayloadError(`Invalid channel bridge binding updatedAtMs at index ${index}`); - } - - if ( - item.threadId !== undefined - && item.threadId !== null - && typeof item.threadId !== 'string' - ) { - throw new ChannelBridgeBadPayloadError(`Invalid channel bridge binding threadId at index ${index}`); - } - - const threadIdRaw = typeof item.threadId === 'string' ? item.threadId.trim() : ''; - const threadId = threadIdRaw.length > 0 ? threadIdRaw : null; - const lastForwardedSeq = Math.trunc(item.lastForwardedSeq); - const createdAtMs = Math.trunc(item.createdAtMs); - const updatedAtMs = Math.trunc(item.updatedAtMs); - bindings.push({ - providerId, - conversationId, - threadId, - sessionId, - lastForwardedSeq, - createdAtMs, - updatedAtMs, - }); - } - - return { - schemaVersion: 1, - bindings, - }; -} - -function telegramConfigKvKey(serverId: string, accountId: string): string { - return `${CHANNEL_BRIDGE_KV_PREFIX}:server:${serverId}:account:${accountId}:telegram-config`; -} - -function bindingsKvKey(serverId: string, accountId: string): string { - return `${CHANNEL_BRIDGE_KV_PREFIX}:server:${serverId}:account:${accountId}:bindings`; -} - -async function readJsonValue(params: Readonly<{ - kv: ChannelBridgeKvClient; - key: string; -}>): Promise> { - const response = await params.kv.get(params.key); - if (response.status === 404) { - return { valueBase64: null, version: -1 }; - } - if (response.status !== 200) { - throw new Error(`KV read failed for ${params.key}: HTTP ${response.status}`); - } - - const parsed = parseKvEntry(response.body); - if (!parsed) { - throw new Error(`KV read returned invalid payload for ${params.key}`); - } - return { - valueBase64: parsed.valueBase64, - version: parsed.version, - }; -} - -async function writeJsonValue(params: Readonly<{ - kv: ChannelBridgeKvClient; - key: string; - valueBase64: string | null; - expectedVersion: number; -}>): Promise { - const response = await params.kv.mutate([ - { - key: params.key, - value: params.valueBase64, - version: params.expectedVersion, - }, - ]); - - if (response.status === 200) { - const body = asRecord(response.body); - const results = body && Array.isArray(body.results) ? body.results : null; - const first = results && results.length > 0 ? asRecord(results[0]) : null; - if (!first || typeof first.version !== 'number' || !Number.isFinite(first.version)) { - throw new Error(`KV write returned invalid success payload for ${params.key}`); - } - return Math.trunc(first.version); - } - - if (response.status === 409) { - const mismatch = parseVersionMismatchError(response.body); - if (!mismatch) { - throw new Error(`KV write version mismatch payload invalid for ${params.key}`); - } - throw new ChannelBridgeKvVersionMismatchError({ - key: params.key, - currentVersion: mismatch.currentVersion, - currentValueBase64: mismatch.currentValueBase64, - }); - } - - throw new Error(`KV write failed for ${params.key}: HTTP ${response.status}`); -} - -export function decodeChannelBridgeBindingsDocFromBase64(valueBase64: string | null): ChannelBridgeServerBindingsDocument { - if (!valueBase64) { - return { schemaVersion: 1, bindings: [] }; - } - const parsed = parseBindingsDocument(decodeBase64ToJson(valueBase64)); - if (!parsed) { - throw new ChannelBridgeBadPayloadError('Invalid or unsupported channel bridge bindings payload in KV'); - } - return parsed; -} - -export function createAxiosChannelBridgeKvClient(params: Readonly<{ token: string }>): ChannelBridgeKvClient { - const token = params.token; - const baseUrl = resolveServerHttpBaseUrl(); - assertSecureChannelBridgeKvBaseUrl(baseUrl); - - return { - get: async (key) => { - const response = await axios.get(`${baseUrl}/v1/kv/${encodeURIComponent(key)}`, { - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - timeout: 15_000, - validateStatus: () => true, - }); - return { - status: response.status, - body: response.data, - }; - }, - mutate: async (mutations) => { - const response = await axios.post(`${baseUrl}/v1/kv`, { - mutations, - }, { - headers: { - Authorization: `Bearer ${token}`, - 'Content-Type': 'application/json', - }, - timeout: 15_000, - validateStatus: () => true, - }); - - return { - status: response.status, - body: response.data, - }; - }, - }; -} - -export async function readChannelBridgeTelegramConfigFromKv(params: Readonly<{ - kv: ChannelBridgeKvClient; - serverId: string; - accountId: string; - allowUnsupportedSchema?: boolean; -}>): Promise> { - const key = telegramConfigKvKey(params.serverId, params.accountId); - const row = await readJsonValue({ kv: params.kv, key }); - if (!row.valueBase64) { - return { record: null, version: row.version, rawValueBase64: null }; - } - - let decoded: unknown; - try { - decoded = decodeBase64ToJson(row.valueBase64); - } catch (error) { - if (params.allowUnsupportedSchema && error instanceof ChannelBridgeBadPayloadError) { - return { record: null, version: row.version, rawValueBase64: row.valueBase64 }; - } - throw error; - } - - let parsed: ChannelBridgeServerTelegramConfigRecord | null; - try { - parsed = parseTelegramConfigRecord(decoded); - } catch (error) { - if (params.allowUnsupportedSchema && error instanceof ChannelBridgeBadPayloadError) { - return { record: null, version: row.version, rawValueBase64: row.valueBase64 }; - } - throw error; - } - if (!parsed) { - if (params.allowUnsupportedSchema) { - return { record: null, version: row.version, rawValueBase64: row.valueBase64 }; - } - throw new Error(`Invalid or unsupported Telegram config schema for key ${key}`); - } - - return { - record: parsed, - version: row.version, - rawValueBase64: row.valueBase64, - }; -} - -export async function replaceChannelBridgeTelegramConfigRawInKv(params: Readonly<{ - kv: ChannelBridgeKvClient; - serverId: string; - accountId: string; - valueBase64: string | null; - expectedCurrentVersion?: number; -}>): Promise { - const key = telegramConfigKvKey(params.serverId, params.accountId); - - if (typeof params.expectedCurrentVersion === 'number' && Number.isFinite(params.expectedCurrentVersion)) { - const expectedCurrentVersion = Math.trunc(params.expectedCurrentVersion); - const current = await readJsonValue({ kv: params.kv, key }); - if (current.version !== expectedCurrentVersion) { - throw new ChannelBridgeKvVersionMismatchError({ - key, - currentVersion: current.version, - currentValueBase64: current.valueBase64, - }); - } - - await writeJsonValue({ - kv: params.kv, - key, - valueBase64: params.valueBase64, - expectedVersion: expectedCurrentVersion, - }); - return; - } - - let current = await readJsonValue({ kv: params.kv, key }); - - for (let attempt = 0; attempt < 4; attempt += 1) { - try { - await writeJsonValue({ - kv: params.kv, - key, - valueBase64: params.valueBase64, - expectedVersion: current.version, - }); - return; - } catch (error) { - if (error instanceof ChannelBridgeKvVersionMismatchError) { - current = { - valueBase64: error.currentValueBase64, - version: error.currentVersion, - }; - continue; - } - throw error; - } - } - - throw new Error(`Unable to replace Telegram bridge KV value after retries for key ${key}`); -} - -function applyTelegramConfigUpdate(current: ChannelBridgeServerTelegramConfigRecord | null, update: ScopedTelegramBridgeUpdate): ChannelBridgeServerTelegramConfigRecord { - const next: { - schemaVersion: 1; - tickMs?: number; - telegram: { - allowedChatIds?: string[]; - requireTopics?: boolean; - webhook?: { - enabled?: boolean; - host?: string; - port?: number; - }; - }; - updatedAtMs: number; - } = { - schemaVersion: 1, - telegram: { - ...(current?.telegram ?? {}), - }, - updatedAtMs: Date.now(), - }; - - if (typeof current?.tickMs === 'number') { - next.tickMs = current.tickMs; - } - // `current` may be null when the existing KV payload is absent/unsupported and the caller - // intentionally rewrites canonical schema. In that case, unspecified tickMs is not carried - // forward and runtime defaults apply unless update.tickMs is explicitly provided. - if (typeof update.tickMs === 'number' && Number.isFinite(update.tickMs)) { - const normalizedTickMs = Math.trunc(update.tickMs); - if (normalizedTickMs < 250 || normalizedTickMs > 60_000) { - throw new ChannelBridgeBadPayloadError('Invalid tickMs update payload: must be in [250, 60000]'); - } - next.tickMs = normalizedTickMs; - } - - if (Array.isArray(update.allowedChatIds)) { - const normalizedAllowedChatIds = normalizeStringArray(update.allowedChatIds); - if ( - update.allowedChatIds.length > 0 - && (!hasOnlyNonEmptyStringValues(update.allowedChatIds) - || normalizedAllowedChatIds.length !== update.allowedChatIds.length) - ) { - throw new ChannelBridgeBadPayloadError('Invalid telegram.allowedChatIds update payload'); - } - next.telegram.allowedChatIds = normalizedAllowedChatIds; - } - if (typeof update.requireTopics === 'boolean') { - next.telegram.requireTopics = update.requireTopics; - } - - const hasWebhookUpdate = - typeof update.webhookEnabled === 'boolean' - || typeof update.webhookHost === 'string' - || (typeof update.webhookPort === 'number' && Number.isFinite(update.webhookPort)); - - if (hasWebhookUpdate) { - const nextWebhook = { - ...(current?.telegram?.webhook ?? {}), - }; - if (typeof update.webhookEnabled === 'boolean') { - nextWebhook.enabled = update.webhookEnabled; - } - if (typeof update.webhookHost === 'string') { - const normalizedHost = update.webhookHost.trim(); - if (normalizedHost.length === 0) { - throw new ChannelBridgeBadPayloadError('Invalid telegram.webhook.host update payload'); - } - if (!isLoopbackHost(normalizedHost)) { - throw new ChannelBridgeBadPayloadError('Invalid telegram.webhook.host update payload'); - } - nextWebhook.host = normalizedHost; - } - if (typeof update.webhookPort === 'number') { - if ( - !Number.isFinite(update.webhookPort) - || !Number.isInteger(update.webhookPort) - || update.webhookPort < 1 - || update.webhookPort > 65_535 - ) { - throw new ChannelBridgeBadPayloadError('Invalid telegram.webhook.port update payload'); - } - nextWebhook.port = update.webhookPort; - } - next.telegram.webhook = nextWebhook; - } - - return next; -} - -function hasNonSecretTelegramConfigUpdate(update: ScopedTelegramBridgeUpdate): boolean { - return ( - (typeof update.tickMs === 'number' && Number.isFinite(update.tickMs)) - || Array.isArray(update.allowedChatIds) - || typeof update.requireTopics === 'boolean' - || typeof update.webhookEnabled === 'boolean' - || typeof update.webhookHost === 'string' - || (typeof update.webhookPort === 'number' && Number.isFinite(update.webhookPort)) - ); -} - -export async function upsertChannelBridgeTelegramConfigInKv(params: Readonly<{ - kv: ChannelBridgeKvClient; - serverId: string; - accountId: string; - update: ScopedTelegramBridgeUpdate; -}>): Promise { - if (!hasNonSecretTelegramConfigUpdate(params.update)) { - return null; - } - - const key = telegramConfigKvKey(params.serverId, params.accountId); - let current: Readonly<{ - record: ChannelBridgeServerTelegramConfigRecord | null; - version: number; - }> | null = null; - for (let attempt = 0; attempt < 4; attempt += 1) { - if (current === null) { - current = await readChannelBridgeTelegramConfigFromKv({ - kv: params.kv, - serverId: params.serverId, - accountId: params.accountId, - allowUnsupportedSchema: true, - }); - } - - const nextRecord = applyTelegramConfigUpdate(current.record, params.update); - const nextValueBase64 = encodeJsonToBase64(nextRecord); - - try { - const writtenVersion = await writeJsonValue({ - kv: params.kv, - key, - valueBase64: nextValueBase64, - expectedVersion: current.version, - }); - return writtenVersion; - } catch (error) { - if (error instanceof ChannelBridgeKvVersionMismatchError) { - try { - const conflictRecord = error.currentValueBase64 === null - ? null - : parseTelegramConfigRecord(decodeBase64ToJson(error.currentValueBase64)); - current = { - record: conflictRecord, - version: error.currentVersion, - }; - } catch { - current = null; - } - continue; - } - throw error; - } - } - throw new Error('Failed to upsert channel bridge telegram config in KV after retries'); -} - -export async function clearChannelBridgeTelegramConfigInKv(params: Readonly<{ - kv: ChannelBridgeKvClient; - serverId: string; - accountId: string; -}>): Promise { - const key = telegramConfigKvKey(params.serverId, params.accountId); - const current = await readChannelBridgeTelegramConfigFromKv({ - kv: params.kv, - serverId: params.serverId, - accountId: params.accountId, - allowUnsupportedSchema: true, - }); - let expectedVersion = current.version; - if (expectedVersion < 0) return null; - - for (let attempt = 0; attempt < 4; attempt += 1) { - try { - const writtenVersion = await writeJsonValue({ - kv: params.kv, - key, - valueBase64: null, - expectedVersion, - }); - return writtenVersion; - } catch (error) { - if (error instanceof ChannelBridgeKvVersionMismatchError) { - if (error.currentVersion < 0) { - return null; - } - expectedVersion = error.currentVersion; - continue; - } - throw error; - } - } - - throw new Error('Failed to clear channel bridge telegram config in KV after retries'); -} - -export async function readChannelBridgeBindingsFromKv(params: Readonly<{ - kv: ChannelBridgeKvClient; - serverId: string; - accountId: string; -}>): Promise> { - const key = bindingsKvKey(params.serverId, params.accountId); - const row = await readJsonValue({ kv: params.kv, key }); - return { - doc: decodeChannelBridgeBindingsDocFromBase64(row.valueBase64), - version: row.version, - }; -} - -export async function writeChannelBridgeBindingsToKv(params: Readonly<{ - kv: ChannelBridgeKvClient; - serverId: string; - accountId: string; - expectedVersion: number; - doc: ChannelBridgeServerBindingsDocument; -}>): Promise { - const key = bindingsKvKey(params.serverId, params.accountId); - const valueBase64 = encodeJsonToBase64(params.doc); - return await writeJsonValue({ - kv: params.kv, - key, - valueBase64, - expectedVersion: params.expectedVersion, - }); -} diff --git a/apps/cli/src/channels/core/channelBridgeWorker.test.ts b/apps/cli/src/channels/core/channelBridgeWorker.test.ts index acd73dbc9a..bc7e29c824 100644 --- a/apps/cli/src/channels/core/channelBridgeWorker.test.ts +++ b/apps/cli/src/channels/core/channelBridgeWorker.test.ts @@ -5,12 +5,12 @@ import { executeChannelBridgeTick, startChannelBridgeWorker, createChannelBridgeInboundDeduper, + ChannelBridgePermanentDeliveryError, type ChannelBridgeAdapter, type ChannelBindingStore, type ChannelBridgeDeps, type ChannelBridgeInboundMessage, } from '@/channels/core/channelBridgeWorker'; -import { TelegramApiError } from '@/channels/telegram/telegramAdapter'; interface SentConversationMessage { conversationId: string; @@ -121,7 +121,8 @@ function createAdapterHarness(providerId: string = 'telegram'): { }, }, pushInbound: (event) => { - queue.push(event); + const next = typeof event.senderId === 'undefined' ? { ...event, senderId: 'user-1' } : event; + queue.push(next); }, sent, failPullOnce: (error) => { @@ -155,6 +156,12 @@ async function waitFor(condition: () => boolean, timeoutMs: number = 2_000): Pro } } +const DEFAULT_BINDING_POLICY = { + ownerSenderId: 'user-1', + inboundMode: 'ownerOnly' as const, + allowMissingSenderId: false, +}; + describe('createInMemoryChannelBindingStore', () => { it('normalizes non-finite cursor values and ignores invalid cursor updates', async () => { const store = createInMemoryChannelBindingStore(); @@ -166,6 +173,7 @@ describe('createInMemoryChannelBindingStore', () => { await store.upsertBinding({ ...ref, + ...DEFAULT_BINDING_POLICY, sessionId: 'sess-cursor', lastForwardedSeq: Number.NaN, }); @@ -190,6 +198,7 @@ describe('createInMemoryChannelBindingStore', () => { const store = createInMemoryChannelBindingStore(); await store.upsertBinding({ + ...DEFAULT_BINDING_POLICY, providerId: ' telegram ', conversationId: ' room-1 ', threadId: ' ', @@ -233,6 +242,7 @@ describe('executeChannelBridgeTick', () => { const store = createInMemoryChannelBindingStore(); const upserted = await store.upsertBinding({ + ...DEFAULT_BINDING_POLICY, providerId: 'telegram', conversationId: '-100sanity', threadId: null, @@ -263,6 +273,7 @@ describe('executeChannelBridgeTick', () => { it('returns defensive copies from in-memory binding store reads', async () => { const store = createInMemoryChannelBindingStore(() => 1_000); await store.upsertBinding({ + ...DEFAULT_BINDING_POLICY, providerId: 'telegram', conversationId: 'room-copy', threadId: null, @@ -357,11 +368,176 @@ describe('executeChannelBridgeTick', () => { ]); }); + it('denies /attach when sender identity is missing (safe-by-default)', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + + const { deps } = createDepsHarness({ + resolveSessionIdOrPrefix: async () => ({ ok: true as const, sessionId: 'sess-abc123' }), + resolveLatestSessionSeq: async () => 0, + }); + + harness.pushInbound({ + providerId: 'telegram', + conversationId: '-1001', + threadId: null, + text: '/attach abc123', + messageId: 'm-attach-missing-sender', + senderId: null, + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(await store.listBindings()).toEqual([]); + expect(harness.sent.some((row) => row.text.toLowerCase().includes('sender'))).toBe(true); + }); + + it('denies forwarding from non-owner senders by default', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + + const { deps, sentToSession } = createDepsHarness({ + resolveSessionIdOrPrefix: async () => ({ ok: true as const, sessionId: 'sess-abc123' }), + resolveLatestSessionSeq: async () => 0, + }); + + harness.pushInbound({ + providerId: 'telegram', + conversationId: '-1001', + threadId: null, + senderId: 'user-1', + text: '/attach abc123', + messageId: 'm-attach-owner', + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + harness.pushInbound({ + providerId: 'telegram', + conversationId: '-1001', + threadId: null, + senderId: 'user-2', + text: 'hello from non-owner', + messageId: 'm-non-owner', + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(sentToSession).toEqual([]); + expect(harness.sent.some((row) => row.text.toLowerCase().includes('authorized'))).toBe(true); + }); + + it('allows forwarding from anyone when attached with --anyone', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + + const { deps, sentToSession } = createDepsHarness({ + resolveSessionIdOrPrefix: async () => ({ ok: true as const, sessionId: 'sess-abc123' }), + resolveLatestSessionSeq: async () => 0, + }); + + harness.pushInbound({ + providerId: 'telegram', + conversationId: '-1001', + threadId: null, + senderId: 'user-1', + text: '/attach abc123 --anyone', + messageId: 'm-attach-anyone', + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + harness.pushInbound({ + providerId: 'telegram', + conversationId: '-1001', + threadId: null, + senderId: 'user-2', + text: 'hello from anyone', + messageId: 'm-anyone', + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(sentToSession).toHaveLength(1); + expect(sentToSession[0]?.text).toBe('hello from anyone'); + }); + + it('allows unsafe forwarding without senderId when attached with --allow-missing-sender-id', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + + const { deps, sentToSession } = createDepsHarness({ + resolveSessionIdOrPrefix: async () => ({ ok: true as const, sessionId: 'sess-abc123' }), + resolveLatestSessionSeq: async () => 0, + }); + + harness.pushInbound({ + providerId: 'telegram', + conversationId: '-1001', + threadId: null, + senderId: null, + text: '/attach abc123 --allow-missing-sender-id', + messageId: 'm-attach-unsafe', + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + harness.pushInbound({ + providerId: 'telegram', + conversationId: '-1001', + threadId: null, + senderId: null, + text: 'unsafe no sender', + messageId: 'm-unsafe-forward', + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(sentToSession).toHaveLength(1); + expect(sentToSession[0]?.text).toBe('unsafe no sender'); + }); + it('includes previous session id when /attach replaces an existing binding', async () => { const store = createInMemoryChannelBindingStore(); const harness = createAdapterHarness(); await store.upsertBinding({ + ...DEFAULT_BINDING_POLICY, providerId: 'telegram', conversationId: '-1001', threadId: '88', @@ -441,6 +617,7 @@ describe('executeChannelBridgeTick', () => { const harness = createAdapterHarness(); await store.upsertBinding({ + ...DEFAULT_BINDING_POLICY, providerId: 'telegram', conversationId: '-1001', threadId: '99', @@ -519,6 +696,7 @@ describe('executeChannelBridgeTick', () => { const { deps } = createDepsHarness(); await store.upsertBinding({ + ...DEFAULT_BINDING_POLICY, providerId: 'telegram', conversationId: '-1001', threadId: '99', @@ -632,6 +810,7 @@ describe('executeChannelBridgeTick', () => { const { deps, sentToSession } = createDepsHarness(); await store.upsertBinding({ + ...DEFAULT_BINDING_POLICY, providerId: 'telegram', conversationId: '-1001', threadId: '99', @@ -950,6 +1129,7 @@ describe('executeChannelBridgeTick', () => { const harness = createAdapterHarness(); await store.upsertBinding({ + ...DEFAULT_BINDING_POLICY, providerId: 'telegram', conversationId: '-1005', threadId: null, @@ -990,6 +1170,7 @@ describe('executeChannelBridgeTick', () => { const harness = createAdapterHarness(); await store.upsertBinding({ + ...DEFAULT_BINDING_POLICY, providerId: 'telegram', conversationId: '-1005b', threadId: null, @@ -1026,6 +1207,7 @@ describe('executeChannelBridgeTick', () => { const harness = createAdapterHarness(); await store.upsertBinding({ + ...DEFAULT_BINDING_POLICY, providerId: 'telegram', conversationId: '-1006', threadId: null, @@ -1392,6 +1574,7 @@ describe('executeChannelBridgeTick', () => { const harness = createAdapterHarness('telegram'); await store.upsertBinding({ + ...DEFAULT_BINDING_POLICY, providerId: 'telegram', conversationId: 'chat-42', threadId: null, @@ -1432,6 +1615,7 @@ describe('executeChannelBridgeTick', () => { const healthy = createAdapterHarness('discord'); await store.upsertBinding({ + ...DEFAULT_BINDING_POLICY, providerId: 'discord', conversationId: 'discord-room', threadId: null, @@ -1477,6 +1661,7 @@ describe('executeChannelBridgeTick', () => { const second = createAdapterHarness('telegram'); await store.upsertBinding({ + ...DEFAULT_BINDING_POLICY, providerId: 'telegram', conversationId: 'room-1', threadId: null, @@ -1518,6 +1703,7 @@ describe('executeChannelBridgeTick', () => { const harness = createAdapterHarness(); await store.upsertBinding({ + ...DEFAULT_BINDING_POLICY, providerId: 'telegram', conversationId: 'failing-room', threadId: null, @@ -1555,6 +1741,7 @@ describe('executeChannelBridgeTick', () => { const harness = createAdapterHarness(); await store.upsertBinding({ + ...DEFAULT_BINDING_POLICY, providerId: 'telegram', conversationId: 'failing-room-reply-fail', threadId: null, @@ -1603,6 +1790,7 @@ describe('executeChannelBridgeTick', () => { const store = createInMemoryChannelBindingStore(); await store.upsertBinding({ + ...DEFAULT_BINDING_POLICY, providerId: 'telegram', conversationId: '-2002', threadId: null, @@ -1647,6 +1835,7 @@ describe('executeChannelBridgeTick', () => { const harness = createAdapterHarness(); await store.upsertBinding({ + ...DEFAULT_BINDING_POLICY, providerId: 'telegram', conversationId: '-2003', threadId: null, @@ -1676,6 +1865,7 @@ describe('executeChannelBridgeTick', () => { const harness = createAdapterHarness(); await store.upsertBinding({ + ...DEFAULT_BINDING_POLICY, providerId: 'telegram', conversationId: 'direct-dedupe-room', threadId: null, @@ -1723,6 +1913,7 @@ describe('executeChannelBridgeTick', () => { await baseStore.upsertBinding({ ...ref, + ...DEFAULT_BINDING_POLICY, sessionId: 'sess-retry-on-failure', lastForwardedSeq: 0, }); @@ -1786,6 +1977,7 @@ describe('executeChannelBridgeTick', () => { const harness = createAdapterHarness(); await store.upsertBinding({ + ...DEFAULT_BINDING_POLICY, providerId: 'telegram', conversationId: 'empty-id-room', threadId: null, @@ -1828,6 +2020,7 @@ describe('executeChannelBridgeTick', () => { const harness = createAdapterHarness(); await store.upsertBinding({ + ...DEFAULT_BINDING_POLICY, providerId: 'telegram', conversationId: '-3001', threadId: null, @@ -1966,7 +2159,7 @@ describe('executeChannelBridgeTick', () => { expect(binding?.lastForwardedSeq).toBe(9); }); - it('treats typed Telegram permanent delivery failures as non-retryable and advances cursor', async () => { + it('treats typed permanent delivery failures as non-retryable and advances cursor', async () => { const store = createInMemoryChannelBindingStore(); const { deps, warnings } = createDepsHarness({ fetchAgentMessagesAfterSeq: async () => [{ seq: 1, text: 'delivery fails permanently' }], @@ -1984,10 +2177,9 @@ describe('executeChannelBridgeTick', () => { providerId: 'telegram', pullInboundMessages: async () => [], sendMessage: async () => { - throw new TelegramApiError({ - method: 'sendMessage', - statusCode: 403, - data: { description: 'Forbidden: bot was blocked by the user' }, + throw new ChannelBridgePermanentDeliveryError({ + code: 'forbidden', + message: 'Forbidden: bot was blocked by the user', }); }, }; @@ -2002,21 +2194,21 @@ describe('executeChannelBridgeTick', () => { const [binding] = await store.listBindings(); expect(binding?.lastForwardedSeq).toBe(1); expect( - warnings.some((row) => row.message.includes('Detected permanent Telegram delivery failure')), + warnings.some((row) => row.message.includes('Detected permanent delivery failure')), ).toBe(true); expect( warnings.some((row) => row.message.includes('Failed to forward agent output to channel')), ).toBe(false); }); - it('treats typed Telegram permanent failures as non-retryable even for non-default provider ids', async () => { + it('treats typed permanent failures as non-retryable even for non-default provider ids', async () => { const store = createInMemoryChannelBindingStore(); const { deps, warnings } = createDepsHarness({ fetchAgentMessagesAfterSeq: async () => [{ seq: 1, text: 'delivery fails permanently' }], }); await store.upsertBinding({ - providerId: 'telegram-v2', + providerId: 'telegram-alt', conversationId: '-3004b-alt', threadId: null, sessionId: 'sess-typed-permanent-alt', @@ -2024,13 +2216,12 @@ describe('executeChannelBridgeTick', () => { }); const adapter: ChannelBridgeAdapter = { - providerId: 'telegram-v2', + providerId: 'telegram-alt', pullInboundMessages: async () => [], sendMessage: async () => { - throw new TelegramApiError({ - method: 'sendMessage', - statusCode: 403, - data: { description: 'Forbidden: bot was blocked by the user' }, + throw new ChannelBridgePermanentDeliveryError({ + code: 'forbidden', + message: 'Forbidden: bot was blocked by the user', }); }, }; @@ -2045,11 +2236,11 @@ describe('executeChannelBridgeTick', () => { const [binding] = await store.listBindings(); expect(binding?.lastForwardedSeq).toBe(1); expect( - warnings.some((row) => row.message.includes('Detected permanent Telegram delivery failure')), + warnings.some((row) => row.message.includes('Detected permanent delivery failure')), ).toBe(true); }); - it('treats typed Telegram 400 chat-not-found failures as non-retryable', async () => { + it('treats typed chat-not-found failures as non-retryable', async () => { const store = createInMemoryChannelBindingStore(); const { deps, warnings } = createDepsHarness({ fetchAgentMessagesAfterSeq: async () => [{ seq: 1, text: 'delivery fails permanently' }], @@ -2067,10 +2258,9 @@ describe('executeChannelBridgeTick', () => { providerId: 'telegram', pullInboundMessages: async () => [], sendMessage: async () => { - throw new TelegramApiError({ - method: 'sendMessage', - statusCode: 400, - data: { description: 'Bad Request: chat not found' }, + throw new ChannelBridgePermanentDeliveryError({ + code: 'conversation_not_found', + message: 'Bad Request: chat not found', }); }, }; @@ -2085,7 +2275,7 @@ describe('executeChannelBridgeTick', () => { const [binding] = await store.listBindings(); expect(binding?.lastForwardedSeq).toBe(1); expect( - warnings.some((row) => row.message.includes('Detected permanent Telegram delivery failure')), + warnings.some((row) => row.message.includes('Detected permanent delivery failure')), ).toBe(true); }); @@ -2121,7 +2311,7 @@ describe('executeChannelBridgeTick', () => { const [binding] = await store.listBindings(); expect(binding?.lastForwardedSeq).toBe(0); expect( - warnings.some((row) => row.message.includes('Detected permanent Telegram delivery failure')), + warnings.some((row) => row.message.includes('Detected permanent delivery failure')), ).toBe(false); expect( warnings.some((row) => row.message.includes('Failed to forward agent output to channel')), @@ -2283,6 +2473,7 @@ describe('executeChannelBridgeTick', () => { await store.upsertBinding({ ...bindingRef, + ...DEFAULT_BINDING_POLICY, sessionId: 'sess-orphaned-2', lastForwardedSeq: 0, }); @@ -2325,6 +2516,7 @@ describe('executeChannelBridgeTick', () => { it('acknowledges handled inbound messages when adapter exposes ack hook', async () => { const store = createInMemoryChannelBindingStore(); await store.upsertBinding({ + ...DEFAULT_BINDING_POLICY, providerId: 'telegram', conversationId: 'room-ack', threadId: null, @@ -2448,6 +2640,7 @@ describe('startChannelBridgeWorker', () => { const harness = createAdapterHarness(); await store.upsertBinding({ + ...DEFAULT_BINDING_POLICY, providerId: 'telegram', conversationId: 'dedupe-room', threadId: null, @@ -2498,6 +2691,7 @@ describe('startChannelBridgeWorker', () => { let startedTick = false; await store.upsertBinding({ + ...DEFAULT_BINDING_POLICY, providerId: 'telegram', conversationId: 'stop-room', threadId: null, diff --git a/apps/cli/src/channels/core/channelBridgeWorker.ts b/apps/cli/src/channels/core/channelBridgeWorker.ts index c5bb2a93d2..8c1bc6e235 100644 --- a/apps/cli/src/channels/core/channelBridgeWorker.ts +++ b/apps/cli/src/channels/core/channelBridgeWorker.ts @@ -14,7 +14,6 @@ * - `updateLastForwardedSeq` must persist the maximum forwarded sequence. */ import { startSingleFlightIntervalLoop, type SingleFlightIntervalLoopHandle } from '@/daemon/lifecycle/singleFlightIntervalLoop'; -import { TelegramApiError } from '@/channels/telegram/telegramAdapter'; /** * Logical channel conversation reference. @@ -71,9 +70,14 @@ export type ChannelBridgeAdapter = Readonly<{ /** * Persisted conversation -> session mapping and agent cursor state. */ +export type ChannelBridgeInboundMode = 'ownerOnly' | 'anyone'; + export type ChannelSessionBinding = ChannelBridgeConversationRef & Readonly<{ sessionId: string; lastForwardedSeq: number; + ownerSenderId: string | null; + inboundMode: ChannelBridgeInboundMode; + allowMissingSenderId: boolean; createdAtMs: number; updatedAtMs: number; }>; @@ -139,6 +143,9 @@ export type ChannelBindingStore = Readonly<{ threadId: string | null; sessionId: string; lastForwardedSeq: number; + ownerSenderId: string | null; + inboundMode: ChannelBridgeInboundMode; + allowMissingSenderId: boolean; }>) => Promise; updateLastForwardedSeq: ( ref: ChannelBridgeConversationRef, @@ -170,6 +177,26 @@ function toNonNegativeInt(value: unknown): number | null { return parsed; } +function normalizeSenderId(raw: unknown): string | null { + if (typeof raw !== 'string') return null; + const trimmed = raw.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +export class ChannelBridgePermanentDeliveryError extends Error { + readonly code: 'forbidden' | 'conversation_not_found' | 'unknown'; + + constructor(params: Readonly<{ code: ChannelBridgePermanentDeliveryError['code']; message: string }>) { + super(params.message); + this.name = 'ChannelBridgePermanentDeliveryError'; + this.code = params.code; + } +} + +function isChannelBridgePermanentDeliveryFailure(error: unknown): error is ChannelBridgePermanentDeliveryError { + return error instanceof ChannelBridgePermanentDeliveryError; +} + const DEFAULT_EXTERNAL_IO_TIMEOUT_MS = 30_000; function resolveExternalIoTimeoutMs(): number { @@ -214,14 +241,6 @@ async function withTimeout(promise: Promise, timeoutMs: number, label: str } } -function isTelegramPermanentDeliveryFailure(error: unknown): boolean { - if (!(error instanceof TelegramApiError)) return false; - if (error.method !== 'sendMessage') return false; - if (error.statusCode === 403) return true; - if (error.statusCode === 400 && error.description !== null && /chat not found/i.test(error.description)) return true; - return false; -} - type ChannelBridgeInboundDeduper = Readonly<{ hasSeen: (message: ChannelBridgeInboundMessage) => boolean; markSeen: (message: ChannelBridgeInboundMessage) => void; @@ -342,10 +361,16 @@ export function createInMemoryChannelBindingStore(now: () => number = () => Date const key = bindingKey(normalizedRef); const existing = byKey.get(key); const normalizedLastForwardedSeq = toNonNegativeInt(binding.lastForwardedSeq) ?? 0; + const ownerSenderId = normalizeSenderId(binding.ownerSenderId); + const inboundMode: ChannelBridgeInboundMode = binding.inboundMode === 'anyone' ? 'anyone' : 'ownerOnly'; + const allowMissingSenderId = binding.allowMissingSenderId === true; const next: ChannelSessionBinding = { ...normalizedRef, sessionId: binding.sessionId.trim(), lastForwardedSeq: normalizedLastForwardedSeq, + ownerSenderId, + inboundMode, + allowMissingSenderId, createdAtMs: existing?.createdAtMs ?? now(), updatedAtMs: now(), }; @@ -385,6 +410,101 @@ function parseSlashCommand(text: string): Readonly<{ name: string; args: string[ return { name, args }; } +function parseAttachFlags(args: readonly string[]): Readonly<{ + allowAnyone: boolean; + allowMissingSenderId: boolean; + unknownFlags: string[]; +}> { + const allowAnyone = args.some((arg) => arg.trim().toLowerCase() === '--anyone'); + const allowMissingSenderId = args.some((arg) => arg.trim().toLowerCase() === '--allow-missing-sender-id'); + const unknownFlags = args + .map((arg) => arg.trim()) + .filter((arg) => arg.startsWith('--')) + .filter((arg) => arg.toLowerCase() !== '--anyone' && arg.toLowerCase() !== '--allow-missing-sender-id'); + return { + allowAnyone, + allowMissingSenderId, + unknownFlags, + }; +} + +function authorizeBindingControl(params: Readonly<{ + binding: ChannelSessionBinding | null; + senderId: string | null; +}>): Readonly<{ allowed: boolean; message: string | null }> { + const { binding, senderId } = params; + + if (!binding) { + if (!senderId) { + return { + allowed: false, + message: 'This command requires a stable sender identity. Try using it in a DM.', + }; + } + return { allowed: true, message: null }; + } + + if (!senderId) { + if (binding.allowMissingSenderId) { + return { allowed: true, message: null }; + } + return { + allowed: false, + message: 'This binding does not allow commands without a sender identity.', + }; + } + + if (!binding.ownerSenderId) { + return { allowed: true, message: null }; + } + + if (binding.ownerSenderId !== senderId) { + return { + allowed: false, + message: 'You are not authorized to control this binding.', + }; + } + + return { allowed: true, message: null }; +} + +function authorizeInboundForwarding(params: Readonly<{ + binding: ChannelSessionBinding; + senderId: string | null; +}>): Readonly<{ allowed: boolean; message: string | null }> { + const { binding, senderId } = params; + + if (!senderId) { + if (binding.allowMissingSenderId) { + return { allowed: true, message: null }; + } + return { + allowed: false, + message: 'Sender identity is missing; forwarding is disabled for safety.', + }; + } + + if (binding.inboundMode === 'anyone') { + return { allowed: true, message: null }; + } + + if (!binding.ownerSenderId) { + return { + allowed: false, + message: 'This binding has no owner identity; reattach to establish one.', + }; + } + + if (binding.ownerSenderId !== senderId) { + return { + allowed: false, + message: 'You are not authorized to send messages to this session from here.', + }; + } + + return { allowed: true, message: null }; +} + function formatSessionsMessage(rows: Array>): string { if (rows.length === 0) { return 'No sessions found.'; @@ -425,8 +545,7 @@ async function authorizeCommand(params: Readonly<{ return { allowed: true, message: null }; } - const senderRaw = params.event.senderId; - const senderId = typeof senderRaw === 'string' && senderRaw.trim().length > 0 ? senderRaw.trim() : null; + const senderId = normalizeSenderId(params.event.senderId); const actor: ChannelBridgeActorContext = { providerId: params.event.providerId, conversationId: params.event.conversationId, @@ -504,7 +623,7 @@ async function handleCommand(params: Readonly<{ [ 'Happier bridge commands:', '/sessions - list recent sessions', - '/attach - bind this DM/topic', + '/attach [--anyone] [--allow-missing-sender-id] - attach this conversation', '/detach - unbind this DM/topic', '/session - show current binding', '/help - show command help', @@ -554,9 +673,26 @@ async function handleCommand(params: Readonly<{ } if (command.name === 'attach') { - const idOrPrefix = String(command.args[0] ?? '').trim(); + const rawArgs = command.args.map((arg) => String(arg)); + const idOrPrefix = String(rawArgs[0] ?? '').trim(); if (!idOrPrefix) { - await replyForCommand('Usage: /attach '); + await replyForCommand('Usage: /attach [--anyone] [--allow-missing-sender-id]'); + return; + } + + const flags = parseAttachFlags(rawArgs.slice(1)); + if (flags.unknownFlags.length > 0) { + await replyForCommand( + `Unknown flags: ${flags.unknownFlags.join(', ')}\nUsage: /attach [--anyone] [--allow-missing-sender-id]`, + ); + return; + } + + const senderId = normalizeSenderId(event.senderId); + if (!senderId && !flags.allowMissingSenderId) { + await replyForCommand( + 'Cannot attach: sender identity is missing. Try attaching from a DM, or pass --allow-missing-sender-id (unsafe).', + ); return; } @@ -635,14 +771,38 @@ async function handleCommand(params: Readonly<{ } const previousSessionId = previousBinding?.sessionId ?? null; + if (previousBinding) { + const controlAuthz = authorizeBindingControl({ + binding: previousBinding, + senderId, + }); + if (!controlAuthz.allowed) { + await replyForCommand(controlAuthz.message ?? 'You are not authorized to attach here.'); + return; + } + } + + const ownerSenderId = senderId; + const allowMissingSenderId = flags.allowMissingSenderId === true; + const requestedInboundMode: ChannelBridgeInboundMode = flags.allowAnyone ? 'anyone' : 'ownerOnly'; + const inboundMode: ChannelBridgeInboundMode = + ownerSenderId + ? requestedInboundMode + : allowMissingSenderId + ? 'anyone' + : 'ownerOnly'; + try { await withTimeout( store.upsertBinding({ - providerId: ref.providerId, - conversationId: ref.conversationId, - threadId: ref.threadId, - sessionId: resolved.sessionId, - lastForwardedSeq: latestSeq, + providerId: ref.providerId, + conversationId: ref.conversationId, + threadId: ref.threadId, + sessionId: resolved.sessionId, + lastForwardedSeq: latestSeq, + ownerSenderId, + inboundMode, + allowMissingSenderId, }), EXTERNAL_IO_TIMEOUT_MS, `store.upsertBinding(${ref.providerId}:${ref.conversationId}:${ref.threadId ?? 'null'})`, @@ -662,6 +822,29 @@ async function handleCommand(params: Readonly<{ } if (command.name === 'detach') { + let existing: ChannelSessionBinding | null; + try { + existing = await withTimeout( + store.getBinding(ref), + EXTERNAL_IO_TIMEOUT_MS, + `store.getBinding(${ref.providerId}:${ref.conversationId}:${ref.threadId ?? 'null'})`, + ); + } catch (error) { + deps.onWarning?.('Failed to read binding before /detach command', error); + await replyForCommand('Failed to read current binding. Please try again later.'); + return; + } + + const senderId = normalizeSenderId(event.senderId); + const controlAuthz = authorizeBindingControl({ + binding: existing, + senderId, + }); + if (!controlAuthz.allowed) { + await replyForCommand(controlAuthz.message ?? 'You are not authorized to detach here.'); + return; + } + let removed = false; try { removed = await withTimeout( @@ -851,6 +1034,30 @@ export async function executeChannelBridgeTick(params: Readonly<{ continue; } + const senderId = normalizeSenderId(event.senderId); + const forwardAuthz = authorizeInboundForwarding({ + binding, + senderId, + }); + if (!forwardAuthz.allowed) { + try { + await replyToConversation( + adapter, + ref, + forwardAuthz.message ?? 'You are not authorized to send messages here.', + ); + } catch (replyError) { + params.deps.onWarning?.( + `Failed to send forwarding-unauthorized reply for provider=${adapter.providerId} conversation=${event.conversationId} thread=${event.threadId ?? 'null'}`, + replyError, + ); + } + deduper.markSeen(event); + ackableInbound.push(event); + processedSuccessfully = true; + continue; + } + try { await withTimeout( params.deps.sendUserMessageToSession({ @@ -1035,9 +1242,9 @@ export async function executeChannelBridgeTick(params: Readonly<{ `sendMessage(${adapter.providerId})`, ); } catch (error) { - if (isTelegramPermanentDeliveryFailure(error)) { + if (isChannelBridgePermanentDeliveryFailure(error)) { params.deps.onWarning?.( - `Detected permanent Telegram delivery failure; advancing outbound cursor without retry for session=${binding.sessionId} provider=${binding.providerId} conversation=${binding.conversationId} seq=${nextSeq}`, + `Detected permanent delivery failure; advancing outbound cursor without retry for session=${binding.sessionId} provider=${binding.providerId} conversation=${binding.conversationId} seq=${nextSeq}`, error, ); } else { diff --git a/apps/cli/src/channels/providers/_registry/channelBridgeProviderRegistry.ts b/apps/cli/src/channels/providers/_registry/channelBridgeProviderRegistry.ts new file mode 100644 index 0000000000..fe2625b592 --- /dev/null +++ b/apps/cli/src/channels/providers/_registry/channelBridgeProviderRegistry.ts @@ -0,0 +1,26 @@ +import type { ChannelBridgeRuntimeConfig } from '@/channels/channelBridgeConfig'; + +import { telegramChannelBridgeProvider } from './telegramChannelBridgeProvider'; +import type { ChannelBridgeProviderDefinition } from './types'; + +export type AnyChannelBridgeProviderDefinition = ChannelBridgeProviderDefinition< + string, + ChannelBridgeRuntimeConfig, + unknown +>; + +export const channelBridgeProviderRegistry = { + telegram: telegramChannelBridgeProvider, +} as const satisfies Record; + +export type ChannelBridgeProviderId = keyof typeof channelBridgeProviderRegistry; + +export function listChannelBridgeProviderIds(): readonly ChannelBridgeProviderId[] { + return Object.keys(channelBridgeProviderRegistry) as ChannelBridgeProviderId[]; +} + +export function resolveChannelBridgeProviderDefinition( + providerId: ChannelBridgeProviderId, +): AnyChannelBridgeProviderDefinition { + return channelBridgeProviderRegistry[providerId]; +} diff --git a/apps/cli/src/channels/providers/_registry/telegramChannelBridgeProvider.ts b/apps/cli/src/channels/providers/_registry/telegramChannelBridgeProvider.ts new file mode 100644 index 0000000000..94fa1f8f39 --- /dev/null +++ b/apps/cli/src/channels/providers/_registry/telegramChannelBridgeProvider.ts @@ -0,0 +1,103 @@ +import { serializeAxiosErrorForLog } from '@/api/client/serializeAxiosErrorForLog'; +import { createTelegramChannelAdapter } from '@/channels/providers/telegram/telegramAdapter'; +import { + startTelegramWebhookRelay, + type TelegramWebhookRelayHandle, +} from '@/channels/providers/telegram/telegramWebhookRelay'; +import type { ChannelBridgeRuntimeConfig } from '@/channels/channelBridgeConfig'; +import { logger } from '@/ui/logger'; + +import type { ChannelBridgeProviderDefinition, ChannelBridgeProviderRuntime } from './types'; + +async function stopRelayBestEffort(relayHandle: TelegramWebhookRelayHandle | null): Promise { + if (!relayHandle) return; + try { + await relayHandle.stop(); + } catch (error) { + logger.warn('[channelBridge] Error stopping Telegram webhook relay during shutdown', error); + } +} + +export const telegramChannelBridgeProvider: ChannelBridgeProviderDefinition< + 'telegram', + ChannelBridgeRuntimeConfig, + ChannelBridgeRuntimeConfig['telegram'] +> = { + providerId: 'telegram', + readConfig: (root) => root.telegram, + createRuntime: async ({ config }) => { + const botToken = config.botToken; + if (!botToken) return null; + + const allowedChatIdsRaw = config.allowedChatIds; + const allowedChatIds = allowedChatIdsRaw.length > 0 ? new Set(allowedChatIdsRaw) : null; + const allowAllSharedChats = config.allowAllSharedChats; + const requireTopics = config.requireTopics; + + const webhookEnabled = config.webhookEnabled; + const webhookSecret = config.webhookSecret; + if (webhookEnabled && webhookSecret.length === 0) { + logger.warn( + '[channelBridge] Telegram webhook.enabled=true but webhook.secret is missing; falling back to polling mode', + ); + } + + const webhookModeRequested = webhookEnabled && webhookSecret.length > 0; + + let relayHandle: TelegramWebhookRelayHandle | null = null; + let adapter = createTelegramChannelAdapter({ + botToken, + allowedChatIds, + allowAllSharedChats, + requireTopics, + webhookMode: webhookModeRequested, + }); + + if (webhookModeRequested) { + const port = config.webhookPort; + const host = config.webhookHost; + try { + relayHandle = await startTelegramWebhookRelay({ + port, + host, + // We intentionally use one shared secret today because bridge config currently + // exposes a single webhook secret field. The relay API keeps both knobs + // separate so we can split path/header secrets in a future config version. + secretPathToken: webhookSecret, + secretHeaderToken: webhookSecret, + onUpdate: adapter.enqueueWebhookUpdate, + }); + logger.debug( + `[channelBridge] Telegram webhook relay listening on http://${host}:${relayHandle.port} (path redacted)`, + ); + } catch (error) { + logger.warn( + '[channelBridge] Failed to start Telegram webhook relay; bridge will continue without webhook relay', + serializeAxiosErrorForLog(error), + ); + await stopRelayBestEffort(relayHandle); + relayHandle = null; + adapter = createTelegramChannelAdapter({ + botToken, + allowedChatIds, + allowAllSharedChats, + requireTopics, + webhookMode: false, + }); + logger.warn('[channelBridge] Falling back to Telegram polling mode because webhook relay failed to start'); + } + } + + let stopped = false; + const runtime: ChannelBridgeProviderRuntime = { + adapters: [adapter], + stop: async () => { + if (stopped) return; + stopped = true; + await stopRelayBestEffort(relayHandle); + }, + }; + return runtime; + }, +}; + diff --git a/apps/cli/src/channels/providers/_registry/types.ts b/apps/cli/src/channels/providers/_registry/types.ts new file mode 100644 index 0000000000..4364e519ba --- /dev/null +++ b/apps/cli/src/channels/providers/_registry/types.ts @@ -0,0 +1,17 @@ +import type { ChannelBridgeAdapter } from '@/channels/core/channelBridgeWorker'; + +export type ChannelBridgeProviderRuntime = Readonly<{ + adapters: readonly ChannelBridgeAdapter[]; + stop: () => Promise; +}>; + +export type ChannelBridgeProviderDefinition< + TProviderId extends string, + TRootConfig, + TProviderConfig, +> = Readonly<{ + providerId: TProviderId; + readConfig: (root: TRootConfig) => TProviderConfig; + createRuntime: (params: Readonly<{ config: TProviderConfig }>) => Promise; +}>; + diff --git a/apps/cli/src/channels/telegram/telegramAdapter.test.ts b/apps/cli/src/channels/providers/telegram/telegramAdapter.test.ts similarity index 84% rename from apps/cli/src/channels/telegram/telegramAdapter.test.ts rename to apps/cli/src/channels/providers/telegram/telegramAdapter.test.ts index 4173a5a7f5..68fa5be454 100644 --- a/apps/cli/src/channels/telegram/telegramAdapter.test.ts +++ b/apps/cli/src/channels/providers/telegram/telegramAdapter.test.ts @@ -58,6 +58,7 @@ describe('createTelegramChannelAdapter', () => { const adapter = createTelegramChannelAdapter({ botToken: 'test-token', api, + allowedChatIds: new Set(['-100555']), }); const inbound = await adapter.pullInboundMessages(); @@ -73,6 +74,86 @@ describe('createTelegramChannelAdapter', () => { ]); }); + it('ignores non-private chats by default unless allowlisted or allowAllSharedChats is enabled', async () => { + const api = { + getMe: vi.fn(async () => ({ id: 777, username: 'happier_bot' })), + getUpdates: vi.fn(async () => ([ + { + update_id: 101, + message: { + message_id: 5001, + text: 'hello from dm', + chat: { id: 1234, type: 'private' }, + from: { id: 42, is_bot: false, first_name: 'Ada' }, + }, + }, + { + update_id: 102, + message: { + message_id: 5002, + text: 'hello from group', + chat: { id: -100555, type: 'supergroup' }, + from: { id: 43, is_bot: false, first_name: 'Grace' }, + }, + }, + ])), + sendMessage: vi.fn(async () => undefined), + }; + + const adapter = createTelegramChannelAdapter({ + botToken: 'test-token', + api, + }); + + const inbound = await adapter.pullInboundMessages(); + expect(inbound).toEqual([ + { + providerId: 'telegram', + conversationId: '1234', + threadId: null, + senderId: '42', + text: 'hello from dm', + messageId: '5001', + }, + ]); + }); + + it('allows non-private chats when allowAllSharedChats is enabled', async () => { + const api = { + getMe: vi.fn(async () => ({ id: 777, username: 'happier_bot' })), + getUpdates: vi.fn(async () => ([ + { + update_id: 101, + message: { + message_id: 5001, + text: 'hello from group', + chat: { id: -100555, type: 'supergroup' }, + from: { id: 42, is_bot: false, first_name: 'Ada' }, + }, + }, + ])), + sendMessage: vi.fn(async () => undefined), + }; + + const adapter = createTelegramChannelAdapter({ + botToken: 'test-token', + api, + allowAllSharedChats: true, + }); + + const inbound = await adapter.pullInboundMessages(); + expect(inbound).toEqual([ + { + providerId: 'telegram', + conversationId: '-100555', + threadId: null, + senderId: '42', + text: 'hello from group', + messageId: '5001', + }, + ]); + }); + it('sends outbound messages with thread targeting when threadId is present', async () => { const api = { getMe: vi.fn(async () => ({ id: 9, username: 'happier_bot' })), diff --git a/apps/cli/src/channels/telegram/telegramAdapter.ts b/apps/cli/src/channels/providers/telegram/telegramAdapter.ts similarity index 93% rename from apps/cli/src/channels/telegram/telegramAdapter.ts rename to apps/cli/src/channels/providers/telegram/telegramAdapter.ts index 030e5a9a77..967fc487ea 100644 --- a/apps/cli/src/channels/telegram/telegramAdapter.ts +++ b/apps/cli/src/channels/providers/telegram/telegramAdapter.ts @@ -1,5 +1,6 @@ import axios from 'axios'; +import { ChannelBridgePermanentDeliveryError } from '@/channels/core/channelBridgeWorker'; import type { ChannelBridgeAdapter, ChannelBridgeInboundMessage } from '@/channels/core/channelBridgeWorker'; import { logger } from '@/ui/logger'; @@ -100,11 +101,26 @@ function createDefaultTelegramApiClient(botToken: string): TelegramApiClient { validateStatus: () => true, }); if (response.status !== 200 || !response.data || response.data.ok !== true) { - throw new TelegramApiError({ + const apiError = new TelegramApiError({ method: 'sendMessage', statusCode: response.status, data: response.data, }); + + if (apiError.statusCode === 403) { + throw new ChannelBridgePermanentDeliveryError({ + code: 'forbidden', + message: apiError.message, + }); + } + if (apiError.statusCode === 400 && apiError.description !== null && /chat not found/i.test(apiError.description)) { + throw new ChannelBridgePermanentDeliveryError({ + code: 'conversation_not_found', + message: apiError.message, + }); + } + + throw apiError; } }, }; @@ -119,6 +135,7 @@ function parseInboundFromUpdate(params: Readonly<{ update: unknown; selfBotId: number | null; allowedChatIds: ReadonlySet | null; + allowAllSharedChats: boolean; requireTopics: boolean; }>): ChannelBridgeInboundMessage | null { const update = asRecord(params.update); @@ -140,8 +157,12 @@ function parseInboundFromUpdate(params: Readonly<{ : ''; if (!conversationId) return null; - if (params.allowedChatIds && !params.allowedChatIds.has(conversationId)) { - return null; + const chatType = typeof chat.type === 'string' ? chat.type : ''; + const isPrivateChat = chatType === 'private'; + if (!isPrivateChat && !params.allowAllSharedChats) { + if (!params.allowedChatIds || !params.allowedChatIds.has(conversationId)) { + return null; + } } const threadId = @@ -150,7 +171,6 @@ function parseInboundFromUpdate(params: Readonly<{ : null; if (params.requireTopics) { - const chatType = typeof chat.type === 'string' ? chat.type : ''; if (chatType === 'supergroup' && threadId === null) { return null; } @@ -213,6 +233,7 @@ export function createTelegramChannelAdapter(params: Readonly<{ webhookMode?: boolean; updateLimit?: number; allowedChatIds?: ReadonlySet | null; + allowAllSharedChats?: boolean; requireTopics?: boolean; }>): ChannelBridgeAdapter & Readonly<{ enqueueWebhookUpdate: (update: unknown) => void }> { const api = params.api ?? createDefaultTelegramApiClient(params.botToken); @@ -222,6 +243,7 @@ export function createTelegramChannelAdapter(params: Readonly<{ ? Math.max(1, Math.min(100, Math.trunc(params.updateLimit))) : 100; const allowedChatIds = params.allowedChatIds ?? null; + const allowAllSharedChats = params.allowAllSharedChats === true; const requireTopics = params.requireTopics === true; const MAX_WEBHOOK_QUEUE_SIZE = 2_000; @@ -296,6 +318,7 @@ export function createTelegramChannelAdapter(params: Readonly<{ update, selfBotId, allowedChatIds, + allowAllSharedChats, requireTopics, }); if (parsed) out.push(parsed); @@ -338,6 +361,7 @@ export function createTelegramChannelAdapter(params: Readonly<{ update: row.update, selfBotId, allowedChatIds, + allowAllSharedChats, requireTopics, }); if (!message) { diff --git a/apps/cli/src/channels/telegram/telegramWebhookRelay.test.ts b/apps/cli/src/channels/providers/telegram/telegramWebhookRelay.test.ts similarity index 100% rename from apps/cli/src/channels/telegram/telegramWebhookRelay.test.ts rename to apps/cli/src/channels/providers/telegram/telegramWebhookRelay.test.ts diff --git a/apps/cli/src/channels/telegram/telegramWebhookRelay.ts b/apps/cli/src/channels/providers/telegram/telegramWebhookRelay.ts similarity index 89% rename from apps/cli/src/channels/telegram/telegramWebhookRelay.ts rename to apps/cli/src/channels/providers/telegram/telegramWebhookRelay.ts index b4eef04b67..e6080622ca 100644 --- a/apps/cli/src/channels/telegram/telegramWebhookRelay.ts +++ b/apps/cli/src/channels/providers/telegram/telegramWebhookRelay.ts @@ -1,8 +1,8 @@ import { timingSafeEqual } from 'node:crypto'; -import { isIP } from 'node:net'; import fastify from 'fastify'; +import { isLoopbackHostname } from '@/server/serverUrlClassification'; import { logger } from '@/ui/logger'; function secureCompareToken(providedToken: string, expectedToken: string): boolean { @@ -24,21 +24,6 @@ export type TelegramWebhookRelayHandle = Readonly<{ stop: () => Promise; }>; -export function isLoopbackHost(host: string): boolean { - const normalized = host.trim().toLowerCase(); - if (normalized === 'localhost') return true; - - const ipVersion = isIP(normalized); - if (ipVersion === 4) { - return normalized.startsWith('127.'); - } - if (ipVersion === 6) { - return normalized === '::1'; - } - - return false; -} - export async function startTelegramWebhookRelay(params: Readonly<{ port: number; host?: string; @@ -60,7 +45,7 @@ export async function startTelegramWebhookRelay(params: Readonly<{ } const host = String(params.host ?? '127.0.0.1').trim() || '127.0.0.1'; - if (!isLoopbackHost(host)) { + if (!isLoopbackHostname(host)) { throw new Error('Webhook host must be loopback-only'); } if (params.port !== undefined && params.port !== null && !Number.isFinite(params.port)) { diff --git a/apps/cli/src/channels/runtime/startChannelBridgeRuntime.ts b/apps/cli/src/channels/runtime/startChannelBridgeRuntime.ts new file mode 100644 index 0000000000..7af357b947 --- /dev/null +++ b/apps/cli/src/channels/runtime/startChannelBridgeRuntime.ts @@ -0,0 +1,76 @@ +import type { + ChannelBindingStore, + ChannelBridgeAdapter, + ChannelBridgeDeps, + ChannelBridgeWorkerHandle, +} from '@/channels/core/channelBridgeWorker'; +import { startChannelBridgeWorker } from '@/channels/core/channelBridgeWorker'; +import type { ChannelBridgeRuntimeConfig } from '@/channels/channelBridgeConfig'; +import { listChannelBridgeProviderIds, resolveChannelBridgeProviderDefinition } from '@/channels/providers/_registry/channelBridgeProviderRegistry'; + +export type ChannelBridgeRuntimeHandle = ChannelBridgeWorkerHandle; + +async function startProviderRuntimes(params: Readonly<{ + runtimeConfig: ChannelBridgeRuntimeConfig; +}>): Promise Promise }>> { + const adapters: ChannelBridgeAdapter[] = []; + const stopFns: Array<() => Promise> = []; + + for (const providerId of listChannelBridgeProviderIds()) { + const provider = resolveChannelBridgeProviderDefinition(providerId); + const providerConfig = provider.readConfig(params.runtimeConfig); + const runtime = await provider.createRuntime({ config: providerConfig }); + if (!runtime) continue; + adapters.push(...runtime.adapters); + stopFns.push(runtime.stop); + } + + return { + adapters, + stop: async () => { + for (const stop of stopFns) { + try { + await stop(); + } catch { + // Best-effort shutdown: keep stopping remaining provider runtimes. + } + } + }, + }; +} + +export async function startChannelBridgeRuntime(params: Readonly<{ + store: ChannelBindingStore; + deps: ChannelBridgeDeps; + runtimeConfig: ChannelBridgeRuntimeConfig; +}>): Promise { + const providers = await startProviderRuntimes({ runtimeConfig: params.runtimeConfig }); + if (providers.adapters.length === 0) return null; + + let worker: ChannelBridgeWorkerHandle; + try { + worker = startChannelBridgeWorker({ + store: params.store, + adapters: providers.adapters, + deps: params.deps, + tickMs: params.runtimeConfig.tickMs, + }); + } catch (error) { + await providers.stop().catch(() => undefined); + throw error; + } + + let stopped = false; + return { + trigger: worker.trigger, + stop: async () => { + if (stopped) return; + stopped = true; + try { + await worker.stop(); + } finally { + await providers.stop(); + } + }, + }; +} diff --git a/apps/cli/src/channels/startChannelBridgeWorker.startupFailure.test.ts b/apps/cli/src/channels/startChannelBridgeWorker.startupFailure.test.ts index 816a2ff230..2d1d7e9d11 100644 --- a/apps/cli/src/channels/startChannelBridgeWorker.startupFailure.test.ts +++ b/apps/cli/src/channels/startChannelBridgeWorker.startupFailure.test.ts @@ -14,7 +14,7 @@ describe('startChannelBridgeFromEnv startup failures', () => { const relayStop = vi.fn(async () => undefined); - vi.doMock('./core/channelBridgeWorker', () => ({ + vi.doMock('@/channels/core/channelBridgeWorker', () => ({ createInMemoryChannelBindingStore: vi.fn(() => ({ listBindings: async () => [], getBinding: async () => null, @@ -35,7 +35,7 @@ describe('startChannelBridgeFromEnv startup failures', () => { }), })); - vi.doMock('./telegram/telegramAdapter', () => ({ + vi.doMock('@/channels/providers/telegram/telegramAdapter', () => ({ createTelegramChannelAdapter: vi.fn(() => ({ providerId: 'telegram', pullInboundMessages: async () => [], @@ -44,12 +44,16 @@ describe('startChannelBridgeFromEnv startup failures', () => { })), })); - vi.doMock('./telegram/telegramWebhookRelay', () => ({ - startTelegramWebhookRelay: vi.fn(async () => ({ - port: 8787, - stop: relayStop, - })), - })); + vi.doMock('@/channels/providers/telegram/telegramWebhookRelay', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + startTelegramWebhookRelay: vi.fn(async () => ({ + port: 8787, + stop: relayStop, + })), + }; + }); const { startChannelBridgeFromEnv } = await import('./startChannelBridgeWorker'); @@ -82,7 +86,7 @@ describe('startChannelBridgeFromEnv startup failures', () => { }, })); - vi.doMock('./core/channelBridgeWorker', () => ({ + vi.doMock('@/channels/core/channelBridgeWorker', () => ({ createInMemoryChannelBindingStore: vi.fn(() => ({ listBindings: async () => [], getBinding: async () => null, @@ -104,7 +108,7 @@ describe('startChannelBridgeFromEnv startup failures', () => { })), })); - vi.doMock('./telegram/telegramAdapter', () => ({ + vi.doMock('@/channels/providers/telegram/telegramAdapter', () => ({ createTelegramChannelAdapter: vi.fn(() => ({ providerId: 'telegram', pullInboundMessages: async () => [], @@ -113,13 +117,16 @@ describe('startChannelBridgeFromEnv startup failures', () => { })), })); - vi.doMock('./telegram/telegramWebhookRelay', () => ({ - startTelegramWebhookRelay: vi.fn(async () => ({ - port: 8787, - path: '/telegram/webhook/secret-1', - stop: relayStop, - })), - })); + vi.doMock('@/channels/providers/telegram/telegramWebhookRelay', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + startTelegramWebhookRelay: vi.fn(async () => ({ + port: 8787, + stop: relayStop, + })), + }; + }); const { startChannelBridgeFromEnv } = await import('./startChannelBridgeWorker'); @@ -137,7 +144,7 @@ describe('startChannelBridgeFromEnv startup failures', () => { expect(workerStop).toHaveBeenCalledTimes(1); expect(relayStop).toHaveBeenCalledTimes(1); expect(warnSpy).toHaveBeenCalledWith( - '[channelBridge] Error stopping webhook relay during shutdown', + '[channelBridge] Error stopping Telegram webhook relay during shutdown', expect.any(Error), ); }); diff --git a/apps/cli/src/channels/startChannelBridgeWorker.test.ts b/apps/cli/src/channels/startChannelBridgeWorker.test.ts index 352905ce8b..c57701e31c 100644 --- a/apps/cli/src/channels/startChannelBridgeWorker.test.ts +++ b/apps/cli/src/channels/startChannelBridgeWorker.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { startChannelBridgeFromEnv } from './startChannelBridgeWorker'; -import type { ChannelBridgeDeps } from './core/channelBridgeWorker'; +import type { ChannelBridgeDeps } from '@/channels/core/channelBridgeWorker'; type SendCommittedFn = typeof import('@/session/transport/socket/sessionSocketSendMessage').sendSessionMessageViaSocketCommitted; @@ -24,10 +24,10 @@ afterEach(() => { '@/session/transport/socket/sessionSocketSendMessage', '@/session/replay/decryptTranscriptRows', '@/ui/logger', - './core/channelBridgeWorker', - './telegram/telegramAdapter', - './telegram/telegramWebhookRelay', - './channelBridgeServerKv', + '@/channels/core/channelBridgeWorker', + '@/channels/providers/telegram/telegramAdapter', + '@/channels/providers/telegram/telegramWebhookRelay', + '@/channels/state/localBindingStore', ]) { vi.doUnmock(modulePath); } @@ -100,7 +100,7 @@ describe('startChannelBridgeFromEnv', () => { }; }); - vi.doMock('./core/channelBridgeWorker', () => ({ + vi.doMock('@/channels/core/channelBridgeWorker', () => ({ createInMemoryChannelBindingStore: vi.fn(() => ({ listBindings: async () => [], getBinding: async () => null, @@ -110,6 +110,9 @@ describe('startChannelBridgeFromEnv', () => { threadId: null, sessionId: 'sess-1', lastForwardedSeq: 0, + ownerSenderId: 'user-1', + inboundMode: 'ownerOnly', + allowMissingSenderId: false, createdAtMs: Date.now(), updatedAtMs: Date.now(), }), @@ -125,7 +128,7 @@ describe('startChannelBridgeFromEnv', () => { }), })); - vi.doMock('./telegram/telegramAdapter', () => ({ + vi.doMock('@/channels/providers/telegram/telegramAdapter', () => ({ createTelegramChannelAdapter: vi.fn(() => ({ providerId: 'telegram', pullInboundMessages: async () => [], @@ -194,7 +197,7 @@ describe('startChannelBridgeFromEnv', () => { sendSessionMessageViaSocketCommitted: sendCommitted, })); - vi.doMock('./core/channelBridgeWorker', () => ({ + vi.doMock('@/channels/core/channelBridgeWorker', () => ({ createInMemoryChannelBindingStore: vi.fn(() => ({ listBindings: async () => [], getBinding: async () => null, @@ -204,6 +207,9 @@ describe('startChannelBridgeFromEnv', () => { threadId: null, sessionId: 'sess-1', lastForwardedSeq: 0, + ownerSenderId: 'user-1', + inboundMode: 'ownerOnly', + allowMissingSenderId: false, createdAtMs: Date.now(), updatedAtMs: Date.now(), }), @@ -219,7 +225,7 @@ describe('startChannelBridgeFromEnv', () => { }), })); - vi.doMock('./telegram/telegramAdapter', () => ({ + vi.doMock('@/channels/providers/telegram/telegramAdapter', () => ({ createTelegramChannelAdapter: vi.fn(() => ({ providerId: 'telegram', pullInboundMessages: async () => [], @@ -334,7 +340,7 @@ describe('startChannelBridgeFromEnv', () => { decryptTranscriptRows: decryptRows, })); - vi.doMock('./core/channelBridgeWorker', () => ({ + vi.doMock('@/channels/core/channelBridgeWorker', () => ({ createInMemoryChannelBindingStore: vi.fn(() => ({ listBindings: async () => [], getBinding: async () => null, @@ -344,6 +350,9 @@ describe('startChannelBridgeFromEnv', () => { threadId: null, sessionId: 'sess-1', lastForwardedSeq: 0, + ownerSenderId: 'user-1', + inboundMode: 'ownerOnly', + allowMissingSenderId: false, createdAtMs: Date.now(), updatedAtMs: Date.now(), }), @@ -359,7 +368,7 @@ describe('startChannelBridgeFromEnv', () => { }), })); - vi.doMock('./telegram/telegramAdapter', () => ({ + vi.doMock('@/channels/providers/telegram/telegramAdapter', () => ({ createTelegramChannelAdapter: vi.fn(() => ({ providerId: 'telegram', pullInboundMessages: async () => [], @@ -435,7 +444,7 @@ describe('startChannelBridgeFromEnv', () => { sendSessionMessageViaSocketCommitted: sendCommitted, })); - vi.doMock('./core/channelBridgeWorker', () => ({ + vi.doMock('@/channels/core/channelBridgeWorker', () => ({ createInMemoryChannelBindingStore: vi.fn(() => ({ listBindings: async () => [], getBinding: async () => null, @@ -445,6 +454,9 @@ describe('startChannelBridgeFromEnv', () => { threadId: null, sessionId: 'sess-1', lastForwardedSeq: 0, + ownerSenderId: 'user-1', + inboundMode: 'ownerOnly', + allowMissingSenderId: false, createdAtMs: Date.now(), updatedAtMs: Date.now(), }), @@ -460,7 +472,7 @@ describe('startChannelBridgeFromEnv', () => { }), })); - vi.doMock('./telegram/telegramAdapter', () => ({ + vi.doMock('@/channels/providers/telegram/telegramAdapter', () => ({ createTelegramChannelAdapter: vi.fn(() => ({ providerId: 'telegram', pullInboundMessages: async () => [], @@ -597,7 +609,7 @@ describe('startChannelBridgeFromEnv', () => { }; }); - vi.doMock('./core/channelBridgeWorker', () => ({ + vi.doMock('@/channels/core/channelBridgeWorker', () => ({ createInMemoryChannelBindingStore: vi.fn(() => ({ listBindings: async () => [], getBinding: async () => null, @@ -607,6 +619,9 @@ describe('startChannelBridgeFromEnv', () => { threadId: null, sessionId: 'sess-1', lastForwardedSeq: 0, + ownerSenderId: 'user-1', + inboundMode: 'ownerOnly', + allowMissingSenderId: false, createdAtMs: Date.now(), updatedAtMs: Date.now(), }), @@ -622,7 +637,7 @@ describe('startChannelBridgeFromEnv', () => { }), })); - vi.doMock('./telegram/telegramAdapter', () => ({ + vi.doMock('@/channels/providers/telegram/telegramAdapter', () => ({ createTelegramChannelAdapter: vi.fn(() => ({ providerId: 'telegram', pullInboundMessages: async () => [], @@ -685,7 +700,7 @@ describe('startChannelBridgeFromEnv', () => { sendSessionMessageViaSocketCommitted: sendCommitted, })); - vi.doMock('./core/channelBridgeWorker', () => ({ + vi.doMock('@/channels/core/channelBridgeWorker', () => ({ createInMemoryChannelBindingStore: vi.fn(() => ({ listBindings: async () => [], getBinding: async () => null, @@ -695,6 +710,9 @@ describe('startChannelBridgeFromEnv', () => { threadId: null, sessionId: 'sess-1', lastForwardedSeq: 0, + ownerSenderId: 'user-1', + inboundMode: 'ownerOnly', + allowMissingSenderId: false, createdAtMs: Date.now(), updatedAtMs: Date.now(), }), @@ -710,7 +728,7 @@ describe('startChannelBridgeFromEnv', () => { }), })); - vi.doMock('./telegram/telegramAdapter', () => ({ + vi.doMock('@/channels/providers/telegram/telegramAdapter', () => ({ createTelegramChannelAdapter: vi.fn(() => ({ providerId: 'telegram', pullInboundMessages: async () => [], @@ -801,7 +819,7 @@ describe('startChannelBridgeFromEnv', () => { }, })); - vi.doMock('./core/channelBridgeWorker', () => ({ + vi.doMock('@/channels/core/channelBridgeWorker', () => ({ createInMemoryChannelBindingStore: vi.fn(() => ({ listBindings: async () => [], getBinding: async () => null, @@ -811,6 +829,9 @@ describe('startChannelBridgeFromEnv', () => { threadId: null, sessionId: 'sess-1', lastForwardedSeq: 0, + ownerSenderId: 'user-1', + inboundMode: 'ownerOnly', + allowMissingSenderId: false, createdAtMs: Date.now(), updatedAtMs: Date.now(), }), @@ -823,12 +844,12 @@ describe('startChannelBridgeFromEnv', () => { })), })); - vi.doMock('./telegram/telegramAdapter', () => ({ + vi.doMock('@/channels/providers/telegram/telegramAdapter', () => ({ createTelegramChannelAdapter, })); - vi.doMock('./telegram/telegramWebhookRelay', async (importOriginal) => { - const actual = await importOriginal(); + vi.doMock('@/channels/providers/telegram/telegramWebhookRelay', async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, startTelegramWebhookRelay: startRelay, @@ -855,12 +876,15 @@ describe('startChannelBridgeFromEnv', () => { await handle?.stop(); }); - it('stops webhook relay when store initialization fails before worker startup', async () => { - vi.resetModules(); + it('stops webhook relay when binding store initialization fails before worker startup', async () => { + vi.resetModules(); - const relayStop = vi.fn(async () => undefined); - const startWorker = vi.fn(); - const kvInitError = new Error('kv init failed'); + const relayStop = vi.fn(async () => undefined); + const startWorker = vi.fn(() => ({ + trigger: vi.fn(), + stop: vi.fn(async () => undefined), + })); + const storeInitError = new Error('binding store init failed'); vi.doMock('@/ui/logger', () => ({ logger: { @@ -870,7 +894,7 @@ describe('startChannelBridgeFromEnv', () => { }, })); - vi.doMock('./core/channelBridgeWorker', () => ({ + vi.doMock('@/channels/core/channelBridgeWorker', () => ({ createInMemoryChannelBindingStore: vi.fn(() => ({ listBindings: async () => [], getBinding: async () => null, @@ -880,6 +904,9 @@ describe('startChannelBridgeFromEnv', () => { threadId: null, sessionId: 'sess-1', lastForwardedSeq: 0, + ownerSenderId: 'user-1', + inboundMode: 'ownerOnly', + allowMissingSenderId: false, createdAtMs: Date.now(), updatedAtMs: Date.now(), }), @@ -889,7 +916,7 @@ describe('startChannelBridgeFromEnv', () => { startChannelBridgeWorker: startWorker, })); - vi.doMock('./telegram/telegramAdapter', () => ({ + vi.doMock('@/channels/providers/telegram/telegramAdapter', () => ({ createTelegramChannelAdapter: vi.fn(() => ({ providerId: 'telegram', pullInboundMessages: async () => [], @@ -899,65 +926,139 @@ describe('startChannelBridgeFromEnv', () => { })), })); - vi.doMock('./telegram/telegramWebhookRelay', async (importOriginal) => { - const actual = await importOriginal(); - return { - ...actual, - startTelegramWebhookRelay: vi.fn(async () => ({ - port: 8787, - stop: relayStop, - })), - }; - }); + vi.doMock('@/channels/providers/telegram/telegramWebhookRelay', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + startTelegramWebhookRelay: vi.fn(async () => ({ + port: 8787, + stop: relayStop, + })), + }; + }); + + vi.doMock('@/channels/state/localBindingStore', () => ({ + createLocalChannelBindingStore: vi.fn(() => { + throw storeInitError; + }), + })); + + const { startChannelBridgeFromEnv } = await import('./startChannelBridgeWorker'); + + await expect(startChannelBridgeFromEnv({ + credentials, + serverId: 'server-a', + accountId: 'acct-a', + env: { + HAPPIER_TELEGRAM_BOT_TOKEN: 'bot-token', + HAPPIER_TELEGRAM_WEBHOOK_ENABLED: 'true', + HAPPIER_TELEGRAM_WEBHOOK_SECRET: 'secret-token', + } as NodeJS.ProcessEnv, + })).rejects.toThrow('binding store init failed'); + + expect(relayStop).toHaveBeenCalledTimes(0); + expect(startWorker).not.toHaveBeenCalled(); + }); + + it('starts with in-memory bindings when only serverId is provided', async () => { + vi.resetModules(); - vi.doMock('./channelBridgeServerKv', () => ({ - createAxiosChannelBridgeKvClient: vi.fn(() => { - throw kvInitError; + const startWorker = vi.fn(() => ({ + trigger: vi.fn(), + stop: vi.fn(async () => undefined), + })); + const createLocalChannelBindingStore = vi.fn(); + const createInMemoryChannelBindingStore = vi.fn(() => ({ + listBindings: async () => [], + getBinding: async () => null, + upsertBinding: async () => ({ + providerId: 'telegram', + conversationId: 'conv-1', + threadId: null, + sessionId: 'sess-1', + lastForwardedSeq: 0, + ownerSenderId: 'user-1', + inboundMode: 'ownerOnly', + allowMissingSenderId: false, + createdAtMs: Date.now(), + updatedAtMs: Date.now(), }), + updateLastForwardedSeq: async () => true, + removeBinding: async () => false, + })); + + vi.doMock('@/channels/core/channelBridgeWorker', () => ({ + createInMemoryChannelBindingStore, + startChannelBridgeWorker: startWorker, + })); + + vi.doMock('@/channels/state/localBindingStore', () => ({ + createLocalChannelBindingStore, + })); + + vi.doMock('@/channels/providers/telegram/telegramAdapter', () => ({ + createTelegramChannelAdapter: vi.fn(() => ({ + providerId: 'telegram', + pullInboundMessages: async () => [], + sendMessage: async () => undefined, + enqueueWebhookUpdate: vi.fn(), + stop: async () => undefined, + })), })); const { startChannelBridgeFromEnv } = await import('./startChannelBridgeWorker'); - await expect(startChannelBridgeFromEnv({ + const worker = await startChannelBridgeFromEnv({ credentials, - serverId: 'server-a', - accountId: 'acct-a', + serverId: 'server-only', env: { HAPPIER_TELEGRAM_BOT_TOKEN: 'bot-token', - HAPPIER_TELEGRAM_WEBHOOK_ENABLED: 'true', - HAPPIER_TELEGRAM_WEBHOOK_SECRET: 'secret-token', } as NodeJS.ProcessEnv, - })).rejects.toThrow('kv init failed'); + }); - expect(relayStop).toHaveBeenCalledTimes(1); - expect(startWorker).not.toHaveBeenCalled(); + expect(worker).not.toBeNull(); + expect(createInMemoryChannelBindingStore).toHaveBeenCalledTimes(1); + expect(createLocalChannelBindingStore).not.toHaveBeenCalled(); + expect(startWorker).toHaveBeenCalledTimes(1); }); - it('fails fast when only one of serverId/accountId is provided', async () => { + it('uses local bindings when accountId is provided (server scope comes from activeServerDir)', async () => { vi.resetModules(); - const startWorker = vi.fn(); + const startWorker = vi.fn(() => ({ + trigger: vi.fn(), + stop: vi.fn(async () => undefined), + })); + const createInMemoryChannelBindingStore = vi.fn(); + const createLocalChannelBindingStore = vi.fn(() => ({ + listBindings: async () => [], + getBinding: async () => null, + upsertBinding: async () => ({ + providerId: 'telegram', + conversationId: 'conv-1', + threadId: null, + sessionId: 'sess-1', + lastForwardedSeq: 0, + ownerSenderId: 'user-1', + inboundMode: 'ownerOnly', + allowMissingSenderId: false, + createdAtMs: Date.now(), + updatedAtMs: Date.now(), + }), + updateLastForwardedSeq: async () => true, + removeBinding: async () => false, + })); - vi.doMock('./core/channelBridgeWorker', () => ({ - createInMemoryChannelBindingStore: vi.fn(() => ({ - listBindings: async () => [], - getBinding: async () => null, - upsertBinding: async () => ({ - providerId: 'telegram', - conversationId: 'conv-1', - threadId: null, - sessionId: 'sess-1', - lastForwardedSeq: 0, - createdAtMs: Date.now(), - updatedAtMs: Date.now(), - }), - updateLastForwardedSeq: async () => true, - removeBinding: async () => false, - })), + vi.doMock('@/channels/core/channelBridgeWorker', () => ({ + createInMemoryChannelBindingStore, startChannelBridgeWorker: startWorker, })); - vi.doMock('./telegram/telegramAdapter', () => ({ + vi.doMock('@/channels/state/localBindingStore', () => ({ + createLocalChannelBindingStore, + })); + + vi.doMock('@/channels/providers/telegram/telegramAdapter', () => ({ createTelegramChannelAdapter: vi.fn(() => ({ providerId: 'telegram', pullInboundMessages: async () => [], @@ -969,22 +1070,17 @@ describe('startChannelBridgeFromEnv', () => { const { startChannelBridgeFromEnv } = await import('./startChannelBridgeWorker'); - await expect(startChannelBridgeFromEnv({ + const worker = await startChannelBridgeFromEnv({ credentials, - serverId: 'server-only', + accountId: 'acct_123', env: { HAPPIER_TELEGRAM_BOT_TOKEN: 'bot-token', } as NodeJS.ProcessEnv, - })).rejects.toThrow('require both serverId and accountId'); - - await expect(startChannelBridgeFromEnv({ - credentials, - accountId: 'account-only', - env: { - HAPPIER_TELEGRAM_BOT_TOKEN: 'bot-token', - } as NodeJS.ProcessEnv, - })).rejects.toThrow('require both serverId and accountId'); + }); - expect(startWorker).not.toHaveBeenCalled(); + expect(worker).not.toBeNull(); + expect(createLocalChannelBindingStore).toHaveBeenCalledTimes(1); + expect(createInMemoryChannelBindingStore).not.toHaveBeenCalled(); + expect(startWorker).toHaveBeenCalledTimes(1); }); }); diff --git a/apps/cli/src/channels/startChannelBridgeWorker.ts b/apps/cli/src/channels/startChannelBridgeWorker.ts index 4c7527238b..c2d5eba393 100644 --- a/apps/cli/src/channels/startChannelBridgeWorker.ts +++ b/apps/cli/src/channels/startChannelBridgeWorker.ts @@ -4,7 +4,7 @@ * Responsibilities: * - resolve effective bridge runtime config from env/settings/account scope * - construct provider adapters (Telegram today) and optional webhook relay - * - choose binding store implementation (in-memory or server-backed KV) + * - choose binding store implementation (in-memory or local file-backed state) * - build default bridge deps that map channel events <-> session transcript I/O * - start and return the core channel bridge worker lifecycle handle * @@ -42,12 +42,10 @@ import { type ChannelBridgeAgentMessageRow, type ChannelBridgeDeps, type ChannelBridgeWorkerHandle, -} from './core/channelBridgeWorker'; -import { resolveChannelBridgeRuntimeConfig } from './channelBridgeConfig'; -import { createServerBackedChannelBindingStore } from './channelBindingStore.server'; -import { createAxiosChannelBridgeKvClient } from './channelBridgeServerKv'; -import { createTelegramChannelAdapter } from './telegram/telegramAdapter'; -import { startTelegramWebhookRelay, type TelegramWebhookRelayHandle } from './telegram/telegramWebhookRelay'; +} from '@/channels/core/channelBridgeWorker'; +import { resolveChannelBridgeRuntimeConfig } from '@/channels/channelBridgeConfig'; +import { startChannelBridgeRuntime } from '@/channels/runtime/startChannelBridgeRuntime'; +import { createLocalChannelBindingStore } from '@/channels/state/localBindingStore'; export type ChannelBridgeRuntimeHandle = ChannelBridgeWorkerHandle; @@ -212,155 +210,156 @@ function createDefaultChannelBridgeDeps(credentials: Credentials): DefaultChanne return { deps: { - listSessions: async () => { - const displayLimit = 20; - const pageSize = displayLimit; - const maxPages = 1; - const sessions: RawSessionListRow[] = []; - let cursor: string | undefined; - - for (let pageIndex = 0; pageIndex < maxPages; pageIndex += 1) { - const page = await fetchSessionsPage({ - token: credentials.token, - activeOnly: false, - limit: pageSize, - cursor, - }); - sessions.push(...page.sessions); - if (sessions.length >= displayLimit) { - cursor = undefined; - break; + listSessions: async () => { + const displayLimit = 20; + const pageSize = displayLimit; + const maxPages = 1; + const sessions: RawSessionListRow[] = []; + let cursor: string | undefined; + + for (let pageIndex = 0; pageIndex < maxPages; pageIndex += 1) { + const page = await fetchSessionsPage({ + token: credentials.token, + activeOnly: false, + limit: pageSize, + cursor, + }); + sessions.push(...page.sessions); + if (sessions.length >= displayLimit) { + cursor = undefined; + break; + } + if (!page.hasNext || !page.nextCursor) { + cursor = undefined; + break; + } + cursor = page.nextCursor; } - if (!page.hasNext || !page.nextCursor) { - cursor = undefined; - break; + + return sessions.slice(0, displayLimit).map((row) => { + const meta = tryDecryptSessionMetadata({ credentials, rawSession: row }); + const tagRaw = meta?.tag; + const tag = typeof tagRaw === 'string' ? tagRaw.trim() : ''; + return { + sessionId: row.id, + label: tag.length > 0 ? tag : null, + }; + }); + }, + resolveSessionIdOrPrefix: async (idOrPrefix: string) => { + return await resolveSessionIdOrPrefix({ + credentials, + idOrPrefix, + }); + }, + sendUserMessageToSession: async (params) => { + const runtime = await resolveSessionRuntime(params.sessionId); + if (!runtime) { + throw new Error(`Session not found: ${params.sessionId}`); } - cursor = page.nextCursor; - } - return sessions.slice(0, displayLimit).map((row) => { - const meta = tryDecryptSessionMetadata({ credentials, rawSession: row }); - const tagRaw = meta?.tag; - const tag = typeof tagRaw === 'string' ? tagRaw.trim() : ''; - return { - sessionId: row.id, - label: tag.length > 0 ? tag : null, + const ctx = runtime.ctx; + const mode = runtime.mode; + const record = { + role: 'user', + content: { type: 'text', text: params.text }, + meta: { + sentFrom: params.sentFrom, + source: params.sentFrom, + channel: { + providerId: params.providerId, + conversationId: params.conversationId, + threadId: params.threadId, + }, + }, }; - }); - }, - resolveSessionIdOrPrefix: async (idOrPrefix: string) => { - return await resolveSessionIdOrPrefix({ - credentials, - idOrPrefix, - }); - }, - sendUserMessageToSession: async (params) => { - const runtime = await resolveSessionRuntime(params.sessionId); - if (!runtime) { - throw new Error(`Session not found: ${params.sessionId}`); - } - const ctx = runtime.ctx; - const mode = runtime.mode; - const record = { - role: 'user', - content: { type: 'text', text: params.text }, - meta: { - sentFrom: params.sentFrom, - source: params.sentFrom, - channel: { + const content = + mode === 'plain' + ? ({ t: 'plain', v: record } as const) + : ({ t: 'encrypted', c: encryptSessionPayload({ ctx, payload: record }) } as const); + + await sendSessionMessageViaSocketCommitted({ + token: credentials.token, + sessionId: params.sessionId, + content, + localId: createStableBridgeLocalId({ providerId: params.providerId, conversationId: params.conversationId, threadId: params.threadId, - }, - }, - }; - - const content = - mode === 'plain' - ? ({ t: 'plain', v: record } as const) - : ({ t: 'encrypted', c: encryptSessionPayload({ ctx, payload: record }) } as const); - - await sendSessionMessageViaSocketCommitted({ - token: credentials.token, - sessionId: params.sessionId, - content, - localId: createStableBridgeLocalId({ - providerId: params.providerId, - conversationId: params.conversationId, - threadId: params.threadId, - sessionId: params.sessionId, - text: params.text, - messageId: params.messageId, - }), - sentFrom: params.sentFrom, - }); - }, - resolveLatestSessionSeq: async (sessionId: string) => { - const rows = await fetchEncryptedTranscriptPageLatest({ - token: credentials.token, - sessionId, - limit: 1, - }); - if (rows.length === 0) return 0; - return Math.max(0, Math.trunc(rows[0]!.seq)); - }, - fetchAgentMessagesAfterSeq: async ({ sessionId, afterSeq }) => { - const runtime = await resolveSessionRuntime(sessionId); - if (!runtime) { - return { - messages: [], - highestSeenSeq: null, - }; - } + sessionId: params.sessionId, + text: params.text, + messageId: params.messageId, + }), + sentFrom: params.sentFrom, + }); + }, + resolveLatestSessionSeq: async (sessionId: string) => { + const rows = await fetchEncryptedTranscriptPageLatest({ + token: credentials.token, + sessionId, + limit: 1, + }); + if (rows.length === 0) return 0; + return Math.max(0, Math.trunc(rows[0]!.seq)); + }, + fetchAgentMessagesAfterSeq: async ({ sessionId, afterSeq }) => { + const runtime = await resolveSessionRuntime(sessionId); + if (!runtime) { + return { + messages: [], + highestSeenSeq: null, + }; + } - const encrypted = await fetchEncryptedTranscriptPageAfterSeq({ - token: credentials.token, - sessionId, - afterSeq, - limit: 50, - }); - - const highestSeenSeq = encrypted.reduce((currentMax, row) => { - const parsedSeq = toNonNegativeInt(row.seq); - if (parsedSeq === null) return currentMax; - if (currentMax === null) return parsedSeq; - return parsedSeq > currentMax ? parsedSeq : currentMax; - }, null); - - const transcriptRows = runtime.mode === 'plain' - ? parsePlainTranscriptRows(encrypted) - : decryptTranscriptRows({ - ctx: runtime.ctx, - rows: encrypted, + const encrypted = await fetchEncryptedTranscriptPageAfterSeq({ + token: credentials.token, + sessionId, + afterSeq, + limit: 50, }); - const messages: ChannelBridgeAgentMessageRow[] = []; - for (const row of transcriptRows) { - if (row.role !== 'agent') { - continue; - } + const highestSeenSeq = encrypted.reduce((currentMax, row) => { + const parsedSeq = toNonNegativeInt(row.seq); + if (parsedSeq === null) return currentMax; + if (currentMax === null) return parsedSeq; + return parsedSeq > currentMax ? parsedSeq : currentMax; + }, null); + + const transcriptRows = + runtime.mode === 'plain' + ? parsePlainTranscriptRows(encrypted) + : decryptTranscriptRows({ + ctx: runtime.ctx, + rows: encrypted, + }); - const text = extractAssistantText(row.content); - if (!text) { - onWarning( - `Skipped agent transcript row with unsupported content for session=${sessionId} seq=${row.seq}`, - ); - continue; - } + const messages: ChannelBridgeAgentMessageRow[] = []; + for (const row of transcriptRows) { + if (row.role !== 'agent') { + continue; + } - messages.push({ - seq: row.seq, - text, - }); - } + const text = extractAssistantText(row.content); + if (!text) { + onWarning( + `Skipped agent transcript row with unsupported content for session=${sessionId} seq=${row.seq}`, + ); + continue; + } - return { - messages, - highestSeenSeq, - }; - }, - onWarning, + messages.push({ + seq: row.seq, + text, + }); + } + + return { + messages, + highestSeenSeq, + }; + }, + onWarning, }, dispose: () => { sessionRuntimeCache.clear(); @@ -386,72 +385,6 @@ export async function startChannelBridgeFromEnv(params: Readonly<{ accountId: params.accountId, }); - let relayHandle: TelegramWebhookRelayHandle | null = null; - const adapters: ChannelBridgeAdapter[] = params.adapters ? [...params.adapters] : []; - if (adapters.length === 0) { - const botToken = runtimeConfig.telegram.botToken; - if (!botToken) { - return null; - } - - const allowedChatIdsRaw = runtimeConfig.telegram.allowedChatIds; - const allowedChatIds = allowedChatIdsRaw.length > 0 ? new Set(allowedChatIdsRaw) : null; - const webhookEnabled = runtimeConfig.telegram.webhookEnabled; - const webhookSecret = runtimeConfig.telegram.webhookSecret; - const requireTopics = runtimeConfig.telegram.requireTopics; - - if (webhookEnabled && webhookSecret.length === 0) { - logger.warn('[channelBridge] Telegram webhook.enabled=true but webhook.secret is missing; falling back to polling mode'); - } - - const webhookModeRequested = webhookEnabled && webhookSecret.length > 0; - let telegramAdapter = createTelegramChannelAdapter({ - botToken, - allowedChatIds, - requireTopics, - webhookMode: webhookModeRequested, - }); - - const telegramAdapterIndex = adapters.push(telegramAdapter) - 1; - - if (webhookModeRequested) { - const port = runtimeConfig.telegram.webhookPort; - const host = runtimeConfig.telegram.webhookHost; - try { - relayHandle = await startTelegramWebhookRelay({ - port, - host, - // We intentionally use one shared secret today because bridge config currently - // exposes a single webhook secret field. The relay API keeps both knobs - // separate so we can split path/header secrets in a future config version. - secretPathToken: webhookSecret, - secretHeaderToken: webhookSecret, - onUpdate: telegramAdapter.enqueueWebhookUpdate, - }); - logger.debug( - `[channelBridge] Telegram webhook relay listening on http://${host}:${relayHandle.port} (path redacted)`, - ); - } catch (error) { - logger.warn( - '[channelBridge] Failed to start Telegram webhook relay; bridge will continue without webhook relay', - serializeAxiosErrorForLog(error), - ); - telegramAdapter = createTelegramChannelAdapter({ - botToken, - allowedChatIds, - requireTopics, - webhookMode: false, - }); - adapters[telegramAdapterIndex] = telegramAdapter; - logger.warn('[channelBridge] Falling back to Telegram polling mode because webhook relay failed to start'); - } - } - } - - if (adapters.length === 0) { - return null; - } - let defaultDepsHandle: DefaultChannelBridgeDepsHandle | null = null; try { const deps = (() => { @@ -462,30 +395,33 @@ export async function startChannelBridgeFromEnv(params: Readonly<{ let store = params.store ?? null; if (!store) { - const serverId = typeof params.serverId === 'string' ? params.serverId.trim() : ''; const accountId = typeof params.accountId === 'string' ? params.accountId.trim() : ''; - if ((serverId.length > 0) !== (accountId.length > 0)) { - throw new Error('Channel bridge server-backed bindings require both serverId and accountId'); - } - if (serverId && accountId) { - const kv = createAxiosChannelBridgeKvClient({ token: params.credentials.token }); - store = createServerBackedChannelBindingStore({ - kv, - serverId, - accountId, - }); + if (accountId) { + store = createLocalChannelBindingStore({ accountId }); } } if (!store) { store = createInMemoryChannelBindingStore(); } - const worker = startChannelBridgeWorker({ - store, - adapters, - deps, - tickMs: runtimeConfig.tickMs, - }); + const injectedAdapters = params.adapters ? [...params.adapters] : []; + const worker = injectedAdapters.length > 0 + ? startChannelBridgeWorker({ + store, + adapters: injectedAdapters, + deps, + tickMs: runtimeConfig.tickMs, + }) + : await startChannelBridgeRuntime({ + store, + deps, + runtimeConfig, + }); + + if (!worker) { + defaultDepsHandle?.dispose(); + return null; + } return { trigger: worker.trigger, @@ -493,20 +429,11 @@ export async function startChannelBridgeFromEnv(params: Readonly<{ try { await worker.stop(); } finally { - try { - if (relayHandle) { - await relayHandle.stop().catch((error: unknown) => { - logger.warn('[channelBridge] Error stopping webhook relay during shutdown', error); - }); - } - } finally { - defaultDepsHandle?.dispose(); - } + defaultDepsHandle?.dispose(); } }, }; } catch (error) { - await relayHandle?.stop().catch(() => undefined); defaultDepsHandle?.dispose(); throw error; } diff --git a/apps/cli/src/channels/state/localBindingStore.test.ts b/apps/cli/src/channels/state/localBindingStore.test.ts new file mode 100644 index 0000000000..b1c5587e04 --- /dev/null +++ b/apps/cli/src/channels/state/localBindingStore.test.ts @@ -0,0 +1,97 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createEnvKeyScope } from '@/testkit/env/envScope'; +import { withTempDir } from '@/testkit/fs/tempDir'; + +describe('createLocalChannelBindingStore', () => { + const envKeys = ['HAPPIER_HOME_DIR'] as const; + let envScope = createEnvKeyScope(envKeys); + + afterEach(() => { + envScope.restore(); + envScope = createEnvKeyScope(envKeys); + vi.resetModules(); + }); + + it('persists bindings under the active server directory scoped by account', async () => { + await withTempDir('happier-channel-bridge-bindings-', async (homeDir) => { + envScope.patch({ HAPPIER_HOME_DIR: homeDir }); + vi.resetModules(); + + const { createLocalChannelBindingStore } = await import('./localBindingStore'); + + const storeA = createLocalChannelBindingStore({ + accountId: 'acct-1', + }); + + await storeA.upsertBinding({ + providerId: 'telegram', + conversationId: '-1001', + threadId: null, + sessionId: 'sess-1', + lastForwardedSeq: 0, + ownerSenderId: 'user-1', + inboundMode: 'ownerOnly', + allowMissingSenderId: false, + }); + + const storeB = createLocalChannelBindingStore({ + accountId: 'acct-1', + }); + + await expect(storeB.getBinding({ providerId: 'telegram', conversationId: '-1001', threadId: null })).resolves.toEqual( + expect.objectContaining({ + providerId: 'telegram', + conversationId: '-1001', + threadId: null, + sessionId: 'sess-1', + lastForwardedSeq: 0, + }), + ); + }); + }); + + it('keeps lastForwardedSeq monotonic', async () => { + await withTempDir('happier-channel-bridge-bindings-monotonic-', async (homeDir) => { + envScope.patch({ HAPPIER_HOME_DIR: homeDir }); + vi.resetModules(); + + const { createLocalChannelBindingStore } = await import('./localBindingStore'); + + const store = createLocalChannelBindingStore({ + accountId: 'acct-1', + }); + + await store.upsertBinding({ + providerId: 'telegram', + conversationId: '-1001', + threadId: null, + sessionId: 'sess-1', + lastForwardedSeq: 0, + ownerSenderId: 'user-1', + inboundMode: 'ownerOnly', + allowMissingSenderId: false, + }); + + await expect( + store.updateLastForwardedSeq( + { providerId: 'telegram', conversationId: '-1001', threadId: null }, + { expectedSessionId: 'sess-1', seq: 10 }, + ), + ).resolves.toBe(true); + + await expect( + store.updateLastForwardedSeq( + { providerId: 'telegram', conversationId: '-1001', threadId: null }, + { expectedSessionId: 'sess-1', seq: 5 }, + ), + ).resolves.toBe(false); + + await expect(store.getBinding({ providerId: 'telegram', conversationId: '-1001', threadId: null })).resolves.toEqual( + expect.objectContaining({ + lastForwardedSeq: 10, + }), + ); + }); + }); +}); diff --git a/apps/cli/src/channels/state/localBindingStore.ts b/apps/cli/src/channels/state/localBindingStore.ts new file mode 100644 index 0000000000..d08fdd1078 --- /dev/null +++ b/apps/cli/src/channels/state/localBindingStore.ts @@ -0,0 +1,309 @@ +import { chmod, mkdir, readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import type { ChannelBindingStore, ChannelBridgeConversationRef, ChannelSessionBinding } from '@/channels/core/channelBridgeWorker'; +import { configuration } from '@/configuration'; +import { writeJsonAtomic } from '@/utils/fs/writeJsonAtomic'; +import { logger } from '@/ui/logger'; + +type RecordLike = Record; + +type StoredBindingsDocV1 = Readonly<{ + schemaVersion: 1; + bindings: Array>; +}>; + +const ACCOUNT_ID_SAFE_RE = /^[A-Za-z0-9._-]{1,128}$/; + +function assertFilesystemSafeAccountId(raw: string): string { + const value = raw.trim(); + if (!value) { + throw new Error('Invalid accountId: empty'); + } + if (value === '.' || value === '..') { + throw new Error(`Invalid accountId: ${value}`); + } + if (value.includes('/') || value.includes('\\')) { + throw new Error(`Invalid accountId: ${value}`); + } + if (!ACCOUNT_ID_SAFE_RE.test(value)) { + throw new Error(`Invalid accountId: ${value}`); + } + return value; +} + +function asRecord(value: unknown): RecordLike | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + return value as RecordLike; +} + +function toNonNegativeInt(value: unknown): number | null { + if (typeof value !== 'number' || !Number.isFinite(value)) return null; + const truncated = Math.trunc(value); + if (truncated < 0) return null; + return truncated; +} + +function bindingKey(ref: ChannelBridgeConversationRef): string { + return JSON.stringify([ref.providerId, ref.conversationId, ref.threadId]); +} + +function cloneBinding(binding: ChannelSessionBinding): ChannelSessionBinding { + return { + providerId: binding.providerId, + conversationId: binding.conversationId, + threadId: binding.threadId, + sessionId: binding.sessionId, + lastForwardedSeq: binding.lastForwardedSeq, + ownerSenderId: binding.ownerSenderId, + inboundMode: binding.inboundMode, + allowMissingSenderId: binding.allowMissingSenderId, + createdAtMs: binding.createdAtMs, + updatedAtMs: binding.updatedAtMs, + }; +} + +function cloneBindings(bindings: readonly ChannelSessionBinding[]): ChannelSessionBinding[] { + return bindings.map((binding) => cloneBinding(binding)); +} + +function parseStoredBindingsDoc(value: unknown): StoredBindingsDocV1 | null { + const record = asRecord(value); + if (!record) return null; + if (record.schemaVersion !== 1) return null; + const bindingsRaw = record.bindings; + if (!Array.isArray(bindingsRaw)) return null; + + const bindings: StoredBindingsDocV1['bindings'] = []; + for (const entry of bindingsRaw) { + const row = asRecord(entry); + if (!row) continue; + if (typeof row.providerId !== 'string' || row.providerId.trim().length === 0) continue; + if (typeof row.conversationId !== 'string' || row.conversationId.trim().length === 0) continue; + if (typeof row.sessionId !== 'string' || row.sessionId.trim().length === 0) continue; + const threadId = + row.threadId === null + ? null + : typeof row.threadId === 'string' && row.threadId.trim().length > 0 + ? row.threadId.trim() + : null; + const lastForwardedSeq = toNonNegativeInt(row.lastForwardedSeq) ?? 0; + const ownerSenderId = + typeof row.ownerSenderId === 'string' && row.ownerSenderId.trim().length > 0 + ? row.ownerSenderId.trim() + : null; + const inboundMode = row.inboundMode === 'anyone' ? 'anyone' : 'ownerOnly'; + const allowMissingSenderId = row.allowMissingSenderId === true; + const createdAtMs = toNonNegativeInt(row.createdAtMs) ?? Date.now(); + const updatedAtMs = toNonNegativeInt(row.updatedAtMs) ?? createdAtMs; + bindings.push({ + providerId: row.providerId.trim(), + conversationId: row.conversationId.trim(), + threadId, + sessionId: row.sessionId.trim(), + lastForwardedSeq, + ownerSenderId, + inboundMode, + allowMissingSenderId, + createdAtMs, + updatedAtMs, + }); + } + + return { + schemaVersion: 1, + bindings, + }; +} + +async function bestEffortChmod0700(path: string): Promise { + if (process.platform === 'win32') return; + await chmod(path, 0o700).catch(() => {}); +} + +function resolveBindingsFilePath(accountId: string): string { + return join(configuration.activeServerDir, 'channel-bridges', 'v1', 'account', accountId, 'bindings.json'); +} + +type BindingCache = Readonly<{ + bindings: ChannelSessionBinding[]; + fetchedAtMs: number; +}>; + +export function createLocalChannelBindingStore(params: Readonly<{ + accountId: string; + cacheTtlMs?: number; +}>): ChannelBindingStore { + const accountId = assertFilesystemSafeAccountId(params.accountId); + const cacheTtlMs = typeof params.cacheTtlMs === 'number' ? Math.max(0, Math.trunc(params.cacheTtlMs)) : 1_000; + const bindingsFile = resolveBindingsFilePath(accountId); + let cache: BindingCache | null = null; + let queue = Promise.resolve(); + + const enqueue = (work: () => Promise): Promise => { + const run = queue.then(work, work); + queue = run.then(() => undefined, () => undefined); + return run; + }; + + async function load(forceRefresh: boolean): Promise { + if (!forceRefresh && cache && Date.now() - cache.fetchedAtMs <= cacheTtlMs) { + return cache; + } + + try { + const raw = await readFile(bindingsFile, { encoding: 'utf-8' }).catch((error: unknown) => { + const err = error as NodeJS.ErrnoException; + if (err?.code === 'ENOENT') return null; + throw error; + }); + const parsed = raw ? parseStoredBindingsDoc(JSON.parse(raw)) : null; + const next: BindingCache = { + bindings: parsed ? parsed.bindings.map((binding) => ({ + providerId: binding.providerId, + conversationId: binding.conversationId, + threadId: binding.threadId, + sessionId: binding.sessionId, + lastForwardedSeq: binding.lastForwardedSeq, + ownerSenderId: binding.ownerSenderId, + inboundMode: binding.inboundMode, + allowMissingSenderId: binding.allowMissingSenderId, + createdAtMs: binding.createdAtMs, + updatedAtMs: binding.updatedAtMs, + })) : [], + fetchedAtMs: Date.now(), + }; + cache = next; + return next; + } catch (error) { + logger.warn('[channelBridge] Failed to read local channel bridge bindings; using empty bindings', error); + const next: BindingCache = { bindings: [], fetchedAtMs: Date.now() }; + cache = next; + return next; + } + } + + async function persist(bindings: readonly ChannelSessionBinding[]): Promise { + await mkdir(join(configuration.activeServerDir, 'channel-bridges', 'v1'), { recursive: true, mode: 0o700 }); + await bestEffortChmod0700(configuration.activeServerDir); + + const doc: StoredBindingsDocV1 = { + schemaVersion: 1, + bindings: bindings.map((binding) => ({ + providerId: binding.providerId, + conversationId: binding.conversationId, + threadId: binding.threadId, + sessionId: binding.sessionId, + lastForwardedSeq: binding.lastForwardedSeq, + ownerSenderId: binding.ownerSenderId, + inboundMode: binding.inboundMode, + allowMissingSenderId: binding.allowMissingSenderId, + createdAtMs: binding.createdAtMs, + updatedAtMs: binding.updatedAtMs, + })), + }; + await writeJsonAtomic(bindingsFile, doc); + await bestEffortChmod0700(join(configuration.activeServerDir, 'channel-bridges')); + await bestEffortChmod0700(join(configuration.activeServerDir, 'channel-bridges', 'v1')); + } + + function setCache(bindings: readonly ChannelSessionBinding[]): void { + cache = { + bindings: cloneBindings(bindings), + fetchedAtMs: Date.now(), + }; + } + + return { + listBindings: async () => { + const current = await load(false); + return cloneBindings(current.bindings); + }, + getBinding: async (ref) => { + const current = await load(false); + const key = bindingKey(ref); + const found = current.bindings.find((binding) => bindingKey(binding) === key); + return found ? cloneBinding(found) : null; + }, + upsertBinding: async (binding) => enqueue(async () => { + const now = Date.now(); + const current = await load(true); + const key = bindingKey(binding); + const existing = current.bindings.find((row) => bindingKey(row) === key); + const next: ChannelSessionBinding = { + providerId: binding.providerId, + conversationId: binding.conversationId, + threadId: binding.threadId, + sessionId: binding.sessionId, + lastForwardedSeq: Math.max(0, Math.trunc(binding.lastForwardedSeq)), + ownerSenderId: typeof binding.ownerSenderId === 'string' && binding.ownerSenderId.trim().length > 0 ? binding.ownerSenderId.trim() : null, + inboundMode: binding.inboundMode === 'anyone' ? 'anyone' : 'ownerOnly', + allowMissingSenderId: binding.allowMissingSenderId === true, + createdAtMs: existing?.createdAtMs ?? now, + updatedAtMs: now, + }; + const without = current.bindings.filter((row) => bindingKey(row) !== key); + const nextBindings = [...without, next]; + await persist(nextBindings); + setCache(nextBindings); + return cloneBinding(next); + }), + updateLastForwardedSeq: async (ref, params) => enqueue(async () => { + const current = await load(true); + const key = bindingKey(ref); + const nextBindings = current.bindings.map((row) => { + if (bindingKey(row) !== key) return row; + if (row.sessionId !== params.expectedSessionId) return row; + const nextSeq = Math.max(0, Math.trunc(params.seq)); + if (nextSeq <= row.lastForwardedSeq) return row; + return { + ...row, + lastForwardedSeq: nextSeq, + updatedAtMs: Date.now(), + }; + }); + + const before = current.bindings.find((row) => bindingKey(row) === key); + const after = nextBindings.find((row) => bindingKey(row) === key); + const changed = + !!before && + !!after && + before.sessionId === params.expectedSessionId && + after.sessionId === params.expectedSessionId && + after.lastForwardedSeq > before.lastForwardedSeq; + + if (!changed) { + setCache(nextBindings); + return false; + } + + await persist(nextBindings); + setCache(nextBindings); + return true; + }), + removeBinding: async (ref) => enqueue(async () => { + const current = await load(true); + const key = bindingKey(ref); + const nextBindings = current.bindings.filter((row) => bindingKey(row) !== key); + const removed = nextBindings.length !== current.bindings.length; + if (!removed) { + setCache(nextBindings); + return false; + } + await persist(nextBindings); + setCache(nextBindings); + return true; + }), + }; +} diff --git a/apps/cli/src/cli/commands/bridge.test.ts b/apps/cli/src/cli/commands/bridge.test.ts index b5b083be63..889d28663b 100644 --- a/apps/cli/src/cli/commands/bridge.test.ts +++ b/apps/cli/src/cli/commands/bridge.test.ts @@ -4,7 +4,7 @@ import { join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { configuration, reloadConfiguration } from '@/configuration'; +import { reloadConfiguration } from '@/configuration'; const readCredentialsMock = vi.fn(); const readSettingsMock = vi.fn(); @@ -12,12 +12,6 @@ const updateSettingsMock = vi.fn(); const decodeJwtPayloadMock = vi.fn(); const checkDaemonMock = vi.fn(); -const createKvClientMock = vi.fn(); -const readKvConfigMock = vi.fn(); -const upsertKvConfigMock = vi.fn(); -const clearKvConfigMock = vi.fn(); -const replaceKvRawConfigMock = vi.fn(); - vi.mock('@/persistence', () => ({ readCredentials: readCredentialsMock, readSettings: readSettingsMock, @@ -32,23 +26,19 @@ vi.mock('@/daemon/controlClient', () => ({ checkIfDaemonRunningAndCleanupStaleState: checkDaemonMock, })); -vi.mock('@/channels/channelBridgeServerKv', () => ({ - createAxiosChannelBridgeKvClient: createKvClientMock, - upsertChannelBridgeTelegramConfigInKv: upsertKvConfigMock, - clearChannelBridgeTelegramConfigInKv: clearKvConfigMock, - replaceChannelBridgeTelegramConfigRawInKv: replaceKvRawConfigMock, - readChannelBridgeTelegramConfigFromKv: readKvConfigMock, -})); - -describe('happier bridge command', () => { +describe('happier bridge command (local-only v1)', () => { let homeDir: string; const prevHomeDir = process.env.HAPPIER_HOME_DIR; const prevServerUrl = process.env.HAPPIER_SERVER_URL; const prevWebappUrl = process.env.HAPPIER_WEBAPP_URL; + let logSpy: ReturnType | null = null; + let lastUpdatedSettings: unknown = null; beforeEach(async () => { vi.resetModules(); vi.clearAllMocks(); + logSpy?.mockRestore(); + logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); readCredentialsMock.mockResolvedValue({ token: 'token.jwt' }); decodeJwtPayloadMock.mockReturnValue({ sub: 'acct_123' }); @@ -56,15 +46,9 @@ describe('happier bridge command', () => { checkDaemonMock.mockResolvedValue(false); updateSettingsMock.mockImplementation(async (updater: (current: unknown) => Promise | unknown) => { - await updater({}); + lastUpdatedSettings = await updater({}); }); - createKvClientMock.mockReturnValue({ get: vi.fn(), mutate: vi.fn() }); - readKvConfigMock.mockResolvedValue({ record: null, version: -1, rawValueBase64: null }); - upsertKvConfigMock.mockResolvedValue(12); - clearKvConfigMock.mockResolvedValue(7); - replaceKvRawConfigMock.mockResolvedValue(undefined); - homeDir = await mkdtemp(join(tmpdir(), 'happier-bridge-cmd-')); process.env.HAPPIER_HOME_DIR = homeDir; process.env.HAPPIER_SERVER_URL = 'http://127.0.0.1:3005'; @@ -73,6 +57,8 @@ describe('happier bridge command', () => { }); afterEach(async () => { + logSpy?.mockRestore(); + logSpy = null; process.exitCode = undefined; if (prevHomeDir === undefined) delete process.env.HAPPIER_HOME_DIR; else process.env.HAPPIER_HOME_DIR = prevHomeDir; @@ -84,7 +70,7 @@ describe('happier bridge command', () => { await rm(homeDir, { recursive: true, force: true }); }); - it('writes telegram non-secret set updates to server KV and local settings', async () => { + it('writes telegram set updates to local settings', async () => { const { handleBridgeCliCommand } = await import('./bridge'); await handleBridgeCliCommand({ @@ -93,59 +79,17 @@ describe('happier bridge command', () => { terminalRuntime: null, }); - expect(createKvClientMock).toHaveBeenCalledWith({ token: 'token.jwt' }); - expect(upsertKvConfigMock).toHaveBeenCalledWith({ - kv: expect.any(Object), - serverId: configuration.activeServerId, - accountId: 'acct_123', - update: { - allowedChatIds: [], - requireTopics: true, - }, - }); expect(updateSettingsMock).toHaveBeenCalledTimes(1); - expect(upsertKvConfigMock.mock.invocationCallOrder[0]).toBeLessThan(updateSettingsMock.mock.invocationCallOrder[0]); - expect(process.exitCode).toBeUndefined(); - }); - - it('does not update local settings when shared KV write fails', async () => { - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); - upsertKvConfigMock.mockRejectedValueOnce(new Error('KV unavailable')); - - try { - const { handleBridgeCliCommand } = await import('./bridge'); - - await handleBridgeCliCommand({ - args: ['bridge', 'telegram', 'set', '--bot-token', 'bot-token-1', '--require-topics', 'true'], - rawArgv: [], - terminalRuntime: null, - }); - - expect(upsertKvConfigMock).toHaveBeenCalledTimes(1); - expect(updateSettingsMock).not.toHaveBeenCalled(); - expect(process.exitCode).toBe(1); - expect(errorSpy).toHaveBeenCalledWith(expect.anything(), expect.stringContaining('KV unavailable')); - } finally { - errorSpy.mockRestore(); - } - }); - - it('does not write secrets-only updates to server KV', async () => { - const { handleBridgeCliCommand } = await import('./bridge'); - - await handleBridgeCliCommand({ - args: ['bridge', 'telegram', 'set', '--bot-token', 'bot-token-1'], - rawArgv: [], - terminalRuntime: null, + expect(lastUpdatedSettings).toMatchObject({ + experiments: true, + featureToggles: { + channelBridges: true, + }, }); - - expect(createKvClientMock).not.toHaveBeenCalled(); - expect(upsertKvConfigMock).not.toHaveBeenCalled(); - expect(updateSettingsMock).toHaveBeenCalledTimes(1); expect(process.exitCode).toBeUndefined(); }); - it('clears telegram config in server KV and local settings', async () => { + it('clears telegram config in local settings', async () => { const { handleBridgeCliCommand } = await import('./bridge'); await handleBridgeCliCommand({ @@ -154,39 +98,10 @@ describe('happier bridge command', () => { terminalRuntime: null, }); - expect(createKvClientMock).toHaveBeenCalledWith({ token: 'token.jwt' }); - expect(clearKvConfigMock).toHaveBeenCalledWith({ - kv: expect.any(Object), - serverId: configuration.activeServerId, - accountId: 'acct_123', - }); expect(updateSettingsMock).toHaveBeenCalledTimes(1); - expect(clearKvConfigMock.mock.invocationCallOrder[0]).toBeLessThan(updateSettingsMock.mock.invocationCallOrder[0]); expect(process.exitCode).toBeUndefined(); }); - it('does not update local settings when shared KV clear fails', async () => { - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); - clearKvConfigMock.mockRejectedValueOnce(new Error('KV clear unavailable')); - - try { - const { handleBridgeCliCommand } = await import('./bridge'); - - await handleBridgeCliCommand({ - args: ['bridge', 'telegram', 'clear'], - rawArgv: [], - terminalRuntime: null, - }); - - expect(clearKvConfigMock).toHaveBeenCalledTimes(1); - expect(updateSettingsMock).not.toHaveBeenCalled(); - expect(process.exitCode).toBe(1); - expect(errorSpy).toHaveBeenCalledWith(expect.anything(), expect.stringContaining('KV clear unavailable')); - } finally { - errorSpy.mockRestore(); - } - }); - it('rejects webhook secrets that do not match Telegram-safe token charset', async () => { const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); try { @@ -199,8 +114,6 @@ describe('happier bridge command', () => { }); expect(updateSettingsMock).not.toHaveBeenCalled(); - expect(createKvClientMock).not.toHaveBeenCalled(); - expect(upsertKvConfigMock).not.toHaveBeenCalled(); expect(process.exitCode).toBe(1); expect(errorSpy).toHaveBeenCalledWith( expect.anything(), @@ -213,194 +126,23 @@ describe('happier bridge command', () => { it('rejects explicitly passed empty --bot-token values', async () => { const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); - try { const { handleBridgeCliCommand } = await import('./bridge'); await handleBridgeCliCommand({ - args: ['bridge', 'telegram', 'set', '--bot-token'], + args: ['bridge', 'telegram', 'set', '--bot-token', ' '], rawArgv: [], terminalRuntime: null, }); expect(updateSettingsMock).not.toHaveBeenCalled(); - expect(createKvClientMock).not.toHaveBeenCalled(); expect(process.exitCode).toBe(1); expect(errorSpy).toHaveBeenCalledWith( expect.anything(), - expect.stringContaining('Invalid --bot-token value: cannot be empty'), + expect.stringContaining('Invalid --bot-token value'), ); } finally { errorSpy.mockRestore(); } }); - - it('rejects malformed --allowed-chat-ids that parse to an empty list', async () => { - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); - - try { - const { handleBridgeCliCommand } = await import('./bridge'); - - await handleBridgeCliCommand({ - args: ['bridge', 'telegram', 'set', '--allowed-chat-ids', ',,,'], - rawArgv: [], - terminalRuntime: null, - }); - - expect(updateSettingsMock).not.toHaveBeenCalled(); - expect(createKvClientMock).not.toHaveBeenCalled(); - expect(process.exitCode).toBe(1); - expect(errorSpy).toHaveBeenCalledWith( - expect.anything(), - expect.stringContaining('Invalid --allowed-chat-ids value'), - ); - } finally { - errorSpy.mockRestore(); - } - }); - - it('rejects non-loopback --webhook-host values', async () => { - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); - - try { - const { handleBridgeCliCommand } = await import('./bridge'); - - await handleBridgeCliCommand({ - args: ['bridge', 'telegram', 'set', '--webhook-host', '0.0.0.0'], - rawArgv: [], - terminalRuntime: null, - }); - - expect(updateSettingsMock).not.toHaveBeenCalled(); - expect(createKvClientMock).not.toHaveBeenCalled(); - expect(process.exitCode).toBe(1); - expect(errorSpy).toHaveBeenCalledWith( - expect.anything(), - expect.stringContaining('Invalid --webhook-host value'), - ); - } finally { - errorSpy.mockRestore(); - } - }); - - it('rolls back shared KV update when local scoped settings write fails', async () => { - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); - updateSettingsMock.mockRejectedValueOnce(new Error('settings unavailable')); - - try { - const { handleBridgeCliCommand } = await import('./bridge'); - - await handleBridgeCliCommand({ - args: ['bridge', 'telegram', 'set', '--bot-token', 'bot-token-1', '--require-topics', 'true'], - rawArgv: [], - terminalRuntime: null, - }); - - expect(upsertKvConfigMock).toHaveBeenCalledTimes(1); - expect(replaceKvRawConfigMock).toHaveBeenCalledWith({ - kv: expect.any(Object), - serverId: configuration.activeServerId, - accountId: 'acct_123', - valueBase64: null, - expectedCurrentVersion: 12, - }); - expect(process.exitCode).toBe(1); - expect(errorSpy).toHaveBeenCalledWith(expect.anything(), expect.stringContaining('settings unavailable')); - } finally { - errorSpy.mockRestore(); - } - }); - - it('restores raw pre-existing KV payload when local scoped settings write fails', async () => { - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); - const rawMalformedPayload = Buffer.from('not-json', 'utf8').toString('base64'); - updateSettingsMock.mockRejectedValueOnce(new Error('settings unavailable')); - readKvConfigMock.mockResolvedValueOnce({ - record: null, - version: 7, - rawValueBase64: rawMalformedPayload, - }); - - try { - const { handleBridgeCliCommand } = await import('./bridge'); - - await handleBridgeCliCommand({ - args: ['bridge', 'telegram', 'set', '--bot-token', 'bot-token-1', '--require-topics', 'true'], - rawArgv: [], - terminalRuntime: null, - }); - - expect(upsertKvConfigMock).toHaveBeenCalledTimes(1); - expect(replaceKvRawConfigMock).toHaveBeenCalledWith({ - kv: expect.any(Object), - serverId: configuration.activeServerId, - accountId: 'acct_123', - valueBase64: rawMalformedPayload, - expectedCurrentVersion: 12, - }); - expect(process.exitCode).toBe(1); - expect(errorSpy).toHaveBeenCalledWith(expect.anything(), expect.stringContaining('settings unavailable')); - } finally { - errorSpy.mockRestore(); - } - }); - - it('restores previous shared KV config when local clear fails', async () => { - const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); - updateSettingsMock.mockRejectedValueOnce(new Error('settings clear unavailable')); - const previousRawValueBase64 = Buffer.from(JSON.stringify({ - schemaVersion: 1, - updatedAtMs: 123, - telegram: { - allowedChatIds: ['-100111'], - requireTopics: true, - webhook: { - enabled: true, - host: '127.0.0.1', - port: 8787, - }, - }, - }), 'utf8').toString('base64'); - - readKvConfigMock.mockResolvedValueOnce({ - record: { - schemaVersion: 1, - updatedAtMs: 123, - telegram: { - allowedChatIds: ['-100111'], - requireTopics: true, - webhook: { - enabled: true, - host: '127.0.0.1', - port: 8787, - }, - }, - }, - version: 4, - rawValueBase64: previousRawValueBase64, - }); - - try { - const { handleBridgeCliCommand } = await import('./bridge'); - - await handleBridgeCliCommand({ - args: ['bridge', 'telegram', 'clear'], - rawArgv: [], - terminalRuntime: null, - }); - - expect(clearKvConfigMock).toHaveBeenCalledTimes(1); - expect(replaceKvRawConfigMock).toHaveBeenCalledWith({ - kv: expect.any(Object), - serverId: configuration.activeServerId, - accountId: 'acct_123', - valueBase64: previousRawValueBase64, - expectedCurrentVersion: 7, - }); - expect(process.exitCode).toBe(1); - expect(errorSpy).toHaveBeenCalledWith(expect.anything(), expect.stringContaining('settings clear unavailable')); - } finally { - errorSpy.mockRestore(); - } - }); }); diff --git a/apps/cli/src/cli/commands/bridge.ts b/apps/cli/src/cli/commands/bridge.ts index e8dd6db591..9fae74e304 100644 --- a/apps/cli/src/cli/commands/bridge.ts +++ b/apps/cli/src/cli/commands/bridge.ts @@ -5,24 +5,18 @@ import { configuration } from '@/configuration'; import { decodeJwtPayload } from '@/cloud/decodeJwtPayload'; import { checkIfDaemonRunningAndCleanupStaleState } from '@/daemon/controlClient'; import { - hasSharedTelegramBridgeUpdate, readScopedTelegramBridgeConfig, removeScopedTelegramBridgeConfig, splitScopedTelegramBridgeUpdate, upsertScopedTelegramBridgeConfig, } from '@/channels/channelBridgeAccountConfig'; import { resolveChannelBridgeRuntimeConfig } from '@/channels/channelBridgeConfig'; -import { - clearChannelBridgeTelegramConfigInKv, - createAxiosChannelBridgeKvClient, - readChannelBridgeTelegramConfigFromKv, - replaceChannelBridgeTelegramConfigRawInKv, - upsertChannelBridgeTelegramConfigInKv, -} from '@/channels/channelBridgeServerKv'; -import { overlayServerKvTelegramConfigInSettings } from '@/channels/channelBridgeServerConfigOverlay'; -import { isLoopbackHost } from '@/channels/telegram/telegramWebhookRelay'; +import { isLoopbackHostname } from '@/server/serverUrlClassification'; +import { createLocalChannelBindingStore } from '@/channels/state/localBindingStore'; +import { ensureExperimentalSettingsFeatureToggleEnabled } from '@/features/settingsFeatureToggles'; import { readCredentials, readSettings, updateSettings } from '@/persistence'; import { argvValue } from '@/cli/commands/server/commandUtilities'; +import { join } from 'node:path'; function parseBooleanInput(raw: string, flagName: string): boolean { const value = raw.trim().toLowerCase(); @@ -77,40 +71,20 @@ async function resolveActiveAuthContext(): Promise; - -async function rollbackServerKvAfterLocalSettingsFailure(params: Readonly<{ - kv: ReturnType; - serverId: string; - accountId: string; - previousSnapshot: TelegramKvRawSnapshot; - rollbackGuardVersion: number; -}>): Promise { - await replaceChannelBridgeTelegramConfigRawInKv({ - kv: params.kv, - serverId: params.serverId, - accountId: params.accountId, - valueBase64: params.previousSnapshot.rawValueBase64, - expectedCurrentVersion: params.rollbackGuardVersion, - }); -} - function showBridgeHelp(): void { console.log(` ${chalk.bold('happier bridge')} - Channel bridge configuration (account-scoped) ${chalk.bold('Usage:')} happier bridge list - happier bridge telegram set [--bot-token ] [--allowed-chat-ids |--allow-all] [--require-topics ] [--tick-ms ] [--webhook-enabled ] [--webhook-secret ] [--webhook-host ] [--webhook-port (default: 8787)] + happier bridge telegram set [--bot-token ] [--allowed-chat-ids ] [--allow-all-shared-chats |--allow-all] [--require-topics ] [--tick-ms ] [--webhook-enabled ] [--webhook-secret ] [--webhook-host ] [--webhook-port (default: 8787)] happier bridge telegram clear ${chalk.bold('Notes:')} - Scope is the active server + authenticated account. - - Secrets are local-only (settings/env), not synced to server KV. - - Non-secret bridge config is synced to server KV + scoped settings.json. + - Bridge config is local-only (settings/env) in v1. + - Conversation bindings are created from the channel itself via slash commands: + /sessions, /attach , /detach, /help - Restart daemon to apply: happier daemon stop && happier daemon start `); } @@ -124,37 +98,15 @@ async function cmdList(): Promise { const accountId = auth.accountId; const settings = await readSettings(); - let serverKvRecord: Awaited>['record'] | null = null; - let serverKvError: string | null = null; - try { - const kv = createAxiosChannelBridgeKvClient({ token: auth.token }); - const fetched = await readChannelBridgeTelegramConfigFromKv({ - kv, - serverId, - accountId, - allowUnsupportedSchema: true, - }); - serverKvRecord = fetched.record; - } catch (error) { - serverKvError = error instanceof Error ? error.message : String(error); - } - const scopedTelegram = readScopedTelegramBridgeConfig({ settings, serverId, accountId, }); - const runtimeSettings = overlayServerKvTelegramConfigInSettings({ - settings, - serverId, - accountId, - record: serverKvRecord, - }); - const effective = resolveChannelBridgeRuntimeConfig({ env: process.env, - settings: runtimeSettings, + settings, serverId, accountId, }); @@ -172,45 +124,59 @@ async function cmdList(): Promise { } else { const scopedToken = typeof scopedTelegram.botToken === 'string' ? scopedTelegram.botToken : ''; const scopedAllowed = Array.isArray(scopedTelegram.allowedChatIds) ? scopedTelegram.allowedChatIds : []; + const scopedAllowAllSharedChats = scopedTelegram.allowAllSharedChats === true; const scopedRequireTopics = scopedTelegram.requireTopics === true; console.log(' configured: yes'); console.log(` botToken: ${maskSecret(scopedToken)}`); - console.log(` allowedChatIds: ${scopedAllowed.length > 0 ? scopedAllowed.join(', ') : '(allow all)'}`); + if (scopedAllowAllSharedChats) { + console.log(' allowedChatIds: (allow all shared chats - DANGEROUS)'); + } else { + console.log(` allowedChatIds: ${scopedAllowed.length > 0 ? scopedAllowed.join(', ') : '(dm-only)'}`); + } console.log(` requireTopics: ${scopedRequireTopics ? 'true' : 'false'}`); } - console.log(chalk.bold('\nTelegram (server KV)')); - if (serverKvError) { - console.log(` unavailable: ${serverKvError}`); - } else if (!serverKvRecord) { - console.log(' configured: no'); + console.log(chalk.bold('\nTelegram (effective runtime: env > settings.json)')); + console.log(` botToken: ${maskSecret(effective.telegram.botToken)}`); + if (effective.telegram.allowAllSharedChats) { + console.log(' allowedChatIds: (allow all shared chats - DANGEROUS)'); } else { - const remoteAllowed = Array.isArray(serverKvRecord.telegram.allowedChatIds) ? serverKvRecord.telegram.allowedChatIds : []; - const remoteRequireTopics = serverKvRecord.telegram.requireTopics === true; - const remoteWebhookEnabled = serverKvRecord.telegram.webhook?.enabled === true; - const remoteWebhookHost = - typeof serverKvRecord.telegram.webhook?.host === 'string' ? serverKvRecord.telegram.webhook.host : '(default)'; - const remoteWebhookPort = - typeof serverKvRecord.telegram.webhook?.port === 'number' ? String(serverKvRecord.telegram.webhook.port) : '(default)'; - - console.log(' configured: yes'); - console.log(' botToken: (not stored in server KV)'); - console.log(` allowedChatIds: ${remoteAllowed.length > 0 ? remoteAllowed.join(', ') : '(allow all)'}`); - console.log(` requireTopics: ${remoteRequireTopics ? 'true' : 'false'}`); - console.log(` webhook.enabled: ${remoteWebhookEnabled ? 'true' : 'false'}`); - console.log(` webhook.host: ${remoteWebhookHost}`); - console.log(` webhook.port: ${remoteWebhookPort}`); + console.log( + ` allowedChatIds: ${effective.telegram.allowedChatIds.length > 0 ? effective.telegram.allowedChatIds.join(', ') : '(dm-only)'}`, + ); } - - console.log(chalk.bold('\nTelegram (effective runtime: env > server KV > settings.json)')); - console.log(` botToken: ${maskSecret(effective.telegram.botToken)}`); - console.log( - ` allowedChatIds: ${effective.telegram.allowedChatIds.length > 0 ? effective.telegram.allowedChatIds.join(', ') : '(allow all)'}`, - ); console.log(` requireTopics: ${effective.telegram.requireTopics ? 'true' : 'false'}`); console.log(` webhook.enabled: ${effective.telegram.webhookEnabled ? 'true' : 'false'}`); console.log(` webhook.host: ${effective.telegram.webhookHost}`); console.log(` webhook.port: ${effective.telegram.webhookPort}`); + + try { + const store = createLocalChannelBindingStore({ accountId }); + const bindings = await store.listBindings(); + const bindingsFile = join(configuration.activeServerDir, 'channel-bridges', 'v1', 'account', accountId, 'bindings.json'); + console.log(chalk.bold('\nBindings (local state)')); + console.log(` file: ${bindingsFile}`); + if (bindings.length === 0) { + console.log(' (none) - attach from a DM or a shared chat topic using /attach '); + } else { + console.log(` count: ${bindings.length}`); + for (const binding of bindings.slice(0, 20)) { + const thread = binding.threadId ? `/${binding.threadId}` : ''; + const owner = binding.ownerSenderId ? `owner=${binding.ownerSenderId}` : 'owner='; + const inbound = binding.inboundMode === 'anyone' ? 'anyone' : 'ownerOnly'; + const allowMissing = binding.allowMissingSenderId ? ', allowMissingSenderId=true' : ''; + console.log( + ` - ${binding.providerId}:${binding.conversationId}${thread} → ${binding.sessionId} (${inbound}${allowMissing}; ${owner})`, + ); + } + if (bindings.length > 20) { + console.log(` … and ${bindings.length - 20} more`); + } + } + } catch (error) { + console.log(chalk.yellow('\nBindings (local state)')); + console.log(chalk.yellow(` Failed to read bindings: ${error instanceof Error ? error.message : String(error)}`)); + } } async function cmdTelegramSet(args: string[]): Promise { @@ -225,6 +191,7 @@ async function cmdTelegramSet(args: string[]): Promise { const hasBotTokenFlag = args.some((arg) => arg === '--bot-token' || arg.startsWith('--bot-token=')); const botToken = rawBotToken.trim(); const allowedChatIdsRaw = argvValue(args, '--allowed-chat-ids').trim(); + const allowAllSharedChatsRaw = argvValue(args, '--allow-all-shared-chats').trim(); const allowAll = args.includes('--allow-all'); const requireTopicsRaw = argvValue(args, '--require-topics').trim(); const tickMsRaw = argvValue(args, '--tick-ms').trim(); @@ -236,11 +203,15 @@ async function cmdTelegramSet(args: string[]): Promise { if (allowAll && allowedChatIdsRaw) { throw new Error('Cannot combine --allow-all with --allowed-chat-ids'); } + if (allowAllSharedChatsRaw && allowedChatIdsRaw && parseBooleanInput(allowAllSharedChatsRaw, '--allow-all-shared-chats')) { + throw new Error('Cannot combine --allow-all-shared-chats=true with --allowed-chat-ids'); + } const update: { tickMs?: number; botToken?: string; allowedChatIds?: string[]; + allowAllSharedChats?: boolean; requireTopics?: boolean; webhookEnabled?: boolean; webhookSecret?: string; @@ -255,14 +226,17 @@ async function cmdTelegramSet(args: string[]): Promise { update.botToken = botToken; } if (allowAll) { - update.allowedChatIds = []; + update.allowAllSharedChats = true; } else if (allowedChatIdsRaw) { const parsedAllowedChatIds = parseCsvList(allowedChatIdsRaw); if (parsedAllowedChatIds.length === 0) { - throw new Error('Invalid --allowed-chat-ids value: provide at least one chat id or use --allow-all'); + throw new Error('Invalid --allowed-chat-ids value: provide at least one chat id'); } update.allowedChatIds = parsedAllowedChatIds; } + if (allowAllSharedChatsRaw) { + update.allowAllSharedChats = parseBooleanInput(allowAllSharedChatsRaw, '--allow-all-shared-chats'); + } if (requireTopicsRaw) { update.requireTopics = parseBooleanInput(requireTopicsRaw, '--require-topics'); } @@ -277,7 +251,7 @@ async function cmdTelegramSet(args: string[]): Promise { update.webhookSecret = webhookSecret; } if (webhookHost) { - if (!isLoopbackHost(webhookHost)) { + if (!isLoopbackHostname(webhookHost)) { throw new Error('Invalid --webhook-host value: must be a loopback address (127.0.0.1, ::1, or localhost)'); } update.webhookHost = webhookHost; @@ -288,76 +262,28 @@ async function cmdTelegramSet(args: string[]): Promise { if (Object.keys(update).length === 0) { throw new Error( - 'No updates provided. Use flags like --bot-token, --allowed-chat-ids, --allow-all, --require-topics, --tick-ms, --webhook-enabled, --webhook-secret, --webhook-host, --webhook-port', + 'No updates provided. Use flags like --bot-token, --allowed-chat-ids, --allow-all, --allow-all-shared-chats, --require-topics, --tick-ms, --webhook-enabled, --webhook-secret, --webhook-host, --webhook-port', ); } const split = splitScopedTelegramBridgeUpdate({ update }); - const shouldWriteSharedKv = hasSharedTelegramBridgeUpdate({ update: split.sharedUpdate }); - - let kv: ReturnType | null = null; - let previousServerKvSnapshot: TelegramKvRawSnapshot | null = null; - let rollbackGuardVersion: number | null = null; - - if (shouldWriteSharedKv) { - kv = createAxiosChannelBridgeKvClient({ token: auth.token }); - const previous = await readChannelBridgeTelegramConfigFromKv({ - kv, - serverId, - accountId, - allowUnsupportedSchema: true, - }); - previousServerKvSnapshot = { - version: previous.version, - rawValueBase64: previous.rawValueBase64, - }; - - rollbackGuardVersion = await upsertChannelBridgeTelegramConfigInKv({ - kv, - serverId, - accountId, - update: split.sharedUpdate, - }); - } - - try { - await updateSettings(async (current) => - upsertScopedTelegramBridgeConfig({ + await updateSettings(async (current) => + ensureExperimentalSettingsFeatureToggleEnabled({ + settings: upsertScopedTelegramBridgeConfig({ settings: current, serverId, accountId, update: split.localUpdate, }), - ); - } catch (error) { - if (shouldWriteSharedKv && kv && typeof rollbackGuardVersion === 'number') { - try { - await rollbackServerKvAfterLocalSettingsFailure({ - kv, - serverId, - accountId, - previousSnapshot: previousServerKvSnapshot ?? { version: -1, rawValueBase64: null }, - rollbackGuardVersion, - }); - } catch (rollbackError) { - const primaryMessage = error instanceof Error ? error.message : String(error); - const rollbackMessage = rollbackError instanceof Error ? rollbackError.message : String(rollbackError); - throw new Error( - `Local settings update failed and server KV rollback failed (${primaryMessage}; rollback: ${rollbackMessage})`, - ); - } - } - throw error; - } + featureId: 'channelBridges', + }), + ); console.log(chalk.green('✓ Saved Telegram bridge config for active account scope')); console.log(` Server: ${serverId}`); console.log(` Account: ${accountId}`); - console.log( - shouldWriteSharedKv - ? ' Persisted: non-secret fields -> server KV, full config -> scoped settings.json' - : ' Persisted: secrets-only update -> scoped settings.json (server KV unchanged)', - ); + console.log(' Persisted: scoped settings.json'); + console.log(' Enabled: experimental feature toggle channelBridges'); console.log(' Restart daemon to apply changes:'); console.log(chalk.cyan(' happier daemon stop && happier daemon start')); } @@ -369,58 +295,18 @@ async function cmdTelegramClear(): Promise { } const auth = await resolveActiveAuthContext(); const accountId = auth.accountId; - - const kv = createAxiosChannelBridgeKvClient({ token: auth.token }); - const previous = await readChannelBridgeTelegramConfigFromKv({ - kv, - serverId, - accountId, - allowUnsupportedSchema: true, - }); - const previousSnapshot: TelegramKvRawSnapshot = { - version: previous.version, - rawValueBase64: previous.rawValueBase64, - }; - - const rollbackGuardVersion = await clearChannelBridgeTelegramConfigInKv({ - kv, - serverId, - accountId, - }); - - try { - await updateSettings(async (current) => - removeScopedTelegramBridgeConfig({ - settings: current, - serverId, - accountId, - }), - ); - } catch (error) { - if (typeof rollbackGuardVersion === 'number') { - try { - await rollbackServerKvAfterLocalSettingsFailure({ - kv, - serverId, - accountId, - previousSnapshot, - rollbackGuardVersion, - }); - } catch (rollbackError) { - const primaryMessage = error instanceof Error ? error.message : String(error); - const rollbackMessage = rollbackError instanceof Error ? rollbackError.message : String(rollbackError); - throw new Error( - `Scoped settings clear failed and server KV rollback failed (${primaryMessage}; rollback: ${rollbackMessage})`, - ); - } - } - throw error; - } + await updateSettings(async (current) => + removeScopedTelegramBridgeConfig({ + settings: current, + serverId, + accountId, + }), + ); console.log(chalk.green('✓ Cleared Telegram bridge config for active account scope')); console.log(` Server: ${serverId}`); console.log(` Account: ${accountId}`); - console.log(' Cleared: server KV + scoped settings.json'); + console.log(' Cleared: scoped settings.json'); console.log(' Restart daemon to apply changes:'); console.log(chalk.cyan(' happier daemon stop && happier daemon start')); } diff --git a/apps/cli/src/daemon/channels/resolveChannelBridgesDaemonEnabled.feat.channelBridges.test.ts b/apps/cli/src/daemon/channels/resolveChannelBridgesDaemonEnabled.feat.channelBridges.test.ts index 309a8ead9e..bd271cdfd7 100644 --- a/apps/cli/src/daemon/channels/resolveChannelBridgesDaemonEnabled.feat.channelBridges.test.ts +++ b/apps/cli/src/daemon/channels/resolveChannelBridgesDaemonEnabled.feat.channelBridges.test.ts @@ -23,13 +23,14 @@ describe('resolveChannelBridgesDaemonEnabled', () => { const enabled = await resolveChannelBridgesDaemonEnabled({ env: { HAPPIER_FEATURE_CHANNEL_BRIDGES__ENABLED: '1', HAPPIER_FEATURE_CHANNEL_BRIDGES_TELEGRAM__ENABLED: '1' }, serverUrl: 'https://api.example.test', + settings: { experiments: true, featureToggles: { channelBridges: true } }, timeoutMs: 100, }); expect(enabled).toBe(false); }); - it('returns true when server reports telegram bridge enabled and build policy does not deny it', async () => { + it('returns false when user has not enabled the experimental toggle', async () => { vi.stubGlobal( 'fetch', vi.fn(async () => ({ @@ -48,6 +49,33 @@ describe('resolveChannelBridgesDaemonEnabled', () => { const enabled = await resolveChannelBridgesDaemonEnabled({ env: { HAPPIER_FEATURE_CHANNEL_BRIDGES__ENABLED: '1', HAPPIER_FEATURE_CHANNEL_BRIDGES_TELEGRAM__ENABLED: '1' }, serverUrl: 'https://api.example.test', + settings: {}, + timeoutMs: 100, + }); + + expect(enabled).toBe(false); + }); + + it('returns true when server reports telegram bridge enabled and user has opted in', async () => { + vi.stubGlobal( + 'fetch', + vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => + FeaturesResponseSchema.parse({ + features: { + channelBridges: { enabled: true, telegram: { enabled: true } }, + }, + capabilities: {}, + }), + })) as unknown as typeof fetch, + ); + + const enabled = await resolveChannelBridgesDaemonEnabled({ + env: { HAPPIER_FEATURE_CHANNEL_BRIDGES__ENABLED: '1', HAPPIER_FEATURE_CHANNEL_BRIDGES_TELEGRAM__ENABLED: '1' }, + serverUrl: 'https://api.example.test', + settings: { experiments: true, featureToggles: { channelBridges: true } }, timeoutMs: 100, }); @@ -75,6 +103,7 @@ describe('resolveChannelBridgesDaemonEnabled', () => { HAPPIER_FEATURE_CHANNEL_BRIDGES_TELEGRAM__ENABLED: '1', }, serverUrl: 'https://api.example.test', + settings: { experiments: true, featureToggles: { channelBridges: true } }, timeoutMs: 100, }); @@ -102,6 +131,7 @@ describe('resolveChannelBridgesDaemonEnabled', () => { HAPPIER_FEATURE_CHANNEL_BRIDGES_TELEGRAM__ENABLED: '0', }, serverUrl: 'https://api.example.test', + settings: { experiments: true, featureToggles: { channelBridges: true } }, timeoutMs: 100, }); @@ -109,4 +139,3 @@ describe('resolveChannelBridgesDaemonEnabled', () => { expect(fetchMock).not.toHaveBeenCalled(); }); }); - diff --git a/apps/cli/src/daemon/channels/resolveChannelBridgesDaemonEnabled.ts b/apps/cli/src/daemon/channels/resolveChannelBridgesDaemonEnabled.ts index 2e6e408980..f66db79ccd 100644 --- a/apps/cli/src/daemon/channels/resolveChannelBridgesDaemonEnabled.ts +++ b/apps/cli/src/daemon/channels/resolveChannelBridgesDaemonEnabled.ts @@ -1,8 +1,10 @@ import { resolveCliFeatureDecisionForServer } from '@/features/featureDecisionService'; +import { resolveExperimentalSettingsFeatureToggleEnabled } from '@/features/settingsFeatureToggles'; export async function resolveChannelBridgesDaemonEnabled(params: { env: NodeJS.ProcessEnv; serverUrl: string; + settings?: unknown; timeoutMs?: number; }): Promise { const resolved = await resolveCliFeatureDecisionForServer({ @@ -12,6 +14,13 @@ export async function resolveChannelBridgesDaemonEnabled(params: { timeoutMs: params.timeoutMs, }); - return resolved.decision.state === 'enabled'; -} + if (resolved.decision.state !== 'enabled') { + return false; + } + return resolveExperimentalSettingsFeatureToggleEnabled({ + settings: params.settings, + featureId: 'channelBridges', + defaultEnabled: false, + }); +} diff --git a/apps/cli/src/daemon/startDaemon.ts b/apps/cli/src/daemon/startDaemon.ts index cd4f6798f8..e24bec2fd8 100644 --- a/apps/cli/src/daemon/startDaemon.ts +++ b/apps/cli/src/daemon/startDaemon.ts @@ -16,7 +16,6 @@ import { } from '@/rpc/handlers/registerSessionHandlers'; import { logger } from '@/ui/logger'; import { authAndSetupMachineIfNeeded } from '@/ui/auth'; -import { configuration } from '@/configuration'; import { startCaffeinate, stopCaffeinate } from '@/integrations/caffeinate'; import packageJson from '../../package.json'; import { getEnvironmentInfo } from '@/ui/doctor'; @@ -90,8 +89,7 @@ import { startAutomationWorker, type AutomationWorkerHandle } from './automation import { startMemoryWorker, type MemoryWorkerHandle } from './memory/memoryWorker'; import { createDaemonConnectivityCoordinator } from './connection/createDaemonConnectivityCoordinator'; import { startChannelBridgeFromEnv, type ChannelBridgeRuntimeHandle } from '@/channels/startChannelBridgeWorker'; -import { createAxiosChannelBridgeKvClient, readChannelBridgeTelegramConfigFromKv } from '@/channels/channelBridgeServerKv'; -import { overlayServerKvTelegramConfigInSettings } from '@/channels/channelBridgeServerConfigOverlay'; +import { configuration } from '@/configuration'; import { resolveConnectedServiceAuthForSpawn } from './connectedServices/resolveConnectedServiceAuthForSpawn'; import { shouldResolveConnectedServiceAuthForSpawn } from './connectedServices/shouldResolveConnectedServiceAuthForSpawn'; import { ConnectedServiceRefreshCoordinator } from './connectedServices/refresh/ConnectedServiceRefreshCoordinator'; @@ -1528,60 +1526,38 @@ export async function startDaemon(): Promise { // Do machine bootstrap in the background so shutdown requests are not blocked by /v1/machines latency. void (async () => { - const startChannelBridgeWorkerBestEffort = async (): Promise => { - const channelBridgesEnabled = await resolveChannelBridgesDaemonEnabled({ - env: process.env, - serverUrl: configuration.serverUrl, - timeoutMs: 1500, - }).catch((error) => { - logger.warn( - '[DAEMON RUN] Failed to resolve channel bridge feature gating; skipping channel bridge startup', - serializeAxiosErrorForLog(error), - ); - return false; - }); - if (!channelBridgesEnabled || shutdownInitiated) { - return; - } - - const bridgeSettings = await readSettings().catch((error) => { - logger.warn( - '[DAEMON RUN] Failed to read settings for channel bridge startup; using env-only defaults', - error instanceof Error ? error.message : String(error), + const startChannelBridgeWorkerBestEffort = async (): Promise => { + const bridgeSettings = await readSettings().catch((error) => { + logger.warn( + '[DAEMON RUN] Failed to read settings for channel bridge startup; channel bridges require user experimental opt-in and will remain disabled', + error instanceof Error ? error.message : String(error), ); return null; }); + const channelBridgesEnabled = await resolveChannelBridgesDaemonEnabled({ + env: process.env, + serverUrl: configuration.serverUrl, + settings: bridgeSettings, + timeoutMs: 1500, + }).catch((error) => { + logger.warn( + '[DAEMON RUN] Failed to resolve channel bridge feature gating; skipping channel bridge startup', + serializeAxiosErrorForLog(error), + ); + return false; + }); + if (!channelBridgesEnabled || shutdownInitiated) { + return; + } + const tokenPayload = decodeJwtPayload(credentials.token); const channelBridgeAccountId = tokenPayload && typeof tokenPayload.sub === 'string' ? tokenPayload.sub.trim() : null; const channelBridgeServerId = (configuration.activeServerId ?? '').trim() || null; - - let channelBridgeRuntimeSettings: unknown = bridgeSettings; - if (channelBridgeServerId && channelBridgeAccountId) { - try { - const kv = createAxiosChannelBridgeKvClient({ token: credentials.token }); - const fetched = await readChannelBridgeTelegramConfigFromKv({ - kv, - serverId: channelBridgeServerId, - accountId: channelBridgeAccountId, - allowUnsupportedSchema: true, - }); - channelBridgeRuntimeSettings = overlayServerKvTelegramConfigInSettings({ - settings: bridgeSettings, - serverId: channelBridgeServerId, - accountId: channelBridgeAccountId, - record: fetched.record, - }); - } catch (error) { - logger.warn( - '[DAEMON RUN] Failed to read channel bridge config from server KV; using local/env configuration', - serializeAxiosErrorForLog(error), - ); - } - } + const channelBridgeRuntimeSettings: unknown = bridgeSettings; if (shutdownInitiated) { return; diff --git a/apps/cli/src/features/settingsFeatureToggles.ts b/apps/cli/src/features/settingsFeatureToggles.ts new file mode 100644 index 0000000000..dcf7215c36 --- /dev/null +++ b/apps/cli/src/features/settingsFeatureToggles.ts @@ -0,0 +1,38 @@ +type RecordLike = Record; + +function asRecord(value: unknown): RecordLike | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + return value as RecordLike; +} + +export function resolveExperimentalSettingsFeatureToggleEnabled(params: Readonly<{ + settings: unknown; + featureId: string; + defaultEnabled: boolean; +}>): boolean { + const root = asRecord(params.settings); + if (!root) return false; + + if (root.experiments !== true) return false; + + const featureToggles = asRecord(root.featureToggles); + const explicit = featureToggles ? featureToggles[params.featureId] : undefined; + if (typeof explicit === 'boolean') return explicit; + + return params.defaultEnabled === true; +} + +export function ensureExperimentalSettingsFeatureToggleEnabled(params: Readonly<{ + settings: unknown; + featureId: string; +}>): RecordLike { + const root = JSON.parse(JSON.stringify(asRecord(params.settings) ?? {})) as RecordLike; + if (root.experiments !== true) { + root.experiments = true; + } + + const featureToggles = asRecord(root.featureToggles) ?? {}; + featureToggles[params.featureId] = true; + root.featureToggles = featureToggles; + return root; +} diff --git a/apps/cli/src/server/serverUrlClassification.test.ts b/apps/cli/src/server/serverUrlClassification.test.ts index 192dc56bb4..cccfd65077 100644 --- a/apps/cli/src/server/serverUrlClassification.test.ts +++ b/apps/cli/src/server/serverUrlClassification.test.ts @@ -4,6 +4,7 @@ import { isInsecureRemoteHttpServerUrl, isLocalishHostname, isLocalishServerUrl, + isLoopbackHostname, isLoopbackHttpServerUrl, } from '@/server/serverUrlClassification'; @@ -35,6 +36,17 @@ describe('serverUrlClassification', () => { expect(isLoopbackHttpServerUrl('https://localhost:3005')).toBe(false); }); + it('detects loopback hostnames', () => { + expect(isLoopbackHostname('localhost')).toBe(true); + expect(isLoopbackHostname('127.0.0.1')).toBe(true); + expect(isLoopbackHostname('127.0.0.5')).toBe(true); + expect(isLoopbackHostname('::1')).toBe(true); + expect(isLoopbackHostname('192.168.1.20')).toBe(false); + expect(isLoopbackHostname('0.0.0.0')).toBe(false); + expect(isLoopbackHostname('example.com')).toBe(false); + expect(isLoopbackHostname('')).toBe(false); + }); + it('classifies server URLs by host, not by http scheme alone', () => { expect(isLocalishServerUrl('http://127.0.0.1:3005')).toBe(true); expect(isLocalishServerUrl('https://192.168.1.20:3005')).toBe(true); diff --git a/apps/cli/src/ui/doctor.ts b/apps/cli/src/ui/doctor.ts index fdc13dc0e4..028e88494f 100644 --- a/apps/cli/src/ui/doctor.ts +++ b/apps/cli/src/ui/doctor.ts @@ -8,14 +8,11 @@ import chalk from 'chalk' import { configuration } from '@/configuration' import { readSettings, readCredentials } from '@/persistence' -import { decodeJwtPayload } from '@/cloud/decodeJwtPayload'; import { checkIfDaemonRunningAndCleanupStaleState } from '@/daemon/controlClient' import { findRunawayHappyProcesses, findAllHappyProcesses } from '@/daemon/doctor' import { readDaemonState, type DaemonLocallyPersistedState } from '@/persistence' -import { resolveChannelBridgeRuntimeConfig } from '@/channels/channelBridgeConfig'; -import { createAxiosChannelBridgeKvClient, readChannelBridgeTelegramConfigFromKv } from '@/channels/channelBridgeServerKv'; -import { overlayServerKvTelegramConfigInSettings } from '@/channels/channelBridgeServerConfigOverlay'; -import { isLoopbackHost } from '@/channels/telegram/telegramWebhookRelay'; +import { isLoopbackHostname } from '@/server/serverUrlClassification'; +import { runChannelBridgeDoctorSection } from '@/ui/doctor/channelBridgesDoctor'; import { existsSync, readdirSync, statSync } from 'node:fs' import { readFile } from 'node:fs/promises' import { join } from 'node:path' @@ -69,7 +66,7 @@ export function collectMissingRequiredWebhookFields(params: Readonly<{ const normalizedWebhookHost = params.webhookHost.trim(); if (normalizedWebhookHost.length === 0) { issues.push('webhook.host: (required when webhook.enabled=true)'); - } else if (!isLoopbackHost(normalizedWebhookHost)) { + } else if (!isLoopbackHostname(normalizedWebhookHost)) { issues.push( `webhook.host: '${normalizedWebhookHost}' is not loopback-only (required when webhook.enabled=true)`, ); @@ -394,168 +391,13 @@ export async function runDoctorCommand(filter?: 'all' | 'daemon'): Promise } try { - const authCredentials = credentials ?? await readCredentials().catch(() => null); - const payload = authCredentials?.token ? decodeJwtPayload(authCredentials.token) : null; - const accountId = payload && typeof payload.sub === 'string' ? payload.sub.trim() : ''; const settings = settingsSnapshot ?? await readSettings(); - const serverId = String(configuration.activeServerId ?? '').trim(); - let runtimeSettings: unknown = settings; - let runtimeConfigSource = 'local settings'; - let serverKvReadFailure: string | null = null; - - if (serverId.length > 0 && accountId.length > 0 && authCredentials?.token) { - try { - const kv = createAxiosChannelBridgeKvClient({ token: authCredentials.token }); - const serverRecord = await readChannelBridgeTelegramConfigFromKv({ - kv, - serverId, - accountId, - allowUnsupportedSchema: true, - }); - runtimeSettings = overlayServerKvTelegramConfigInSettings({ - settings, - serverId, - accountId, - record: serverRecord.record, - }); - if (serverRecord.record != null) { - runtimeConfigSource = 'server KV overlay + local settings'; - } - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - serverKvReadFailure = message; - } - } - - const runtimeSettingsRecord = asRecord(runtimeSettings); - const channelBridgeRoot = asRecord(runtimeSettingsRecord?.channelBridge); - const byServerId = asRecord(channelBridgeRoot?.byServerId); - const serverScope = serverId ? asRecord(byServerId?.[serverId]) : null; - const byAccountId = asRecord(serverScope?.byAccountId); - const accountScope = accountId ? asRecord(byAccountId?.[accountId]) : null; - const globalProviders = asRecord(channelBridgeRoot?.providers); - const serverProviders = asRecord(serverScope?.providers); - const accountProviders = asRecord(accountScope?.providers); - const telegram = - asRecord(accountProviders?.telegram) - ?? asRecord(serverProviders?.telegram) - ?? asRecord(globalProviders?.telegram); - const providerMap = new Map>(); - - const mergeProviders = (providersRecord: Record | null): void => { - if (!providersRecord) return; - for (const [providerId, providerConfig] of Object.entries(providersRecord)) { - const providerEntry = asRecord(providerConfig); - if (!providerEntry) continue; - providerMap.set(providerId, providerEntry); - } - }; - - mergeProviders(globalProviders); - mergeProviders(serverProviders); - mergeProviders(accountProviders); - - const providerEntries = [...providerMap.entries()]; - - const runtimeBridge = resolveChannelBridgeRuntimeConfig({ - env: process.env, - settings: runtimeSettings, - serverId, - accountId, + const channelBridgeResult = await runChannelBridgeDoctorSection({ + settings, + credentialsToken: credentials?.token ?? null, }); - - console.log(chalk.bold('\n🔌 Channel Bridges')); - console.log(`Server scope: ${serverId || '(unknown)'}`); - console.log(`Account scope: ${accountId || '(unknown)'}`); - console.log(`Runtime source: ${runtimeConfigSource}`); - if (serverKvReadFailure) { - console.log(chalk.yellow(`⚠️ Unable to read channel bridge server KV: ${serverKvReadFailure}`)); - } - const telegramConfigured = - telegram !== null - || runtimeBridge.telegram.botToken.trim().length > 0 - || runtimeBridge.telegram.webhookEnabled - || runtimeBridge.telegram.webhookSecret.trim().length > 0 - || runtimeBridge.telegram.allowedChatIds.length > 0 - || runtimeBridge.telegram.requireTopics; - - if (telegramConfigured) { - const token = runtimeBridge.telegram.botToken; - const webhookSecret = runtimeBridge.telegram.webhookSecret; - const allowedChatIds = runtimeBridge.telegram.allowedChatIds; - const requireTopics = runtimeBridge.telegram.requireTopics; - const webhookEnabled = runtimeBridge.telegram.webhookEnabled; - const webhookHost = runtimeBridge.telegram.webhookHost; - const webhookPort = String(runtimeBridge.telegram.webhookPort); - const { webhookHost: webhookHostForValidation, webhookPort: webhookPortForValidation } = - resolveTelegramWebhookValidationInputs({ - runtimeWebhookHost: runtimeBridge.telegram.webhookHost, - runtimeWebhookPort: runtimeBridge.telegram.webhookPort, - }); - const tokenMissing = token.trim().length === 0; - const webhookIssues = collectMissingRequiredWebhookFields({ - webhookEnabled, - webhookSecret, - webhookHost: webhookHostForValidation, - webhookPort: webhookPortForValidation, - }); - const hasTelegramCriticalIssue = tokenMissing || webhookIssues.length > 0; - if (hasTelegramCriticalIssue) { - console.log(chalk.red('❌ Telegram bridge configured with critical issues')); - hasCriticalFailures = true; - } else { - console.log(chalk.green('✓ Telegram bridge configured in scoped settings')); - } - if (tokenMissing) { - console.log(chalk.red(' botToken: (required)')); - } else { - console.log(` botToken: ${maskValue(token) ?? ''}`); - } - if (webhookIssues.length > 0) { - for (const issue of webhookIssues) { - console.log(chalk.red(` ${issue}`)); - } - } else { - console.log(` webhook.secret: ${maskValue(webhookSecret) ?? ''}`); - } - console.log(` webhook.enabled: ${webhookEnabled ? 'true' : 'false'}`); - console.log(` webhook.host: ${webhookHost}`); - console.log(` webhook.port: ${webhookPort}`); - console.log(` allowedChatIds: ${allowedChatIds.length > 0 ? allowedChatIds.join(', ') : '(allow all)'}`); - console.log(` requireTopics: ${requireTopics ? 'true' : 'false'}`); - console.log(' secret policy: local-only (not synced to server KV)'); - } else { - console.log(chalk.gray('Telegram bridge not configured for active server/account scope')); - } - - for (const [providerId, providerConfig] of providerEntries) { - if (providerId === 'telegram') continue; - if (!providerConfig || typeof providerConfig !== 'object' || Array.isArray(providerConfig)) continue; - - const providerRecord = providerConfig as Record; - const webhook = asRecord(providerRecord.webhook); - const secrets = asRecord(providerRecord.secrets); - const webhookEnabled = webhook?.enabled === true; - const webhookSecret = typeof secrets?.webhookSecret === 'string' - ? secrets.webhookSecret - : typeof webhook?.secret === 'string' - ? webhook.secret - : ''; - const webhookHostRaw = typeof webhook?.host === 'string' ? webhook.host : ''; - const webhookPortRaw = parseStrictWebhookPort(webhook?.port); - const issues = collectMissingRequiredWebhookFields({ - webhookEnabled, - webhookSecret, - webhookHost: webhookHostRaw, - webhookPort: webhookPortRaw, - }); - if (issues.length > 0) { - console.log(chalk.red(`❌ ${providerId} bridge configured with critical issues`)); - for (const issue of issues) { - console.log(chalk.red(` ${issue}`)); - } - hasCriticalFailures = true; - } + if (channelBridgeResult.hasCriticalFailures) { + hasCriticalFailures = true; } console.log(chalk.gray('Apply changes with daemon restart: happier daemon stop && happier daemon start')); } catch (error) { diff --git a/apps/cli/src/ui/doctor/channelBridgesDoctor.ts b/apps/cli/src/ui/doctor/channelBridgesDoctor.ts new file mode 100644 index 0000000000..25c056a6af --- /dev/null +++ b/apps/cli/src/ui/doctor/channelBridgesDoctor.ts @@ -0,0 +1,137 @@ +import chalk from 'chalk'; + +import { resolveChannelBridgeRuntimeConfig } from '@/channels/channelBridgeConfig'; +import type { ChannelSessionBinding } from '@/channels/core/channelBridgeWorker'; +import { createLocalChannelBindingStore } from '@/channels/state/localBindingStore'; +import { decodeJwtPayload } from '@/cloud/decodeJwtPayload'; +import { configuration } from '@/configuration'; + +export type ChannelBridgeDoctorSectionResult = Readonly<{ + hasCriticalFailures: boolean; +}>; + +function formatBindingRef(binding: ChannelSessionBinding): string { + const thread = binding.threadId ? `/${binding.threadId}` : ''; + return `${binding.providerId}:${binding.conversationId}${thread}`; +} + +function formatSenderIdStatus(binding: ChannelSessionBinding): string { + if (binding.ownerSenderId) return `owner=${binding.ownerSenderId}`; + if (binding.allowMissingSenderId) return 'owner= (allowMissingSenderId=true)'; + return 'owner='; +} + +function formatInboundMode(binding: ChannelSessionBinding): string { + const mode = binding.inboundMode === 'anyone' ? 'anyone' : 'ownerOnly'; + const missing = binding.allowMissingSenderId ? ', allowMissingSenderId=true' : ''; + return `${mode}${missing}`; +} + +function isTelegramConfigured(runtime: ReturnType['telegram']): boolean { + return ( + runtime.botToken.trim().length > 0 + || runtime.webhookEnabled + || runtime.webhookSecret.trim().length > 0 + || runtime.allowedChatIds.length > 0 + || runtime.allowAllSharedChats + || runtime.requireTopics + ); +} + +export async function runChannelBridgeDoctorSection(params: Readonly<{ + settings: unknown; + credentialsToken: string | null; +}>): Promise { + const serverId = String(configuration.activeServerId ?? '').trim(); + const payload = params.credentialsToken ? decodeJwtPayload(params.credentialsToken) : null; + const accountId = payload && typeof payload.sub === 'string' ? payload.sub.trim() : ''; + + const runtime = resolveChannelBridgeRuntimeConfig({ + env: process.env, + settings: params.settings, + serverId, + accountId: accountId || null, + }); + + console.log(chalk.bold('\n🔌 Channel Bridges')); + console.log(`Server scope: ${serverId || '(unknown)'}`); + console.log(`Account scope: ${accountId || '(unknown)'}`); + console.log('Runtime source: env > local settings'); + + let hasCriticalFailures = false; + + if (!isTelegramConfigured(runtime.telegram)) { + console.log(chalk.gray('Telegram bridge not configured for active scope')); + return { hasCriticalFailures }; + } + + const tokenMissing = runtime.telegram.botToken.trim().length === 0; + if (tokenMissing) { + console.log(chalk.red('❌ Telegram bridge configuration present but bot token is missing (bridge will not start)')); + hasCriticalFailures = true; + } else { + console.log(chalk.green('✓ Telegram bridge configured (bot token present)')); + } + + if (runtime.telegram.allowAllSharedChats) { + console.log(chalk.yellow('⚠️ allowAllSharedChats=true (any shared chat can be attached; high risk)')); + } + + const allowedChatIdsLabel = (() => { + if (runtime.telegram.allowAllSharedChats) { + return '(allow all shared chats - UNSAFE)'; + } + if (runtime.telegram.allowedChatIds.length > 0) { + return runtime.telegram.allowedChatIds.join(', '); + } + return '(dm-only)'; + })(); + + console.log(` allowedChatIds: ${allowedChatIdsLabel}`); + console.log(` requireTopics: ${runtime.telegram.requireTopics ? 'true' : 'false'}`); + console.log(` webhook.enabled: ${runtime.telegram.webhookEnabled ? 'true' : 'false'}`); + console.log(` webhook.host: ${runtime.telegram.webhookHost}`); + console.log(` webhook.port: ${runtime.telegram.webhookPort}`); + + if (runtime.telegram.webhookEnabled && runtime.telegram.webhookSecret.trim().length === 0) { + console.log(chalk.yellow('⚠️ webhook.enabled=true but webhook.secret is missing (daemon will fall back to polling)')); + } + + if (!accountId) { + console.log(chalk.gray('Bindings: unavailable (no authenticated account id)')); + return { hasCriticalFailures }; + } + + try { + const store = createLocalChannelBindingStore({ accountId }); + const bindings = await store.listBindings(); + console.log(chalk.bold('\nBindings (local state)')); + console.log(` count: ${bindings.length}`); + + const riskyAnyone = bindings.filter((binding) => binding.inboundMode === 'anyone').length; + const riskyMissingSender = bindings.filter((binding) => binding.allowMissingSenderId).length; + if (riskyAnyone > 0) { + console.log(chalk.yellow(`⚠️ ${riskyAnyone} binding(s) allow inbound from anyone`)); + } + if (riskyMissingSender > 0) { + console.log(chalk.yellow(`⚠️ ${riskyMissingSender} binding(s) allow missing sender identity (unsafe)`)); + } + + const displayLimit = 20; + for (const binding of bindings.slice(0, displayLimit)) { + console.log( + ` - ${formatBindingRef(binding)} → ${binding.sessionId} (${formatInboundMode(binding)}; ${formatSenderIdStatus(binding)})`, + ); + } + if (bindings.length > displayLimit) { + console.log(` … and ${bindings.length - displayLimit} more`); + } + console.log(chalk.gray('Manage bindings in-channel: /sessions, /attach, /detach, /help')); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + console.log(chalk.yellow(`⚠️ Failed to read local bindings: ${message}`)); + } + + return { hasCriticalFailures }; +} + From bb4774c72766ee8d406e78e987d1d2c3cdf8119c Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Thu, 26 Mar 2026 18:53:06 +0100 Subject: [PATCH 39/81] feat(ui): add channel bridges settings screen --- .../app/(app)/settings/channel-bridges.tsx | 4 + .../components/settings/SettingsView.tsx | 15 ++++ .../ChannelBridgesSettingsView.tsx | 77 +++++++++++++++++++ apps/ui/sources/text/translations/ca.ts | 12 +++ apps/ui/sources/text/translations/en.ts | 12 +++ apps/ui/sources/text/translations/es.ts | 12 +++ apps/ui/sources/text/translations/it.ts | 12 +++ apps/ui/sources/text/translations/ja.ts | 12 +++ apps/ui/sources/text/translations/pl.ts | 12 +++ apps/ui/sources/text/translations/pt.ts | 12 +++ apps/ui/sources/text/translations/ru.ts | 12 +++ apps/ui/sources/text/translations/zh-Hans.ts | 12 +++ apps/ui/sources/text/translations/zh-Hant.ts | 10 +++ 13 files changed, 214 insertions(+) create mode 100644 apps/ui/sources/app/(app)/settings/channel-bridges.tsx create mode 100644 apps/ui/sources/components/settings/channelBridges/ChannelBridgesSettingsView.tsx diff --git a/apps/ui/sources/app/(app)/settings/channel-bridges.tsx b/apps/ui/sources/app/(app)/settings/channel-bridges.tsx new file mode 100644 index 0000000000..28a7295458 --- /dev/null +++ b/apps/ui/sources/app/(app)/settings/channel-bridges.tsx @@ -0,0 +1,4 @@ +import { ChannelBridgesSettingsView } from '@/components/settings/channelBridges/ChannelBridgesSettingsView'; + +export default ChannelBridgesSettingsView; + diff --git a/apps/ui/sources/components/settings/SettingsView.tsx b/apps/ui/sources/components/settings/SettingsView.tsx index 0b842cf44d..d983e8976d 100644 --- a/apps/ui/sources/components/settings/SettingsView.tsx +++ b/apps/ui/sources/components/settings/SettingsView.tsx @@ -32,6 +32,7 @@ import { resolveSupportUsAction } from '@/components/settings/supportUsBehavior' import { recordBugReportUserAction } from '@/utils/system/bugReportActionTrail'; import { fireAndForget } from '@/utils/system/fireAndForget'; import { useAutomationsSupport } from '@/hooks/server/useAutomationsSupport'; +import { useFeatureDecision } from '@/hooks/server/useFeatureDecision'; import { useFeatureEnabled } from '@/hooks/server/useFeatureEnabled'; import type { FeatureId } from '@happier-dev/protocol'; import { getFeatureBuildPolicyDecision } from '@/sync/domains/features/featureBuildPolicy'; @@ -66,6 +67,10 @@ export const SettingsView = React.memo(function SettingsView() { const automationsSupport = useAutomationsSupport(); const showAutomations = automationsSupport?.discoverable !== false; const automationsNeedLocalEnablement = automationsSupport?.blockedBy === 'local_policy'; + const channelBridgesDecision = useFeatureDecision('channelBridges'); + const showChannelBridges = getFeatureBuildPolicyDecision('channelBridges') !== 'deny' + && channelBridgesDecision?.state !== 'unsupported'; + const channelBridgesNeedLocalEnablement = channelBridgesDecision?.blockedBy === 'local_policy'; const profile = useProfile(); const displayName = getDisplayName(profile); const avatarUrl = getAvatarUrl(profile); @@ -402,6 +407,16 @@ export const SettingsView = React.memo(function SettingsView() { onPress={() => router.push('/(app)/settings/connected-services')} /> ) : null} + {showChannelBridges ? ( + } + onPress={() => router.push(channelBridgesNeedLocalEnablement ? '/(app)/settings/features' : '/(app)/settings/channel-bridges')} + /> + ) : null} {mcpServersEnabled && ( { + return [ + 'happier bridge telegram set', + '--bot-token ', + '--allowed-chat-ids ', + '--require-topics false', + ].join(' '); + }, []); + + return ( + + + + {!supported ? ( + + + {t('settingsChannelBridges.unsupported')} + + + ) : needsLocalEnablement ? ( + } + onPress={() => router.push('/(app)/settings/features')} + /> + ) : ( + + + {t('settingsChannelBridges.description')} + + + )} + + + {supported ? ( + + + + + + ) : null} + + {decision ? ( + + + + ) : null} + + + ); +}); + diff --git a/apps/ui/sources/text/translations/ca.ts b/apps/ui/sources/text/translations/ca.ts index 8440fa8b69..8de48a4f7b 100644 --- a/apps/ui/sources/text/translations/ca.ts +++ b/apps/ui/sources/text/translations/ca.ts @@ -1041,6 +1041,8 @@ export const ca: TranslationStructure = { executionRunsSubtitle: 'Execucions en diverses màquines', connectedServices: 'Serveis connectats', connectedServicesSubtitle: 'Subscripcions Claude/Codex i perfils OAuth', + channelBridges: 'Channel Bridges', + channelBridgesSubtitle: 'Bridge external chats (Telegram) into sessions', featuresTitle: 'Funcions', featuresSubtitle: 'Activa o desactiva les funcions de l\'aplicació', developer: 'Desenvolupador', @@ -2422,6 +2424,16 @@ export const ca: TranslationStructure = { }, }, + settingsChannelBridges: { + unsupported: 'Channel bridges are not supported in this environment.', + enableInFeatures: 'Enable Channel Bridges', + enableInFeaturesSubtitle: 'Channel bridges are experimental and disabled by default.', + description: 'Channel bridges let you attach external chats (Telegram) to sessions and forward messages to the agent.', + telegramTitle: 'Telegram', + telegramFooter: 'Configure Telegram via CLI, then manage bindings in Telegram with /sessions, /attach, /detach, /help.', + diagnosticsTitle: 'Diagnostics', + }, + settingsFeatures: { // Features settings screen experiments: 'Experimentació', diff --git a/apps/ui/sources/text/translations/en.ts b/apps/ui/sources/text/translations/en.ts index 54d0493b0c..30c6c45c6f 100644 --- a/apps/ui/sources/text/translations/en.ts +++ b/apps/ui/sources/text/translations/en.ts @@ -1128,6 +1128,8 @@ export const en = { executionRunsSubtitle: 'Subagents across machines', connectedServices: 'Connected services', connectedServicesSubtitle: 'Claude/Codex subscriptions and OAuth profiles', + channelBridges: 'Channel Bridges', + channelBridgesSubtitle: 'Bridge external chats (Telegram) into sessions', featuresTitle: 'Features', featuresSubtitle: 'Enable or disable app features', developer: 'Developer', @@ -2517,6 +2519,16 @@ export const en = { }, }, + settingsChannelBridges: { + unsupported: 'Channel bridges are not supported in this environment.', + enableInFeatures: 'Enable Channel Bridges', + enableInFeaturesSubtitle: 'Channel bridges are experimental and disabled by default.', + description: 'Channel bridges let you attach external chats (Telegram) to sessions and forward messages to the agent.', + telegramTitle: 'Telegram', + telegramFooter: 'Configure Telegram via CLI, then manage bindings in Telegram with /sessions, /attach, /detach, /help.', + diagnosticsTitle: 'Diagnostics', + }, + settingsFeatures: { // Features settings screen experiments: 'Experiments', diff --git a/apps/ui/sources/text/translations/es.ts b/apps/ui/sources/text/translations/es.ts index a031abd379..617de99bd6 100644 --- a/apps/ui/sources/text/translations/es.ts +++ b/apps/ui/sources/text/translations/es.ts @@ -1105,6 +1105,8 @@ export const es: TranslationStructure = { executionRunsSubtitle: "Ejecuciones en distintas máquinas", connectedServices: "Servicios conectados", connectedServicesSubtitle: "Suscripciones de Claude/Codex y perfiles OAuth", + channelBridges: 'Channel Bridges', + channelBridgesSubtitle: 'Bridge external chats (Telegram) into sessions', featuresTitle: "Características", featuresSubtitle: "Habilitar o deshabilitar funciones de la aplicación", developer: "Desarrollador", @@ -2587,6 +2589,16 @@ export const es: TranslationStructure = { }, }, + settingsChannelBridges: { + unsupported: "Channel bridges are not supported in this environment.", + enableInFeatures: "Enable Channel Bridges", + enableInFeaturesSubtitle: "Channel bridges are experimental and disabled by default.", + description: "Channel bridges let you attach external chats (Telegram) to sessions and forward messages to the agent.", + telegramTitle: "Telegram", + telegramFooter: "Configure Telegram via CLI, then manage bindings in Telegram with /sessions, /attach, /detach, /help.", + diagnosticsTitle: "Diagnostics", + }, + settingsFeatures: { // Features settings screen experiments: "Experimentos", diff --git a/apps/ui/sources/text/translations/it.ts b/apps/ui/sources/text/translations/it.ts index 781450baf5..f8cf1d7993 100644 --- a/apps/ui/sources/text/translations/it.ts +++ b/apps/ui/sources/text/translations/it.ts @@ -1440,6 +1440,8 @@ export const it: TranslationStructure = { executionRunsSubtitle: "Esecuzioni su più macchine", connectedServices: "Servizi connessi", connectedServicesSubtitle: "Abbonamenti Claude/Codex e profili OAuth", + channelBridges: 'Channel Bridges', + channelBridgesSubtitle: 'Bridge external chats (Telegram) into sessions', featuresTitle: "Funzionalità", featuresSubtitle: "Abilita o disabilita le funzionalità dell'app", developer: "Sviluppatore", @@ -2926,6 +2928,16 @@ export const it: TranslationStructure = { }, }, + settingsChannelBridges: { + unsupported: "Channel bridges are not supported in this environment.", + enableInFeatures: "Enable Channel Bridges", + enableInFeaturesSubtitle: "Channel bridges are experimental and disabled by default.", + description: "Channel bridges let you attach external chats (Telegram) to sessions and forward messages to the agent.", + telegramTitle: "Telegram", + telegramFooter: "Configure Telegram via CLI, then manage bindings in Telegram with /sessions, /attach, /detach, /help.", + diagnosticsTitle: "Diagnostics", + }, + settingsFeatures: { // Features settings screen experiments: "Esperimenti", diff --git a/apps/ui/sources/text/translations/ja.ts b/apps/ui/sources/text/translations/ja.ts index 4b3416da07..29c4419770 100644 --- a/apps/ui/sources/text/translations/ja.ts +++ b/apps/ui/sources/text/translations/ja.ts @@ -1405,6 +1405,8 @@ export const ja: TranslationStructure = { executionRunsSubtitle: "複数マシンでの実行", connectedServices: "接続済みサービス", connectedServicesSubtitle: "Claude/Codex のサブスクリプションと OAuth プロファイル", + channelBridges: "Channel Bridges", + channelBridgesSubtitle: "Bridge external chats (Telegram) into sessions", featuresTitle: "機能", featuresSubtitle: "アプリ機能の有効/無効を切り替え", developer: "開発者", @@ -2869,6 +2871,16 @@ export const ja: TranslationStructure = { }, }, + settingsChannelBridges: { + unsupported: "Channel bridges are not supported in this environment.", + enableInFeatures: "Enable Channel Bridges", + enableInFeaturesSubtitle: "Channel bridges are experimental and disabled by default.", + description: "Channel bridges let you attach external chats (Telegram) to sessions and forward messages to the agent.", + telegramTitle: "Telegram", + telegramFooter: "Configure Telegram via CLI, then manage bindings in Telegram with /sessions, /attach, /detach, /help.", + diagnosticsTitle: "Diagnostics", + }, + settingsFeatures: { // Features settings screen experiments: "実験的機能", diff --git a/apps/ui/sources/text/translations/pl.ts b/apps/ui/sources/text/translations/pl.ts index d54e1853de..7b18532c90 100644 --- a/apps/ui/sources/text/translations/pl.ts +++ b/apps/ui/sources/text/translations/pl.ts @@ -1137,6 +1137,8 @@ export const pl: TranslationStructure = { executionRunsSubtitle: "Execution runs na wielu maszynach", connectedServices: "Połączone usługi", connectedServicesSubtitle: "Subskrypcje Claude/Codex i profile OAuth", + channelBridges: 'Channel Bridges', + channelBridgesSubtitle: 'Bridge external chats (Telegram) into sessions', featuresTitle: "Funkcje", featuresSubtitle: "Włącz lub wyłącz funkcje aplikacji", developer: "Deweloper", @@ -2620,6 +2622,16 @@ export const pl: TranslationStructure = { }, }, + settingsChannelBridges: { + unsupported: "Channel bridges are not supported in this environment.", + enableInFeatures: "Enable Channel Bridges", + enableInFeaturesSubtitle: "Channel bridges are experimental and disabled by default.", + description: "Channel bridges let you attach external chats (Telegram) to sessions and forward messages to the agent.", + telegramTitle: "Telegram", + telegramFooter: "Configure Telegram via CLI, then manage bindings in Telegram with /sessions, /attach, /detach, /help.", + diagnosticsTitle: "Diagnostics", + }, + settingsFeatures: { // Features settings screen experiments: "Eksperymenty", diff --git a/apps/ui/sources/text/translations/pt.ts b/apps/ui/sources/text/translations/pt.ts index 3ef5280778..3fcf46a8e9 100644 --- a/apps/ui/sources/text/translations/pt.ts +++ b/apps/ui/sources/text/translations/pt.ts @@ -1330,6 +1330,8 @@ export const pt: TranslationStructure = { executionRunsSubtitle: "Execuções em várias máquinas", connectedServices: "Serviços conectados", connectedServicesSubtitle: "Assinaturas Claude/Codex e perfis OAuth", + channelBridges: "Channel Bridges", + channelBridgesSubtitle: "Bridge external chats (Telegram) into sessions", featuresTitle: "Recursos", featuresSubtitle: "Ativar ou desativar recursos do aplicativo", developer: "Desenvolvedor", @@ -2812,6 +2814,16 @@ export const pt: TranslationStructure = { }, }, + settingsChannelBridges: { + unsupported: "Channel bridges are not supported in this environment.", + enableInFeatures: "Enable Channel Bridges", + enableInFeaturesSubtitle: "Channel bridges are experimental and disabled by default.", + description: "Channel bridges let you attach external chats (Telegram) to sessions and forward messages to the agent.", + telegramTitle: "Telegram", + telegramFooter: "Configure Telegram via CLI, then manage bindings in Telegram with /sessions, /attach, /detach, /help.", + diagnosticsTitle: "Diagnostics", + }, + settingsFeatures: { // Features settings screen experiments: "Experimentos", diff --git a/apps/ui/sources/text/translations/ru.ts b/apps/ui/sources/text/translations/ru.ts index 836744df34..d15089483c 100644 --- a/apps/ui/sources/text/translations/ru.ts +++ b/apps/ui/sources/text/translations/ru.ts @@ -1097,6 +1097,8 @@ export const ru: TranslationStructure = { executionRunsSubtitle: "Запуски выполнения на разных машинах", connectedServices: "Подключенные сервисы", connectedServicesSubtitle: "Подписки Claude/Codex и OAuth‑профили", + channelBridges: "Channel Bridges", + channelBridgesSubtitle: "Bridge external chats (Telegram) into sessions", featuresTitle: "Возможности", featuresSubtitle: "Включить или отключить функции приложения", developer: "Разработчик", @@ -2568,6 +2570,16 @@ export const ru: TranslationStructure = { }, }, + settingsChannelBridges: { + unsupported: "Channel bridges are not supported in this environment.", + enableInFeatures: "Enable Channel Bridges", + enableInFeaturesSubtitle: "Channel bridges are experimental and disabled by default.", + description: "Channel bridges let you attach external chats (Telegram) to sessions and forward messages to the agent.", + telegramTitle: "Telegram", + telegramFooter: "Configure Telegram via CLI, then manage bindings in Telegram with /sessions, /attach, /detach, /help.", + diagnosticsTitle: "Diagnostics", + }, + settingsFeatures: { // Features settings screen experiments: "Эксперименты", diff --git a/apps/ui/sources/text/translations/zh-Hans.ts b/apps/ui/sources/text/translations/zh-Hans.ts index afa32f1428..78c796af0c 100644 --- a/apps/ui/sources/text/translations/zh-Hans.ts +++ b/apps/ui/sources/text/translations/zh-Hans.ts @@ -1053,6 +1053,8 @@ export const zhHans: TranslationStructure = { executionRunsSubtitle: "跨设备执行运行", connectedServices: "已连接服务", connectedServicesSubtitle: "Claude/Codex 订阅与 OAuth 配置", + channelBridges: "Channel Bridges", + channelBridgesSubtitle: "Bridge external chats (Telegram) into sessions", featuresTitle: "功能", featuresSubtitle: "启用或禁用应用功能", developer: "开发者", @@ -2471,6 +2473,16 @@ export const zhHans: TranslationStructure = { }, }, + settingsChannelBridges: { + unsupported: "此环境不支持频道桥接。", + enableInFeatures: "启用频道桥接", + enableInFeaturesSubtitle: "频道桥接为实验功能,默认关闭。", + description: "频道桥接可将外部聊天(Telegram)附加到会话,并将消息转发给代理。", + telegramTitle: "Telegram", + telegramFooter: "请通过 CLI 配置 Telegram,然后在 Telegram 中使用 /sessions、/attach、/detach、/help 管理绑定。", + diagnosticsTitle: "诊断", + }, + settingsFeatures: { // Features settings screen experiments: "实验功能", diff --git a/apps/ui/sources/text/translations/zh-Hant.ts b/apps/ui/sources/text/translations/zh-Hant.ts index cc50c1fcfd..88b1ed1916 100644 --- a/apps/ui/sources/text/translations/zh-Hant.ts +++ b/apps/ui/sources/text/translations/zh-Hant.ts @@ -2231,6 +2231,16 @@ const zhHantOverrides: DeepPartial = { }, }, + settingsChannelBridges: { + unsupported: '此環境不支援頻道橋接。', + enableInFeatures: '啟用頻道橋接', + enableInFeaturesSubtitle: '頻道橋接為實驗功能,預設關閉。', + description: '頻道橋接可將外部聊天(Telegram)附加到工作階段,並將訊息轉發給代理。', + telegramTitle: 'Telegram', + telegramFooter: '請透過 CLI 設定 Telegram,然後在 Telegram 中使用 /sessions、/attach、/detach、/help 管理綁定。', + diagnosticsTitle: '診斷', + }, + settingsFeatures: { // Features settings screen experiments: '實驗功能', From f52c88080dc5a9dc0ca0c56db60ecbf9e88a7ba8 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Thu, 26 Mar 2026 18:53:32 +0100 Subject: [PATCH 40/81] test(e2e): verify channel bridges feature gates --- ...atureGates.feat.channelBridges.e2e.test.ts | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 packages/tests/suites/core-e2e/channelBridges.foundation.featureGates.feat.channelBridges.e2e.test.ts diff --git a/packages/tests/suites/core-e2e/channelBridges.foundation.featureGates.feat.channelBridges.e2e.test.ts b/packages/tests/suites/core-e2e/channelBridges.foundation.featureGates.feat.channelBridges.e2e.test.ts new file mode 100644 index 0000000000..ffa2ef2a12 --- /dev/null +++ b/packages/tests/suites/core-e2e/channelBridges.foundation.featureGates.feat.channelBridges.e2e.test.ts @@ -0,0 +1,42 @@ +import { afterEach, describe, expect, it } from 'vitest'; + +import { createRunDirs } from '../../src/testkit/runDir'; +import { fetchJson } from '../../src/testkit/http'; +import { startServerLight, type StartedServer } from '../../src/testkit/process/serverLight'; + +const run = createRunDirs({ runLabel: 'core' }); + +describe('core e2e: channel bridges foundation', () => { + let server: StartedServer | null = null; + + afterEach(async () => { + await server?.stop(); + server = null; + }); + + it('advertises channel bridge gates as disabled when the server env gate is disabled', async () => { + const testDir = run.testDir('feature-negotiation-channel-bridges-disabled'); + server = await startServerLight({ + testDir, + dbProvider: 'sqlite', + extraEnv: { + HAPPIER_FEATURE_CHANNEL_BRIDGES__ENABLED: '0', + }, + }); + + const features = await fetchJson(`${server.baseUrl}/v1/features`); + expect(features.status).toBe(200); + expect(features.data?.features?.channelBridges?.enabled).toBe(false); + expect(features.data?.features?.channelBridges?.telegram?.enabled).toBe(false); + }, 180_000); + + it('advertises channel bridge gates as enabled by default', async () => { + const testDir = run.testDir('feature-negotiation-channel-bridges-enabled'); + server = await startServerLight({ testDir, dbProvider: 'sqlite' }); + + const features = await fetchJson(`${server.baseUrl}/v1/features`); + expect(features.status).toBe(200); + expect(features.data?.features?.channelBridges?.enabled).toBe(true); + expect(features.data?.features?.channelBridges?.telegram?.enabled).toBe(true); + }, 180_000); +}); From b4b56ab71733cb70be5d9fcde9267148460eb4e4 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Thu, 26 Mar 2026 19:43:41 +0100 Subject: [PATCH 41/81] fix(cli): tighten channel bridges types and redact Telegram tokens --- .../client/serializeAxiosErrorForLog.test.ts | 2 +- .../api/client/serializeAxiosErrorForLog.ts | 4 +-- .../channels/core/channelBridgeWorker.test.ts | 14 ++++++++++ .../src/channels/providers/_registry/types.ts | 3 +-- .../src/features/settingsFeatureToggles.ts | 26 +++++++++++++------ 5 files changed, 36 insertions(+), 13 deletions(-) diff --git a/apps/cli/src/api/client/serializeAxiosErrorForLog.test.ts b/apps/cli/src/api/client/serializeAxiosErrorForLog.test.ts index 61ffdea49a..d20cc9d79c 100644 --- a/apps/cli/src/api/client/serializeAxiosErrorForLog.test.ts +++ b/apps/cli/src/api/client/serializeAxiosErrorForLog.test.ts @@ -34,7 +34,7 @@ describe('serializeAxiosErrorForLog', () => { const serialized = serializeAxiosErrorForLog(err); expect(serialized).toEqual(expect.objectContaining({ - url: 'https://api.telegram.org/bot/getUpdates', + url: 'https://api.telegram.org/botREDACTED/getUpdates', })); }); }); diff --git a/apps/cli/src/api/client/serializeAxiosErrorForLog.ts b/apps/cli/src/api/client/serializeAxiosErrorForLog.ts index 415eb9389a..b7bcd4c1a6 100644 --- a/apps/cli/src/api/client/serializeAxiosErrorForLog.ts +++ b/apps/cli/src/api/client/serializeAxiosErrorForLog.ts @@ -7,7 +7,7 @@ function redactTelegramBotTokenPathname(pathname: string): string { const redacted = parts.map((part) => { const segment = String(part ?? ''); if (segment.startsWith('bot') && segment.length > 3) { - return 'bot'; + return 'botREDACTED'; } return segment; }); @@ -27,7 +27,7 @@ function redactUrlForLog(raw: unknown): string | undefined { } catch { // Best-effort: strip query/hash to avoid leaking secrets in URLs. const withoutQuery = value.split('?')[0]?.split('#')[0] ?? value; - return withoutQuery.replace(/\/bot[^/]+\//g, '/bot/'); + return withoutQuery.replace(/\/bot[^/]+\//g, '/botREDACTED/'); } } diff --git a/apps/cli/src/channels/core/channelBridgeWorker.test.ts b/apps/cli/src/channels/core/channelBridgeWorker.test.ts index bc7e29c824..5f7afcbad2 100644 --- a/apps/cli/src/channels/core/channelBridgeWorker.test.ts +++ b/apps/cli/src/channels/core/channelBridgeWorker.test.ts @@ -855,6 +855,7 @@ describe('executeChannelBridgeTick', () => { const { deps, sentToSession } = createDepsHarness(); await store.upsertBinding({ + ...DEFAULT_BINDING_POLICY, providerId: 'telegram', conversationId: '-1001', threadId: '99', @@ -923,6 +924,7 @@ describe('executeChannelBridgeTick', () => { const { deps, sentToSession } = createDepsHarness(); await store.upsertBinding({ + ...DEFAULT_BINDING_POLICY, providerId: 'telegram', conversationId: '-1001', threadId: '99', @@ -2054,6 +2056,7 @@ describe('executeChannelBridgeTick', () => { const harness = createAdapterHarness(); await store.upsertBinding({ + ...DEFAULT_BINDING_POLICY, providerId: 'telegram', conversationId: '-3002', threadId: null, @@ -2091,6 +2094,7 @@ describe('executeChannelBridgeTick', () => { const harness = createAdapterHarness(); await store.upsertBinding({ + ...DEFAULT_BINDING_POLICY, providerId: 'telegram', conversationId: '-3003', threadId: null, @@ -2130,6 +2134,7 @@ describe('executeChannelBridgeTick', () => { const harness = createAdapterHarness(); await baseStore.upsertBinding({ + ...DEFAULT_BINDING_POLICY, providerId: 'telegram', conversationId: '-3004', threadId: null, @@ -2166,6 +2171,7 @@ describe('executeChannelBridgeTick', () => { }); await store.upsertBinding({ + ...DEFAULT_BINDING_POLICY, providerId: 'telegram', conversationId: '-3004b', threadId: null, @@ -2208,6 +2214,7 @@ describe('executeChannelBridgeTick', () => { }); await store.upsertBinding({ + ...DEFAULT_BINDING_POLICY, providerId: 'telegram-alt', conversationId: '-3004b-alt', threadId: null, @@ -2247,6 +2254,7 @@ describe('executeChannelBridgeTick', () => { }); await store.upsertBinding({ + ...DEFAULT_BINDING_POLICY, providerId: 'telegram', conversationId: '-3004c', threadId: null, @@ -2286,6 +2294,7 @@ describe('executeChannelBridgeTick', () => { }); await store.upsertBinding({ + ...DEFAULT_BINDING_POLICY, providerId: 'telegram', conversationId: '-3004c', threadId: null, @@ -2328,6 +2337,7 @@ describe('executeChannelBridgeTick', () => { await baseStore.upsertBinding({ ...ref, + ...DEFAULT_BINDING_POLICY, sessionId: 'sess-old', lastForwardedSeq: 0, }); @@ -2337,6 +2347,7 @@ describe('executeChannelBridgeTick', () => { updateLastForwardedSeq: async (bindingRef, params) => { await baseStore.upsertBinding({ ...bindingRef, + ...DEFAULT_BINDING_POLICY, sessionId: 'sess-new', lastForwardedSeq: 0, }); @@ -2372,6 +2383,7 @@ describe('executeChannelBridgeTick', () => { const harness = createAdapterHarness('discord'); await store.upsertBinding({ + ...DEFAULT_BINDING_POLICY, providerId: 'telegram', conversationId: 'orphaned-room', threadId: null, @@ -2398,6 +2410,7 @@ describe('executeChannelBridgeTick', () => { const harness = createAdapterHarness('discord'); await store.upsertBinding({ + ...DEFAULT_BINDING_POLICY, providerId: 'telegram', conversationId: 'orphaned-room', threadId: null, @@ -2444,6 +2457,7 @@ describe('executeChannelBridgeTick', () => { await store.upsertBinding({ ...bindingRef, + ...DEFAULT_BINDING_POLICY, sessionId: 'sess-orphaned', lastForwardedSeq: 0, }); diff --git a/apps/cli/src/channels/providers/_registry/types.ts b/apps/cli/src/channels/providers/_registry/types.ts index 4364e519ba..6a5a142ea4 100644 --- a/apps/cli/src/channels/providers/_registry/types.ts +++ b/apps/cli/src/channels/providers/_registry/types.ts @@ -12,6 +12,5 @@ export type ChannelBridgeProviderDefinition< > = Readonly<{ providerId: TProviderId; readConfig: (root: TRootConfig) => TProviderConfig; - createRuntime: (params: Readonly<{ config: TProviderConfig }>) => Promise; + createRuntime(params: Readonly<{ config: TProviderConfig }>): Promise; }>; - diff --git a/apps/cli/src/features/settingsFeatureToggles.ts b/apps/cli/src/features/settingsFeatureToggles.ts index dcf7215c36..00fa4dcb7e 100644 --- a/apps/cli/src/features/settingsFeatureToggles.ts +++ b/apps/cli/src/features/settingsFeatureToggles.ts @@ -5,6 +5,12 @@ function asRecord(value: unknown): RecordLike | null { return value as RecordLike; } +function shallowCloneRecord(value: unknown): RecordLike { + const record = asRecord(value); + if (!record) return {}; + return { ...record }; +} + export function resolveExperimentalSettingsFeatureToggleEnabled(params: Readonly<{ settings: unknown; featureId: string; @@ -22,17 +28,21 @@ export function resolveExperimentalSettingsFeatureToggleEnabled(params: Readonly return params.defaultEnabled === true; } -export function ensureExperimentalSettingsFeatureToggleEnabled(params: Readonly<{ - settings: unknown; +export function ensureExperimentalSettingsFeatureToggleEnabled(params: Readonly<{ + settings: TSettings; featureId: string; -}>): RecordLike { - const root = JSON.parse(JSON.stringify(asRecord(params.settings) ?? {})) as RecordLike; +}>): TSettings { + const root = shallowCloneRecord(params.settings); + if (root.experiments !== true) { root.experiments = true; } - const featureToggles = asRecord(root.featureToggles) ?? {}; - featureToggles[params.featureId] = true; - root.featureToggles = featureToggles; - return root; + const existingFeatureToggles = asRecord(root.featureToggles); + root.featureToggles = { + ...(existingFeatureToggles ?? {}), + [params.featureId]: true, + }; + + return root as unknown as TSettings; } From 5a318456e71fc85060b706cfcac59992e409f94d Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Thu, 26 Mar 2026 19:43:52 +0100 Subject: [PATCH 42/81] fix(ui): keep typecheck green for channel bridges --- .../ChannelBridgesSettingsView.tsx | 13 +++++----- .../EmbeddedTerminalToolbarIconButton.tsx | 9 ++++--- .../views/timeline/ToolTimelineRowHeader.tsx | 7 ----- .../ui/code/diff/DiffFilesListView.tsx | 26 ++++++++++++------- .../DiffPresentationStyleToggleButton.tsx | 15 ++++++----- .../dev/testkit/fixtures/featureFixtures.ts | 15 +++++++++++ .../hooks/server/serverFeaturesTestUtils.ts | 4 +++ 7 files changed, 57 insertions(+), 32 deletions(-) diff --git a/apps/ui/sources/components/settings/channelBridges/ChannelBridgesSettingsView.tsx b/apps/ui/sources/components/settings/channelBridges/ChannelBridgesSettingsView.tsx index 88441565a0..c4d43225ff 100644 --- a/apps/ui/sources/components/settings/channelBridges/ChannelBridgesSettingsView.tsx +++ b/apps/ui/sources/components/settings/channelBridges/ChannelBridgesSettingsView.tsx @@ -10,6 +10,7 @@ import { ItemList } from '@/components/ui/lists/ItemList'; import { layout } from '@/components/ui/layout/layout'; import { CodeView } from '@/components/ui/media/CodeView'; import { Text } from '@/components/ui/text/Text'; +import type { FeatureId } from '@happier-dev/protocol'; import { FeatureDiagnosticsPanel } from '@/components/settings/features/FeatureDiagnosticsPanel'; import { useFeatureDecision } from '@/hooks/server/useFeatureDecision'; import { t } from '@/text'; @@ -31,6 +32,11 @@ export const ChannelBridgesSettingsView = React.memo(function ChannelBridgesSett ].join(' '); }, []); + const diagnosticsFeatureIds = React.useMemo(() => ([ + 'channelBridges', + 'channelBridges.telegram', + ] as const satisfies readonly FeatureId[]), []); + return ( @@ -65,13 +71,8 @@ export const ChannelBridgesSettingsView = React.memo(function ChannelBridgesSett ) : null} - {decision ? ( - - - - ) : null} + {supported ? : null} ); }); - diff --git a/apps/ui/sources/components/terminal/embedded/EmbeddedTerminalToolbarIconButton.tsx b/apps/ui/sources/components/terminal/embedded/EmbeddedTerminalToolbarIconButton.tsx index ef4040e770..79d896f53c 100644 --- a/apps/ui/sources/components/terminal/embedded/EmbeddedTerminalToolbarIconButton.tsx +++ b/apps/ui/sources/components/terminal/embedded/EmbeddedTerminalToolbarIconButton.tsx @@ -20,9 +20,12 @@ export const EmbeddedTerminalToolbarIconButton = React.memo((props: EmbeddedTerm accessibilityLabel={props.accessibilityLabel} hitSlop={8} onPress={props.onPress} - style={({ pressed, hovered }) => ({ - opacity: pressed ? 0.68 : hovered ? 0.82 : 1, - })} + style={(state) => { + const hovered = (state as typeof state & { hovered?: boolean }).hovered === true; + return { + opacity: state.pressed ? 0.68 : hovered ? 0.82 : 1, + }; + }} > diff --git a/apps/ui/sources/components/tools/shell/views/timeline/ToolTimelineRowHeader.tsx b/apps/ui/sources/components/tools/shell/views/timeline/ToolTimelineRowHeader.tsx index 1bba5de0d4..50443e5e71 100644 --- a/apps/ui/sources/components/tools/shell/views/timeline/ToolTimelineRowHeader.tsx +++ b/apps/ui/sources/components/tools/shell/views/timeline/ToolTimelineRowHeader.tsx @@ -73,7 +73,6 @@ export const ToolTimelineRowHeader = React.memo(function ToolTimelineRowHeader(p @@ -83,7 +82,6 @@ export const ToolTimelineRowHeader = React.memo(function ToolTimelineRowHeader(p style={[ styles.iconLayer, styles.iconLayerOverlay, - Platform.OS === 'web' ? styles.iconLayerTransition : null, isHovered ? null : styles.iconLayerHidden, ]} > @@ -190,11 +188,6 @@ const styles = StyleSheet.create((theme, _runtime) => ({ alignItems: 'center', justifyContent: 'center', }, - iconLayerTransition: { - transitionProperty: 'opacity', - transitionDuration: '140ms', - transitionTimingFunction: 'ease', - }, iconLayerHidden: { opacity: 0, }, diff --git a/apps/ui/sources/components/ui/code/diff/DiffFilesListView.tsx b/apps/ui/sources/components/ui/code/diff/DiffFilesListView.tsx index e8ace4c28b..282c322f65 100644 --- a/apps/ui/sources/components/ui/code/diff/DiffFilesListView.tsx +++ b/apps/ui/sources/components/ui/code/diff/DiffFilesListView.tsx @@ -178,11 +178,14 @@ export const DiffFilesListView = React.forwardRef setFocusedFileKey(file.key)} onBlur={() => setFocusedFileKey((prev) => (prev === file.key ? null : prev))} - style={({ hovered, pressed }) => ([ - styles.fileRowInteractive, - hovered ? styles.fileRowHovered : null, - pressed ? styles.fileRowPressed : null, - ])} + style={(state) => { + const hovered = (state as typeof state & { hovered?: boolean }).hovered === true; + return [ + styles.fileRowInteractive, + hovered ? styles.fileRowHovered : null, + state.pressed ? styles.fileRowPressed : null, + ]; + }} accessibilityRole="button" > @@ -241,11 +244,14 @@ export const DiffFilesListView = React.forwardRef ([ - styles.openFileButton, - hovered ? styles.openFileButtonHovered : null, - pressed ? styles.openFileButtonPressed : null, - ])} + style={(state) => { + const hovered = (state as typeof state & { hovered?: boolean }).hovered === true; + return [ + styles.openFileButton, + hovered ? styles.openFileButtonHovered : null, + state.pressed ? styles.openFileButtonPressed : null, + ]; + }} > diff --git a/apps/ui/sources/components/ui/code/diff/DiffPresentationStyleToggleButton.tsx b/apps/ui/sources/components/ui/code/diff/DiffPresentationStyleToggleButton.tsx index e15d39f041..62572555df 100644 --- a/apps/ui/sources/components/ui/code/diff/DiffPresentationStyleToggleButton.tsx +++ b/apps/ui/sources/components/ui/code/diff/DiffPresentationStyleToggleButton.tsx @@ -39,12 +39,15 @@ export const DiffPresentationStyleToggleButton = React.memo ([ - styles.root, - hovered ? styles.rootHovered : null, - pressed ? styles.rootPressed : null, - disabled ? styles.rootDisabled : null, - ])} + style={(state) => { + const hovered = (state as typeof state & { hovered?: boolean }).hovered === true; + return [ + styles.root, + hovered ? styles.rootHovered : null, + state.pressed ? styles.rootPressed : null, + disabled ? styles.rootDisabled : null, + ]; + }} > , 'features' features?: Omit< Partial, | 'attachments' + | 'channelBridges' | 'automations' | 'connectedServices' | 'updates' @@ -19,6 +20,7 @@ type RootLayoutFeaturesOverrides = Omit, 'features' > & Readonly<{ attachments?: Partial; + channelBridges?: Partial; automations?: Partial; connectedServices?: Partial; updates?: Partial; @@ -54,6 +56,10 @@ const BASE_ROOT_LAYOUT_FEATURES: RootLayoutFeatures = { attachments: { uploads: { enabled: true }, }, + channelBridges: { + enabled: true, + telegram: { enabled: true }, + }, automations: { enabled: true, }, @@ -190,6 +196,7 @@ export function createRootLayoutFeaturesResponse(overrides?: RootLayoutFeaturesO const nextMachines: Partial = nextFeatures.machines ?? {}; const nextTerminal: Partial = nextFeatures.terminal ?? {}; const nextAttachments: Partial = nextFeatures.attachments ?? {}; + const nextChannelBridges: Partial = nextFeatures.channelBridges ?? {}; const nextEncryption: Partial = nextFeatures.encryption ?? {}; const nextE2ee: Partial = nextFeatures.e2ee ?? {}; const nextConnectedServices: Partial = @@ -235,6 +242,14 @@ export function createRootLayoutFeaturesResponse(overrides?: RootLayoutFeaturesO ...BASE_ROOT_LAYOUT_FEATURES.features.attachments, ...nextAttachments, }, + channelBridges: { + ...BASE_ROOT_LAYOUT_FEATURES.features.channelBridges, + ...nextChannelBridges, + telegram: { + ...BASE_ROOT_LAYOUT_FEATURES.features.channelBridges.telegram, + ...(nextChannelBridges.telegram ?? {}), + }, + }, sharing: { ...BASE_ROOT_LAYOUT_FEATURES.features.sharing, ...nextSharing, diff --git a/apps/ui/sources/hooks/server/serverFeaturesTestUtils.ts b/apps/ui/sources/hooks/server/serverFeaturesTestUtils.ts index 7127813133..01a671faf1 100644 --- a/apps/ui/sources/hooks/server/serverFeaturesTestUtils.ts +++ b/apps/ui/sources/hooks/server/serverFeaturesTestUtils.ts @@ -61,6 +61,10 @@ export function buildServerFeaturesResponse(overrides: FixtureOverrides = {}): F attachments: { uploads: { enabled: true }, }, + channelBridges: { + enabled: true, + telegram: { enabled: true }, + }, automations: { enabled: overrides.automationsEnabled ?? true, }, From 4242f488a86ab5694cf73a993783280fa8daafe5 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Fri, 27 Mar 2026 12:42:01 +0100 Subject: [PATCH 43/81] fix(cli): remove conflict markers from daemon automation test --- apps/cli/src/daemon/startDaemon.automation.integration.test.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/apps/cli/src/daemon/startDaemon.automation.integration.test.ts b/apps/cli/src/daemon/startDaemon.automation.integration.test.ts index 08cad683f7..e45597feae 100644 --- a/apps/cli/src/daemon/startDaemon.automation.integration.test.ts +++ b/apps/cli/src/daemon/startDaemon.automation.integration.test.ts @@ -90,11 +90,8 @@ const harness = vi.hoisted(() => { connectedServiceQuotasResume, connectedServiceQuotasStop, createDaemonShutdownController, -<<<<<<< HEAD -======= startChannelBridgeFromEnv, resolveChannelBridgesDaemonEnabled, ->>>>>>> ed44ae300 (feat(cli): gate channel bridge daemon startup) emitMachineConnectionState: (state: any) => machineConnectionStateListener?.(state), setAutoShutdownAfterAutomationStart: (value: boolean) => { autoShutdownAfterAutomationStart = value; From 9e7c26a4082f4b66e1454bd0471f94b0df1f781d Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Fri, 27 Mar 2026 12:51:19 +0100 Subject: [PATCH 44/81] test(cli): include bridge in command surface manifest coverage --- apps/cli/src/cli/commandSurfaceManifest.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/cli/src/cli/commandSurfaceManifest.test.ts b/apps/cli/src/cli/commandSurfaceManifest.test.ts index 377027660e..b8d7a0eba9 100644 --- a/apps/cli/src/cli/commandSurfaceManifest.test.ts +++ b/apps/cli/src/cli/commandSurfaceManifest.test.ts @@ -12,6 +12,7 @@ describe('CLI command-surface manifest', () => { 'opencode', 'gemini', 'connect', + 'bridge', 'notify', 'install', 'daemon', From 77a03e0248fa2416ed2d5653a69aa6c575382bb5 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Fri, 27 Mar 2026 14:36:47 +0100 Subject: [PATCH 45/81] test(server): cover channel bridges env toggles --- .../resolveServerFeaturePayload.spec.ts | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/apps/server/sources/app/features/catalog/resolveServerFeaturePayload.spec.ts b/apps/server/sources/app/features/catalog/resolveServerFeaturePayload.spec.ts index 233a890b12..5fabc24970 100644 --- a/apps/server/sources/app/features/catalog/resolveServerFeaturePayload.spec.ts +++ b/apps/server/sources/app/features/catalog/resolveServerFeaturePayload.spec.ts @@ -132,6 +132,30 @@ describe("resolveServerFeaturePayload", () => { expect(payload.features.channelBridges.telegram.enabled).toBe(true); }); + it("disables channel bridges (and all providers) when the env toggle is off", () => { + const payload = resolveServerFeaturePayload( + { + HAPPIER_FEATURE_CHANNEL_BRIDGES__ENABLED: "0", + } as NodeJS.ProcessEnv, + [resolveChannelBridgesFeature], + ); + + expect(payload.features.channelBridges.enabled).toBe(false); + expect(payload.features.channelBridges.telegram.enabled).toBe(false); + }); + + it("disables only telegram provider when the env toggle is off", () => { + const payload = resolveServerFeaturePayload( + { + HAPPIER_FEATURE_CHANNEL_BRIDGES_TELEGRAM__ENABLED: "0", + } as NodeJS.ProcessEnv, + [resolveChannelBridgesFeature], + ); + + expect(payload.features.channelBridges.enabled).toBe(true); + expect(payload.features.channelBridges.telegram.enabled).toBe(false); + }); + it("disables only generic server-routed transfer when the env toggle is off", () => { const payload = resolveServerFeaturePayload({ HAPPIER_FEATURE_MACHINES_TRANSFER_SERVER_ROUTED__ENABLED: "0", From 635f52f9cb74c0dbcf334b08a7a3e2bbdd55ab6f Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Fri, 27 Mar 2026 15:01:49 +0100 Subject: [PATCH 46/81] fix(telegram): redact webhook secret path from logs --- .../channels/providers/telegram/telegramWebhookRelay.test.ts | 2 +- .../src/channels/providers/telegram/telegramWebhookRelay.ts | 3 ++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/cli/src/channels/providers/telegram/telegramWebhookRelay.test.ts b/apps/cli/src/channels/providers/telegram/telegramWebhookRelay.test.ts index 5864adf633..2d040eea0d 100644 --- a/apps/cli/src/channels/providers/telegram/telegramWebhookRelay.test.ts +++ b/apps/cli/src/channels/providers/telegram/telegramWebhookRelay.test.ts @@ -124,7 +124,7 @@ describe('startTelegramWebhookRelay', () => { expect(warnSpy).toHaveBeenCalledWith( '[TELEGRAM_WEBHOOK_RELAY] Failed to process inbound webhook update', expect.objectContaining({ - path: relay.path, + path: '/telegram/webhook/*', tokenLength: 'secret-fail'.length, }), ); diff --git a/apps/cli/src/channels/providers/telegram/telegramWebhookRelay.ts b/apps/cli/src/channels/providers/telegram/telegramWebhookRelay.ts index e6080622ca..d34a0564ac 100644 --- a/apps/cli/src/channels/providers/telegram/telegramWebhookRelay.ts +++ b/apps/cli/src/channels/providers/telegram/telegramWebhookRelay.ts @@ -56,6 +56,7 @@ export async function startTelegramWebhookRelay(params: Readonly<{ throw new Error('Webhook port must be between 0 and 65535'); } const path = `/telegram/webhook/${secretPathToken}`; + const redactedPath = '/telegram/webhook/*'; const app = fastify({ logger: false }); app.post(path, async (request, reply) => { @@ -75,7 +76,7 @@ export async function startTelegramWebhookRelay(params: Readonly<{ return reply.send({ ok: true }); } catch (error) { logger.warn('[TELEGRAM_WEBHOOK_RELAY] Failed to process inbound webhook update', { - path, + path: redactedPath, tokenLength: providedToken.trim().length, error: error instanceof Error ? error.message : String(error), }); From 0abf6b1d793ec788e9f03ce6a15113b8f13959ef Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Fri, 27 Mar 2026 15:01:58 +0100 Subject: [PATCH 47/81] fix(cli): validate Telegram webhook secrets from settings --- .../src/channels/channelBridgeConfig.test.ts | 18 ++++++++++++++++++ apps/cli/src/channels/channelBridgeConfig.ts | 2 +- 2 files changed, 19 insertions(+), 1 deletion(-) diff --git a/apps/cli/src/channels/channelBridgeConfig.test.ts b/apps/cli/src/channels/channelBridgeConfig.test.ts index c277ddd7fa..3a569be0c4 100644 --- a/apps/cli/src/channels/channelBridgeConfig.test.ts +++ b/apps/cli/src/channels/channelBridgeConfig.test.ts @@ -299,6 +299,24 @@ describe('resolveChannelBridgeRuntimeConfig', () => { expect(config.telegram.webhookSecret).toBe('settings-secret'); }); + it('treats invalid settings webhook secret token as missing', () => { + const config = resolveChannelBridgeRuntimeConfig({ + settings: { + channelBridge: { + providers: { + telegram: { + webhook: { + secret: 'bad token!', + }, + }, + }, + }, + }, + }); + + expect(config.telegram.webhookSecret).toBe(''); + }); + it('ignores non-loopback env webhook host and keeps settings host', () => { const config = resolveChannelBridgeRuntimeConfig({ env: { diff --git a/apps/cli/src/channels/channelBridgeConfig.ts b/apps/cli/src/channels/channelBridgeConfig.ts index a51cd97b4e..5daee3f1d8 100644 --- a/apps/cli/src/channels/channelBridgeConfig.ts +++ b/apps/cli/src/channels/channelBridgeConfig.ts @@ -234,7 +234,7 @@ export function resolveChannelBridgeRuntimeConfig(params: Readonly<{ webhookServer?.secret, secretsGlobal?.webhookSecret, webhookGlobal?.secret, - ], readTrimmedString) + ], readWebhookSecretToken) ?? ''; const envWebhookSecretRaw = typeof env.HAPPIER_TELEGRAM_WEBHOOK_SECRET === 'string' From 9204708f6cba6db391749159ffd31017203faf62 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Fri, 27 Mar 2026 15:02:06 +0100 Subject: [PATCH 48/81] fix(cli): scope inbound idempotency to session --- .../src/channels/startChannelBridgeWorker.test.ts | 13 ++++++++++++- apps/cli/src/channels/startChannelBridgeWorker.ts | 2 ++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/apps/cli/src/channels/startChannelBridgeWorker.test.ts b/apps/cli/src/channels/startChannelBridgeWorker.test.ts index c57701e31c..6ea0dd2b22 100644 --- a/apps/cli/src/channels/startChannelBridgeWorker.test.ts +++ b/apps/cli/src/channels/startChannelBridgeWorker.test.ts @@ -521,6 +521,15 @@ describe('startChannelBridgeFromEnv', () => { threadId: '42', messageId: 'msg-2', }); + await capturedDeps!.sendUserMessageToSession({ + sessionId: 'sess-local-id-2', + text: 'same channel message into different session', + sentFrom: 'telegram', + providerId: 'telegram', + conversationId: '-100local', + threadId: '42', + messageId: 'msg-1', + }); await expect(capturedDeps!.sendUserMessageToSession({ sessionId: 'sess-local-id-1', @@ -541,13 +550,15 @@ describe('startChannelBridgeFromEnv', () => { messageId: ' ', })).rejects.toThrow('inbound messageId is required'); - expect(sendCommitted).toHaveBeenCalledTimes(3); + expect(sendCommitted).toHaveBeenCalledTimes(4); const firstLocalId = sendCommitted.mock.calls[0]?.[0]?.localId; const secondLocalId = sendCommitted.mock.calls[1]?.[0]?.localId; const thirdLocalId = sendCommitted.mock.calls[2]?.[0]?.localId; + const fourthLocalId = sendCommitted.mock.calls[3]?.[0]?.localId; expect(firstLocalId).toBe(secondLocalId); expect(thirdLocalId).not.toBe(firstLocalId); + expect(fourthLocalId).not.toBe(firstLocalId); await handle?.stop(); }); diff --git a/apps/cli/src/channels/startChannelBridgeWorker.ts b/apps/cli/src/channels/startChannelBridgeWorker.ts index c2d5eba393..774bd0f71e 100644 --- a/apps/cli/src/channels/startChannelBridgeWorker.ts +++ b/apps/cli/src/channels/startChannelBridgeWorker.ts @@ -85,6 +85,8 @@ function createStableBridgeLocalId(params: Readonly<{ .update('\u0000') .update(params.threadId ?? '') .update('\u0000') + .update(params.sessionId) + .update('\u0000') .update(normalizedMessageId) .digest('hex'); return `bridge-${digest}`; From efbc3a0f9856fe019a81548817a5dd4e7a04cfa9 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Fri, 27 Mar 2026 15:02:12 +0100 Subject: [PATCH 49/81] fix(ui): gate channel bridges config on provider decision --- .../ChannelBridgesSettingsView.test.tsx | 55 +++++++++++++++++++ .../ChannelBridgesSettingsView.tsx | 21 +++++-- 2 files changed, 70 insertions(+), 6 deletions(-) create mode 100644 apps/ui/sources/components/settings/channelBridges/ChannelBridgesSettingsView.test.tsx diff --git a/apps/ui/sources/components/settings/channelBridges/ChannelBridgesSettingsView.test.tsx b/apps/ui/sources/components/settings/channelBridges/ChannelBridgesSettingsView.test.tsx new file mode 100644 index 0000000000..57b97cfa5a --- /dev/null +++ b/apps/ui/sources/components/settings/channelBridges/ChannelBridgesSettingsView.test.tsx @@ -0,0 +1,55 @@ +import * as React from 'react'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import type { FeatureDecision, FeatureId } from '@happier-dev/protocol'; +import { renderScreen, standardCleanup } from '@/dev/testkit'; +import { installSettingsViewCommonModuleMocks } from '../settingsViewTestHelpers'; + +(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; + +const useFeatureDecisionMock = vi.hoisted(() => vi.fn()); + +vi.mock('@/hooks/server/useFeatureDecision', () => ({ + useFeatureDecision: (featureId: FeatureId) => useFeatureDecisionMock(featureId), +})); + +installSettingsViewCommonModuleMocks(); + +function createEnabledDecision(featureId: FeatureId): FeatureDecision { + return { + featureId, + state: 'enabled', + blockedBy: null, + blockerCode: 'none', + diagnostics: [], + evaluatedAt: 0, + scope: { scopeKind: 'runtime' }, + }; +} + +afterEach(() => { + standardCleanup(); + useFeatureDecisionMock.mockReset(); +}); + +describe('ChannelBridgesSettingsView', () => { + it('does not render provider configuration before decisions resolve', async () => { + useFeatureDecisionMock.mockReturnValue(null); + const { ChannelBridgesSettingsView } = await import('./ChannelBridgesSettingsView'); + const screen = await renderScreen(React.createElement(ChannelBridgesSettingsView)); + + expect(screen.getTextContent()).not.toContain('happier bridge telegram set'); + }); + + it('renders provider configuration when channel bridges and Telegram are enabled', async () => { + useFeatureDecisionMock.mockImplementation((featureId: FeatureId) => { + if (featureId === 'channelBridges') return createEnabledDecision(featureId); + if (featureId === 'channelBridges.telegram') return createEnabledDecision(featureId); + return null; + }); + const { ChannelBridgesSettingsView } = await import('./ChannelBridgesSettingsView'); + const screen = await renderScreen(React.createElement(ChannelBridgesSettingsView)); + + expect(screen.getTextContent()).toContain('happier bridge telegram set'); + }); +}); diff --git a/apps/ui/sources/components/settings/channelBridges/ChannelBridgesSettingsView.tsx b/apps/ui/sources/components/settings/channelBridges/ChannelBridgesSettingsView.tsx index c4d43225ff..d54d62d9f8 100644 --- a/apps/ui/sources/components/settings/channelBridges/ChannelBridgesSettingsView.tsx +++ b/apps/ui/sources/components/settings/channelBridges/ChannelBridgesSettingsView.tsx @@ -18,10 +18,13 @@ import { t } from '@/text'; export const ChannelBridgesSettingsView = React.memo(function ChannelBridgesSettingsView() { const { theme } = useUnistyles(); const router = useRouter(); - const decision = useFeatureDecision('channelBridges'); + const channelBridgesDecision = useFeatureDecision('channelBridges'); + const telegramDecision = useFeatureDecision('channelBridges.telegram'); - const needsLocalEnablement = decision?.blockedBy === 'local_policy'; - const supported = decision?.state !== 'unsupported'; + const loading = channelBridgesDecision === null; + const needsLocalEnablement = channelBridgesDecision?.blockedBy === 'local_policy'; + const supported = channelBridgesDecision?.state !== 'unsupported'; + const telegramEnabled = telegramDecision?.state === 'enabled'; const configureTelegramCommand = React.useMemo(() => { return [ @@ -41,7 +44,13 @@ export const ChannelBridgesSettingsView = React.memo(function ChannelBridgesSett - {!supported ? ( + {loading ? ( + + + {t('common.loading')} + + + ) : !supported ? ( {t('settingsChannelBridges.unsupported')} @@ -63,7 +72,7 @@ export const ChannelBridgesSettingsView = React.memo(function ChannelBridgesSett )} - {supported ? ( + {supported && !needsLocalEnablement && telegramEnabled && !loading ? ( @@ -71,7 +80,7 @@ export const ChannelBridgesSettingsView = React.memo(function ChannelBridgesSett ) : null} - {supported ? : null} + {!loading && supported ? : null} ); From 9fdc9d0c5aa8fce7f48a62785628265e8b9be96f Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Fri, 27 Mar 2026 15:06:40 +0100 Subject: [PATCH 50/81] chore(ui): remove unused channel bridges translation key --- apps/ui/sources/text/translations/ca.ts | 1 - apps/ui/sources/text/translations/en.ts | 1 - apps/ui/sources/text/translations/es.ts | 1 - apps/ui/sources/text/translations/it.ts | 1 - apps/ui/sources/text/translations/ja.ts | 1 - apps/ui/sources/text/translations/pl.ts | 1 - apps/ui/sources/text/translations/pt.ts | 1 - apps/ui/sources/text/translations/ru.ts | 1 - apps/ui/sources/text/translations/zh-Hans.ts | 17 ++++++++--------- apps/ui/sources/text/translations/zh-Hant.ts | 17 ++++++++--------- 10 files changed, 16 insertions(+), 26 deletions(-) diff --git a/apps/ui/sources/text/translations/ca.ts b/apps/ui/sources/text/translations/ca.ts index 8de48a4f7b..8e276a0127 100644 --- a/apps/ui/sources/text/translations/ca.ts +++ b/apps/ui/sources/text/translations/ca.ts @@ -2431,7 +2431,6 @@ export const ca: TranslationStructure = { description: 'Channel bridges let you attach external chats (Telegram) to sessions and forward messages to the agent.', telegramTitle: 'Telegram', telegramFooter: 'Configure Telegram via CLI, then manage bindings in Telegram with /sessions, /attach, /detach, /help.', - diagnosticsTitle: 'Diagnostics', }, settingsFeatures: { diff --git a/apps/ui/sources/text/translations/en.ts b/apps/ui/sources/text/translations/en.ts index 30c6c45c6f..294272018f 100644 --- a/apps/ui/sources/text/translations/en.ts +++ b/apps/ui/sources/text/translations/en.ts @@ -2526,7 +2526,6 @@ export const en = { description: 'Channel bridges let you attach external chats (Telegram) to sessions and forward messages to the agent.', telegramTitle: 'Telegram', telegramFooter: 'Configure Telegram via CLI, then manage bindings in Telegram with /sessions, /attach, /detach, /help.', - diagnosticsTitle: 'Diagnostics', }, settingsFeatures: { diff --git a/apps/ui/sources/text/translations/es.ts b/apps/ui/sources/text/translations/es.ts index 617de99bd6..e817d44a01 100644 --- a/apps/ui/sources/text/translations/es.ts +++ b/apps/ui/sources/text/translations/es.ts @@ -2596,7 +2596,6 @@ export const es: TranslationStructure = { description: "Channel bridges let you attach external chats (Telegram) to sessions and forward messages to the agent.", telegramTitle: "Telegram", telegramFooter: "Configure Telegram via CLI, then manage bindings in Telegram with /sessions, /attach, /detach, /help.", - diagnosticsTitle: "Diagnostics", }, settingsFeatures: { diff --git a/apps/ui/sources/text/translations/it.ts b/apps/ui/sources/text/translations/it.ts index f8cf1d7993..9cdf89ae98 100644 --- a/apps/ui/sources/text/translations/it.ts +++ b/apps/ui/sources/text/translations/it.ts @@ -2935,7 +2935,6 @@ export const it: TranslationStructure = { description: "Channel bridges let you attach external chats (Telegram) to sessions and forward messages to the agent.", telegramTitle: "Telegram", telegramFooter: "Configure Telegram via CLI, then manage bindings in Telegram with /sessions, /attach, /detach, /help.", - diagnosticsTitle: "Diagnostics", }, settingsFeatures: { diff --git a/apps/ui/sources/text/translations/ja.ts b/apps/ui/sources/text/translations/ja.ts index 29c4419770..ebf5c73f34 100644 --- a/apps/ui/sources/text/translations/ja.ts +++ b/apps/ui/sources/text/translations/ja.ts @@ -2878,7 +2878,6 @@ export const ja: TranslationStructure = { description: "Channel bridges let you attach external chats (Telegram) to sessions and forward messages to the agent.", telegramTitle: "Telegram", telegramFooter: "Configure Telegram via CLI, then manage bindings in Telegram with /sessions, /attach, /detach, /help.", - diagnosticsTitle: "Diagnostics", }, settingsFeatures: { diff --git a/apps/ui/sources/text/translations/pl.ts b/apps/ui/sources/text/translations/pl.ts index 7b18532c90..87577ed29e 100644 --- a/apps/ui/sources/text/translations/pl.ts +++ b/apps/ui/sources/text/translations/pl.ts @@ -2629,7 +2629,6 @@ export const pl: TranslationStructure = { description: "Channel bridges let you attach external chats (Telegram) to sessions and forward messages to the agent.", telegramTitle: "Telegram", telegramFooter: "Configure Telegram via CLI, then manage bindings in Telegram with /sessions, /attach, /detach, /help.", - diagnosticsTitle: "Diagnostics", }, settingsFeatures: { diff --git a/apps/ui/sources/text/translations/pt.ts b/apps/ui/sources/text/translations/pt.ts index 3fcf46a8e9..6741015843 100644 --- a/apps/ui/sources/text/translations/pt.ts +++ b/apps/ui/sources/text/translations/pt.ts @@ -2821,7 +2821,6 @@ export const pt: TranslationStructure = { description: "Channel bridges let you attach external chats (Telegram) to sessions and forward messages to the agent.", telegramTitle: "Telegram", telegramFooter: "Configure Telegram via CLI, then manage bindings in Telegram with /sessions, /attach, /detach, /help.", - diagnosticsTitle: "Diagnostics", }, settingsFeatures: { diff --git a/apps/ui/sources/text/translations/ru.ts b/apps/ui/sources/text/translations/ru.ts index d15089483c..27dab68faa 100644 --- a/apps/ui/sources/text/translations/ru.ts +++ b/apps/ui/sources/text/translations/ru.ts @@ -2577,7 +2577,6 @@ export const ru: TranslationStructure = { description: "Channel bridges let you attach external chats (Telegram) to sessions and forward messages to the agent.", telegramTitle: "Telegram", telegramFooter: "Configure Telegram via CLI, then manage bindings in Telegram with /sessions, /attach, /detach, /help.", - diagnosticsTitle: "Diagnostics", }, settingsFeatures: { diff --git a/apps/ui/sources/text/translations/zh-Hans.ts b/apps/ui/sources/text/translations/zh-Hans.ts index 78c796af0c..eca9c9ad2c 100644 --- a/apps/ui/sources/text/translations/zh-Hans.ts +++ b/apps/ui/sources/text/translations/zh-Hans.ts @@ -2473,15 +2473,14 @@ export const zhHans: TranslationStructure = { }, }, - settingsChannelBridges: { - unsupported: "此环境不支持频道桥接。", - enableInFeatures: "启用频道桥接", - enableInFeaturesSubtitle: "频道桥接为实验功能,默认关闭。", - description: "频道桥接可将外部聊天(Telegram)附加到会话,并将消息转发给代理。", - telegramTitle: "Telegram", - telegramFooter: "请通过 CLI 配置 Telegram,然后在 Telegram 中使用 /sessions、/attach、/detach、/help 管理绑定。", - diagnosticsTitle: "诊断", - }, + settingsChannelBridges: { + unsupported: "此环境不支持频道桥接。", + enableInFeatures: "启用频道桥接", + enableInFeaturesSubtitle: "频道桥接为实验功能,默认关闭。", + description: "频道桥接可将外部聊天(Telegram)附加到会话,并将消息转发给代理。", + telegramTitle: "Telegram", + telegramFooter: "请通过 CLI 配置 Telegram,然后在 Telegram 中使用 /sessions、/attach、/detach、/help 管理绑定。", + }, settingsFeatures: { // Features settings screen diff --git a/apps/ui/sources/text/translations/zh-Hant.ts b/apps/ui/sources/text/translations/zh-Hant.ts index 88b1ed1916..d2946604f1 100644 --- a/apps/ui/sources/text/translations/zh-Hant.ts +++ b/apps/ui/sources/text/translations/zh-Hant.ts @@ -2231,15 +2231,14 @@ const zhHantOverrides: DeepPartial = { }, }, - settingsChannelBridges: { - unsupported: '此環境不支援頻道橋接。', - enableInFeatures: '啟用頻道橋接', - enableInFeaturesSubtitle: '頻道橋接為實驗功能,預設關閉。', - description: '頻道橋接可將外部聊天(Telegram)附加到工作階段,並將訊息轉發給代理。', - telegramTitle: 'Telegram', - telegramFooter: '請透過 CLI 設定 Telegram,然後在 Telegram 中使用 /sessions、/attach、/detach、/help 管理綁定。', - diagnosticsTitle: '診斷', - }, + settingsChannelBridges: { + unsupported: '此環境不支援頻道橋接。', + enableInFeatures: '啟用頻道橋接', + enableInFeaturesSubtitle: '頻道橋接為實驗功能,預設關閉。', + description: '頻道橋接可將外部聊天(Telegram)附加到工作階段,並將訊息轉發給代理。', + telegramTitle: 'Telegram', + telegramFooter: '請透過 CLI 設定 Telegram,然後在 Telegram 中使用 /sessions、/attach、/detach、/help 管理綁定。', + }, settingsFeatures: { // Features settings screen From 12397b16c4d9e8a0be24eadc512c83bbddb8e2f0 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Fri, 27 Mar 2026 15:27:49 +0100 Subject: [PATCH 51/81] fix(cli): reject empty bridge flag values --- apps/cli/src/cli/commands/bridge.test.ts | 44 ++++++++++++++++++++++++ apps/cli/src/cli/commands/bridge.ts | 33 ++++++++++++++++++ 2 files changed, 77 insertions(+) diff --git a/apps/cli/src/cli/commands/bridge.test.ts b/apps/cli/src/cli/commands/bridge.test.ts index 889d28663b..1676b65931 100644 --- a/apps/cli/src/cli/commands/bridge.test.ts +++ b/apps/cli/src/cli/commands/bridge.test.ts @@ -145,4 +145,48 @@ describe('happier bridge command (local-only v1)', () => { errorSpy.mockRestore(); } }); + + it('rejects explicitly passed empty --webhook-secret values', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + try { + const { handleBridgeCliCommand } = await import('./bridge'); + + await handleBridgeCliCommand({ + args: ['bridge', 'telegram', 'set', '--webhook-secret', ' '], + rawArgv: [], + terminalRuntime: null, + }); + + expect(updateSettingsMock).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + expect(errorSpy).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining('Invalid --webhook-secret value'), + ); + } finally { + errorSpy.mockRestore(); + } + }); + + it('rejects explicitly passed empty --allowed-chat-ids values', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + try { + const { handleBridgeCliCommand } = await import('./bridge'); + + await handleBridgeCliCommand({ + args: ['bridge', 'telegram', 'set', '--allowed-chat-ids', ' '], + rawArgv: [], + terminalRuntime: null, + }); + + expect(updateSettingsMock).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + expect(errorSpy).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining('Invalid --allowed-chat-ids value'), + ); + } finally { + errorSpy.mockRestore(); + } + }); }); diff --git a/apps/cli/src/cli/commands/bridge.ts b/apps/cli/src/cli/commands/bridge.ts index 9fae74e304..9e9915a93e 100644 --- a/apps/cli/src/cli/commands/bridge.ts +++ b/apps/cli/src/cli/commands/bridge.ts @@ -191,14 +191,47 @@ async function cmdTelegramSet(args: string[]): Promise { const hasBotTokenFlag = args.some((arg) => arg === '--bot-token' || arg.startsWith('--bot-token=')); const botToken = rawBotToken.trim(); const allowedChatIdsRaw = argvValue(args, '--allowed-chat-ids').trim(); + const hasAllowedChatIdsFlag = args.some((arg) => arg === '--allowed-chat-ids' || arg.startsWith('--allowed-chat-ids=')); const allowAllSharedChatsRaw = argvValue(args, '--allow-all-shared-chats').trim(); + const hasAllowAllSharedChatsFlag = args.some((arg) => arg === '--allow-all-shared-chats' || arg.startsWith('--allow-all-shared-chats=')); const allowAll = args.includes('--allow-all'); const requireTopicsRaw = argvValue(args, '--require-topics').trim(); + const hasRequireTopicsFlag = args.some((arg) => arg === '--require-topics' || arg.startsWith('--require-topics=')); const tickMsRaw = argvValue(args, '--tick-ms').trim(); + const hasTickMsFlag = args.some((arg) => arg === '--tick-ms' || arg.startsWith('--tick-ms=')); const webhookEnabledRaw = argvValue(args, '--webhook-enabled').trim(); + const hasWebhookEnabledFlag = args.some((arg) => arg === '--webhook-enabled' || arg.startsWith('--webhook-enabled=')); const webhookSecret = argvValue(args, '--webhook-secret').trim(); + const hasWebhookSecretFlag = args.some((arg) => arg === '--webhook-secret' || arg.startsWith('--webhook-secret=')); const webhookHost = argvValue(args, '--webhook-host').trim(); + const hasWebhookHostFlag = args.some((arg) => arg === '--webhook-host' || arg.startsWith('--webhook-host=')); const webhookPortRaw = argvValue(args, '--webhook-port').trim(); + const hasWebhookPortFlag = args.some((arg) => arg === '--webhook-port' || arg.startsWith('--webhook-port=')); + + if (hasAllowedChatIdsFlag && !allowedChatIdsRaw) { + throw new Error('Invalid --allowed-chat-ids value: cannot be empty'); + } + if (hasAllowAllSharedChatsFlag && !allowAllSharedChatsRaw) { + throw new Error('Invalid --allow-all-shared-chats value: cannot be empty'); + } + if (hasRequireTopicsFlag && !requireTopicsRaw) { + throw new Error('Invalid --require-topics value: cannot be empty'); + } + if (hasTickMsFlag && !tickMsRaw) { + throw new Error('Invalid --tick-ms value: cannot be empty'); + } + if (hasWebhookEnabledFlag && !webhookEnabledRaw) { + throw new Error('Invalid --webhook-enabled value: cannot be empty'); + } + if (hasWebhookSecretFlag && !webhookSecret) { + throw new Error('Invalid --webhook-secret value: cannot be empty'); + } + if (hasWebhookHostFlag && !webhookHost) { + throw new Error('Invalid --webhook-host value: cannot be empty'); + } + if (hasWebhookPortFlag && !webhookPortRaw) { + throw new Error('Invalid --webhook-port value: cannot be empty'); + } if (allowAll && allowedChatIdsRaw) { throw new Error('Cannot combine --allow-all with --allowed-chat-ids'); From 3fd6e58abc006d020e6e4c077e514d1dcd4fd74a Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Fri, 27 Mar 2026 15:27:54 +0100 Subject: [PATCH 52/81] fix(cli): validate webhook secret when persisting bridge config --- .../channels/channelBridgeAccountConfig.test.ts | 13 +++++++++++++ .../src/channels/channelBridgeAccountConfig.ts | 15 ++++++++++++++- 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/apps/cli/src/channels/channelBridgeAccountConfig.test.ts b/apps/cli/src/channels/channelBridgeAccountConfig.test.ts index c60941c4af..a30d057411 100644 --- a/apps/cli/src/channels/channelBridgeAccountConfig.test.ts +++ b/apps/cli/src/channels/channelBridgeAccountConfig.test.ts @@ -9,6 +9,19 @@ import { } from './channelBridgeAccountConfig'; describe('channelBridgeAccountConfig', () => { + it('rejects webhook secrets that do not match Telegram-safe token charset', () => { + expect(() => + upsertScopedTelegramBridgeConfig({ + settings: {}, + serverId: 'local-3005', + accountId: 'acct-1', + update: { + webhookSecret: 'bad token!', + }, + }) + ).toThrow('Invalid webhookSecret: must match [A-Za-z0-9_-]'); + }); + it('writes scoped telegram config under server/account with secrets in local-only block', () => { const next = upsertScopedTelegramBridgeConfig({ settings: {}, diff --git a/apps/cli/src/channels/channelBridgeAccountConfig.ts b/apps/cli/src/channels/channelBridgeAccountConfig.ts index fa45bd2ce3..3e94aa43ed 100644 --- a/apps/cli/src/channels/channelBridgeAccountConfig.ts +++ b/apps/cli/src/channels/channelBridgeAccountConfig.ts @@ -43,6 +43,19 @@ function parseStringArray(value: unknown): string[] { .filter((entry) => entry.length > 0); } +const TELEGRAM_WEBHOOK_SECRET_TOKEN_PATTERN = /^[A-Za-z0-9_-]+$/; + +function assertTelegramWebhookSecretToken(value: string): string { + const token = value.trim(); + if (!token) { + throw new Error('Invalid webhookSecret: cannot be empty'); + } + if (!TELEGRAM_WEBHOOK_SECRET_TOKEN_PATTERN.test(token)) { + throw new Error('Invalid webhookSecret: must match [A-Za-z0-9_-]'); + } + return token; +} + export type ScopedTelegramBridgeUpdate = Readonly<{ tickMs?: number; botToken?: string; @@ -302,7 +315,7 @@ export function upsertScopedTelegramBridgeConfig(params: Readonly<{ } } if (typeof params.update.webhookSecret === 'string') { - secrets.webhookSecret = params.update.webhookSecret; + secrets.webhookSecret = assertTelegramWebhookSecretToken(params.update.webhookSecret); const webhook = asRecord(telegram.webhook); if (webhook && 'secret' in webhook) { delete webhook.secret; From 67bbecb556d5215aca5cd83c51f94522b1197152 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Fri, 27 Mar 2026 15:28:59 +0100 Subject: [PATCH 53/81] test(e2e): cover channel bridges telegram env gate --- ....featureGates.feat.channelBridges.e2e.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/packages/tests/suites/core-e2e/channelBridges.foundation.featureGates.feat.channelBridges.e2e.test.ts b/packages/tests/suites/core-e2e/channelBridges.foundation.featureGates.feat.channelBridges.e2e.test.ts index ffa2ef2a12..31bb82480e 100644 --- a/packages/tests/suites/core-e2e/channelBridges.foundation.featureGates.feat.channelBridges.e2e.test.ts +++ b/packages/tests/suites/core-e2e/channelBridges.foundation.featureGates.feat.channelBridges.e2e.test.ts @@ -30,6 +30,22 @@ describe('core e2e: channel bridges foundation', () => { expect(features.data?.features?.channelBridges?.telegram?.enabled).toBe(false); }, 180_000); + it('advertises telegram gate as disabled when the server env gate is disabled', async () => { + const testDir = run.testDir('feature-negotiation-channel-bridges-telegram-disabled'); + server = await startServerLight({ + testDir, + dbProvider: 'sqlite', + extraEnv: { + HAPPIER_FEATURE_CHANNEL_BRIDGES_TELEGRAM__ENABLED: '0', + }, + }); + + const features = await fetchJson(`${server.baseUrl}/v1/features`); + expect(features.status).toBe(200); + expect(features.data?.features?.channelBridges?.enabled).toBe(true); + expect(features.data?.features?.channelBridges?.telegram?.enabled).toBe(false); + }, 180_000); + it('advertises channel bridge gates as enabled by default', async () => { const testDir = run.testDir('feature-negotiation-channel-bridges-enabled'); server = await startServerLight({ testDir, dbProvider: 'sqlite' }); From c18d4c439dc3acbeeda475607e0f6537c3518bc0 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Fri, 27 Mar 2026 15:35:15 +0100 Subject: [PATCH 54/81] test(ui): harden channel bridges settings view states --- .../ChannelBridgesSettingsView.test.tsx | 133 ++++++++++++++++-- .../ChannelBridgesSettingsView.tsx | 7 +- 2 files changed, 125 insertions(+), 15 deletions(-) diff --git a/apps/ui/sources/components/settings/channelBridges/ChannelBridgesSettingsView.test.tsx b/apps/ui/sources/components/settings/channelBridges/ChannelBridgesSettingsView.test.tsx index 57b97cfa5a..1feabe9ae6 100644 --- a/apps/ui/sources/components/settings/channelBridges/ChannelBridgesSettingsView.test.tsx +++ b/apps/ui/sources/components/settings/channelBridges/ChannelBridgesSettingsView.test.tsx @@ -1,7 +1,7 @@ import * as React from 'react'; import { afterEach, describe, expect, it, vi } from 'vitest'; -import type { FeatureDecision, FeatureId } from '@happier-dev/protocol'; +import type { FeatureAxis, FeatureBlockerCode, FeatureDecision, FeatureId, FeatureState } from '@happier-dev/protocol'; import { renderScreen, standardCleanup } from '@/dev/testkit'; import { installSettingsViewCommonModuleMocks } from '../settingsViewTestHelpers'; @@ -13,14 +13,26 @@ vi.mock('@/hooks/server/useFeatureDecision', () => ({ useFeatureDecision: (featureId: FeatureId) => useFeatureDecisionMock(featureId), })); -installSettingsViewCommonModuleMocks(); +installSettingsViewCommonModuleMocks({ + text: async () => { + const { createTextModuleMock } = await import('@/dev/testkit/mocks/text'); + return createTextModuleMock({ + translate: (key) => key, + }); + }, +}); -function createEnabledDecision(featureId: FeatureId): FeatureDecision { +function createDecision(params: Readonly<{ + featureId: FeatureId; + state: FeatureState; + blockedBy: FeatureAxis | null; + blockerCode: FeatureBlockerCode; +}>): FeatureDecision { return { - featureId, - state: 'enabled', - blockedBy: null, - blockerCode: 'none', + featureId: params.featureId, + state: params.state, + blockedBy: params.blockedBy, + blockerCode: params.blockerCode, diagnostics: [], evaluatedAt: 0, scope: { scopeKind: 'runtime' }, @@ -33,23 +45,120 @@ afterEach(() => { }); describe('ChannelBridgesSettingsView', () => { - it('does not render provider configuration before decisions resolve', async () => { + it('shows a loading state before decisions resolve', async () => { useFeatureDecisionMock.mockReturnValue(null); const { ChannelBridgesSettingsView } = await import('./ChannelBridgesSettingsView'); const screen = await renderScreen(React.createElement(ChannelBridgesSettingsView)); - expect(screen.getTextContent()).not.toContain('happier bridge telegram set'); + expect(screen.findByTestId('settings-channel-bridges-loading')).toBeTruthy(); + expect(screen.findByTestId('settings-channel-bridges-telegram-config')).toBeNull(); + expect(screen.findByTestId('settings-channel-bridges-enable-in-features')).toBeNull(); + }); + + it('shows unsupported state when server does not support channel bridges', async () => { + useFeatureDecisionMock.mockImplementation((featureId: FeatureId) => { + if (featureId === 'channelBridges') { + return createDecision({ + featureId, + state: 'unsupported', + blockedBy: 'server', + blockerCode: 'not_implemented', + }); + } + if (featureId === 'channelBridges.telegram') { + return createDecision({ + featureId, + state: 'unsupported', + blockedBy: 'server', + blockerCode: 'not_implemented', + }); + } + return null; + }); + const { ChannelBridgesSettingsView } = await import('./ChannelBridgesSettingsView'); + const screen = await renderScreen(React.createElement(ChannelBridgesSettingsView)); + + expect(screen.findByTestId('settings-channel-bridges-unsupported')).toBeTruthy(); + expect(screen.findByTestId('settings-channel-bridges-telegram-config')).toBeNull(); + expect(screen.findByTestId('settings-channel-bridges-enable-in-features')).toBeNull(); + }); + + it('shows enablement call-to-action when blocked by local policy', async () => { + useFeatureDecisionMock.mockImplementation((featureId: FeatureId) => { + if (featureId === 'channelBridges') { + return createDecision({ + featureId, + state: 'disabled', + blockedBy: 'local_policy', + blockerCode: 'flag_disabled', + }); + } + if (featureId === 'channelBridges.telegram') { + return createDecision({ + featureId, + state: 'disabled', + blockedBy: 'dependency', + blockerCode: 'dependency_disabled', + }); + } + return null; + }); + const { ChannelBridgesSettingsView } = await import('./ChannelBridgesSettingsView'); + const screen = await renderScreen(React.createElement(ChannelBridgesSettingsView)); + + expect(screen.findByTestId('settings-channel-bridges-enable-in-features')).toBeTruthy(); + expect(screen.findByTestId('settings-channel-bridges-telegram-config')).toBeNull(); + }); + + it('does not render provider configuration when telegram is disabled', async () => { + useFeatureDecisionMock.mockImplementation((featureId: FeatureId) => { + if (featureId === 'channelBridges') { + return createDecision({ + featureId, + state: 'enabled', + blockedBy: null, + blockerCode: 'none', + }); + } + if (featureId === 'channelBridges.telegram') { + return createDecision({ + featureId, + state: 'disabled', + blockedBy: 'server', + blockerCode: 'feature_disabled', + }); + } + return null; + }); + const { ChannelBridgesSettingsView } = await import('./ChannelBridgesSettingsView'); + const screen = await renderScreen(React.createElement(ChannelBridgesSettingsView)); + + expect(screen.findByTestId('settings-channel-bridges-telegram-config')).toBeNull(); }); it('renders provider configuration when channel bridges and Telegram are enabled', async () => { useFeatureDecisionMock.mockImplementation((featureId: FeatureId) => { - if (featureId === 'channelBridges') return createEnabledDecision(featureId); - if (featureId === 'channelBridges.telegram') return createEnabledDecision(featureId); + if (featureId === 'channelBridges') { + return createDecision({ + featureId, + state: 'enabled', + blockedBy: null, + blockerCode: 'none', + }); + } + if (featureId === 'channelBridges.telegram') { + return createDecision({ + featureId, + state: 'enabled', + blockedBy: null, + blockerCode: 'none', + }); + } return null; }); const { ChannelBridgesSettingsView } = await import('./ChannelBridgesSettingsView'); const screen = await renderScreen(React.createElement(ChannelBridgesSettingsView)); - expect(screen.getTextContent()).toContain('happier bridge telegram set'); + expect(screen.findByTestId('settings-channel-bridges-telegram-config')).toBeTruthy(); }); }); diff --git a/apps/ui/sources/components/settings/channelBridges/ChannelBridgesSettingsView.tsx b/apps/ui/sources/components/settings/channelBridges/ChannelBridgesSettingsView.tsx index d54d62d9f8..69f913b7f0 100644 --- a/apps/ui/sources/components/settings/channelBridges/ChannelBridgesSettingsView.tsx +++ b/apps/ui/sources/components/settings/channelBridges/ChannelBridgesSettingsView.tsx @@ -45,19 +45,20 @@ export const ChannelBridgesSettingsView = React.memo(function ChannelBridgesSett {loading ? ( - + {t('common.loading')} ) : !supported ? ( - + {t('settingsChannelBridges.unsupported')} ) : needsLocalEnablement ? ( } @@ -74,7 +75,7 @@ export const ChannelBridgesSettingsView = React.memo(function ChannelBridgesSett {supported && !needsLocalEnablement && telegramEnabled && !loading ? ( - + From 768a4d5a685b5663565a775275e7994d623db366 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Fri, 27 Mar 2026 15:43:10 +0100 Subject: [PATCH 55/81] docs(channel-bridges): align v1 config and UAT guidance --- docs/channel-bridge-uat.md | 12 ++++++------ docs/telegram-channel-bridge.md | 32 +++++++++++++------------------- 2 files changed, 19 insertions(+), 25 deletions(-) diff --git a/docs/channel-bridge-uat.md b/docs/channel-bridge-uat.md index 19a8f57a8b..8b1221c709 100644 --- a/docs/channel-bridge-uat.md +++ b/docs/channel-bridge-uat.md @@ -39,7 +39,7 @@ happier bridge list ``` Expected: -- `Telegram (server KV)` shows `configured: yes` +- `Telegram (scoped settings.json)` shows `configured: yes` - `Telegram (effective runtime...)` shows configured token/topic policy ## 4) Restart daemon and verify bridge worker startup @@ -54,7 +54,7 @@ Expected: - No crash loop in daemon logs. - Final diagnosis line matches overall health: - `✅ Doctor diagnosis complete!` when no critical failures - - `❌ Doctor diagnosis complete!` when any critical failure exists (for example, Telegram bridge configured but bot token missing, or webhook enabled with missing secret/host/port) + - `❌ Doctor diagnosis complete!` when any critical failure exists (for example, Telegram bridge configured but bot token missing) ## 5) Session bind + bi-direction test from Telegram @@ -75,6 +75,7 @@ Expected: Expected: - Same account id. - Existing sessions are visible/resumable (subject to agent backend credentials). +- Bridge config + bindings are local-only in v1: configure the bridge on the second machine too (or copy scoped `settings.json`), and attach the Telegram conversation again via `/attach`. ## 7) Isolation check (optional second account) @@ -95,7 +96,6 @@ Expected: ## Notes -- `allowedChatIds: []` means allow all chats/topics the bot can read. -- Runtime precedence is: `HAPPIER_* env` > `server KV` > `settings.json` > defaults. -- Secrets (bot/API tokens) stay local-only (`settings.json`/env), never server KV. -- For full deployment, server KV is DB-backed and shared across machines in the same account scope. +- `allowedChatIds: []` means **DM-only** (shared chats are blocked by default unless `allowAllSharedChats=true`). +- Runtime precedence is: `HAPPIER_* env` > `settings.json` > defaults. +- Secrets (bot/API tokens) stay local-only (`settings.json`/env). diff --git a/docs/telegram-channel-bridge.md b/docs/telegram-channel-bridge.md index 0dc75ba2ef..76154a28c9 100644 --- a/docs/telegram-channel-bridge.md +++ b/docs/telegram-channel-bridge.md @@ -3,7 +3,7 @@ This repository now includes a **built-in channel bridge core** plus a **Telegram adapter**. - Core worker: `apps/cli/src/channels/core/channelBridgeWorker.ts` -- Telegram adapter: `apps/cli/src/channels/telegram/telegramAdapter.ts` +- Telegram adapter: `apps/cli/src/channels/providers/telegram/telegramAdapter.ts` - Runtime wiring: `apps/cli/src/channels/startChannelBridgeWorker.ts` The design is intentionally modular so additional adapters (Discord, Slack, WhatsApp, etc.) can plug into the same core contract. @@ -71,22 +71,22 @@ Backward compatibility is preserved: root-level `channelBridge.tickMs` and `chan Precedence is: 1. `HAPPIER_*` environment variables -2. Server KV bridge config (account-authenticated, DB-backed in full deployment, **non-secret only**) -3. `settings.json` bridge config (local scoped fallback, includes secrets) -4. built-in defaults +2. `settings.json` bridge config (local, scoped by `serverId` + `accountId`, includes secrets) +3. built-in defaults `allowedChatIds` behavior: -- `[]` (empty list) = allow all chats/topics the bot can read. -- non-empty list = allow only those chat IDs. +- `[]` (empty list) = **DM-only** (shared chats are blocked by default). +- non-empty list = allow only those shared chat IDs. +- to allow *all* shared chats (unsafe), set `allowAllSharedChats=true` (CLI: `--allow-all` or `--allow-all-shared-chats true`). ## Secret Handling Policy (for all adapters) -- **Never sync secrets to server KV**. +- In v1, bridge configuration is **local-only** (settings/env). - Store secrets only in: - local scoped `settings.json` (`providers..secrets.*`), or - process env vars (`HAPPIER_*`). -- Server KV is for non-secret shareable bridge config/state only. +- Do not assume any server-side shared bridge config exists (even for non-secret fields). For new adapters (Discord/Slack/WhatsApp/etc), use the same model: @@ -124,10 +124,9 @@ HAPPIER_CHANNEL_BRIDGE_TICK_MS=2500 Use the bridge CLI to manage Telegram config for the active `serverId + accountId` scope: - `telegram set` - - writes non-secret fields to server KV - writes full config (including secrets) to local scoped `settings.json` -- `telegram clear` clears server KV + local scoped `settings.json` -- `bridge list` prints scoped local config, server KV config, and effective runtime resolution +- `telegram clear` clears local scoped `settings.json` +- `bridge list` prints scoped local config and effective runtime resolution ```bash happier bridge list @@ -138,7 +137,7 @@ happier bridge telegram set \ --require-topics true \ --tick-ms 2500 -# optional webhook relay configuration +# webhook relay configuration (polling is default) happier bridge telegram set \ --webhook-enabled true \ --webhook-secret \ @@ -159,7 +158,7 @@ happier daemon stop && happier daemon start ## Doctor Status Aggregation -`happier doctor` now aggregates critical failures and sets the final diagnosis line accordingly: +`happier doctor` aggregates critical failures and sets the final diagnosis line accordingly: - final line is `✅ Doctor diagnosis complete!` only when no critical failures are found - final line is `❌ Doctor diagnosis complete!` if any critical failure is detected @@ -167,12 +166,7 @@ happier daemon stop && happier daemon start Telegram bridge example: - if Telegram bridge is configured but bot token is missing, doctor prints a red bridge error and the final line is `❌` -- if `webhook.enabled=true` and `secrets.webhookSecret` (or `HAPPIER_TELEGRAM_WEBHOOK_SECRET`) is empty, doctor treats it as critical and final line is `❌` -- if `webhook.enabled=true` and `webhook.host` or `webhook.port` is missing/invalid, doctor treats it as critical and final line is `❌` - -Generic adapter behavior: - -- doctor applies the same webhook-required checks to any channel adapter that configures `webhook.enabled=true` +- if `webhook.enabled=true` and `secrets.webhookSecret` (or `HAPPIER_TELEGRAM_WEBHOOK_SECRET`) is empty, doctor prints a warning and the daemon will fall back to polling ## Webhook Setup (daemon relay mode) From 9233d0c72394d102ccc37d19065cdedb343ce612 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Fri, 27 Mar 2026 15:44:29 +0100 Subject: [PATCH 56/81] chore: drop yarn.lock churn --- yarn.lock | 3715 +++++++++++++++++++++++++++++++++-------------------- 1 file changed, 2324 insertions(+), 1391 deletions(-) diff --git a/yarn.lock b/yarn.lock index 6409334c35..51ed2611dd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -98,6 +98,13 @@ "@csstools/css-tokenizer" "^3.0.3" lru-cache "^10.4.3" +"@babel/code-frame@7.10.4", "@babel/code-frame@~7.10.4": + version "7.10.4" + resolved "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz" + integrity sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg== + dependencies: + "@babel/highlight" "^7.10.4" + "@babel/code-frame@^7.12.13", "@babel/code-frame@^7.20.0", "@babel/code-frame@^7.24.7", "@babel/code-frame@^7.28.6": version "7.28.6" resolved "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.28.6.tgz" @@ -107,26 +114,12 @@ js-tokens "^4.0.0" picocolors "^1.1.1" -"@babel/code-frame@~7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz" - integrity sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg== - dependencies: - "@babel/highlight" "^7.10.4" - -"@babel/code-frame@7.10.4": - version "7.10.4" - resolved "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.10.4.tgz" - integrity sha512-vG6SvB6oYEhvgisZNFRmRCUkLz11c7rp+tbNTynGqc6mS1d5ATd/sGyV6W0KZZnXRKMTzZDRgQT3Ou9jhpAfUg== - dependencies: - "@babel/highlight" "^7.10.4" - "@babel/compat-data@^7.28.6": version "7.28.6" resolved "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.28.6.tgz" integrity sha512-2lfu57JtzctfIrcGMz992hyLlByuzgIk58+hhGCxjKZ3rWI82NnVLjXcaTqkI2NvlcvOskZaiZ5kjUALo3Lpxg== -"@babel/core@*", "@babel/core@^7.0.0", "@babel/core@^7.0.0 || ^8.0.0-0", "@babel/core@^7.0.0-0", "@babel/core@^7.11.6", "@babel/core@^7.12.0", "@babel/core@^7.12.3", "@babel/core@^7.20.0", "@babel/core@^7.25.2", "@babel/core@^7.4.0 || ^8.0.0-0 <8.0.0", "@babel/core@^7.8.0": +"@babel/core@^7.11.6", "@babel/core@^7.12.3", "@babel/core@^7.20.0", "@babel/core@^7.25.2": version "7.28.6" resolved "https://registry.npmjs.org/@babel/core/-/core-7.28.6.tgz" integrity sha512-H3mcG6ZDLTlYfaSNi0iOKkigqMFvkTKlGUYlD8GW7nNOYRrevuA46iTypPyv+06V3fEmvvazfntkBU34L0azAw== @@ -489,7 +482,7 @@ dependencies: "@babel/helper-plugin-utils" "^7.28.6" -"@babel/plugin-transform-arrow-functions@^7.24.7", "@babel/plugin-transform-arrow-functions@7.27.1": +"@babel/plugin-transform-arrow-functions@7.27.1", "@babel/plugin-transform-arrow-functions@^7.24.7": version "7.27.1" resolved "https://registry.npmjs.org/@babel/plugin-transform-arrow-functions/-/plugin-transform-arrow-functions-7.27.1.tgz" integrity sha512-8Z4TGic6xW70FKThA5HYEKKyBpOOsucTOD1DjU3fZxDg+K3zBJcXMFnt/4yQiZnf5+MiOMSXQ9PaEK/Ilh1DeA== @@ -521,14 +514,6 @@ dependencies: "@babel/helper-plugin-utils" "^7.28.6" -"@babel/plugin-transform-class-properties@^7.25.4": - version "7.28.6" - resolved "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.28.6.tgz" - integrity sha512-dY2wS3I2G7D697VHndN91TJr8/AAfXQNt5ynCTI/MpxMsSzHp+52uNivYT5wCPax3whc47DR8Ba7cmlQMg24bw== - dependencies: - "@babel/helper-create-class-features-plugin" "^7.28.6" - "@babel/helper-plugin-utils" "^7.28.6" - "@babel/plugin-transform-class-properties@7.27.1": version "7.27.1" resolved "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.27.1.tgz" @@ -537,25 +522,21 @@ "@babel/helper-create-class-features-plugin" "^7.27.1" "@babel/helper-plugin-utils" "^7.27.1" -"@babel/plugin-transform-class-static-block@^7.27.1": +"@babel/plugin-transform-class-properties@^7.25.4": version "7.28.6" - resolved "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.28.6.tgz" - integrity sha512-rfQ++ghVwTWTqQ7w8qyDxL1XGihjBss4CmTgGRCTAC9RIbhVpyp4fOeZtta0Lbf+dTNIVJer6ych2ibHwkZqsQ== + resolved "https://registry.npmjs.org/@babel/plugin-transform-class-properties/-/plugin-transform-class-properties-7.28.6.tgz" + integrity sha512-dY2wS3I2G7D697VHndN91TJr8/AAfXQNt5ynCTI/MpxMsSzHp+52uNivYT5wCPax3whc47DR8Ba7cmlQMg24bw== dependencies: "@babel/helper-create-class-features-plugin" "^7.28.6" "@babel/helper-plugin-utils" "^7.28.6" -"@babel/plugin-transform-classes@^7.25.4": +"@babel/plugin-transform-class-static-block@^7.27.1": version "7.28.6" - resolved "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.6.tgz" - integrity sha512-EF5KONAqC5zAqT783iMGuM2ZtmEBy+mJMOKl2BCvPZ2lVrwvXnB6o+OBWCS+CoeCCpVRF2sA2RBKUxvT8tQT5Q== + resolved "https://registry.npmjs.org/@babel/plugin-transform-class-static-block/-/plugin-transform-class-static-block-7.28.6.tgz" + integrity sha512-rfQ++ghVwTWTqQ7w8qyDxL1XGihjBss4CmTgGRCTAC9RIbhVpyp4fOeZtta0Lbf+dTNIVJer6ych2ibHwkZqsQ== dependencies: - "@babel/helper-annotate-as-pure" "^7.27.3" - "@babel/helper-compilation-targets" "^7.28.6" - "@babel/helper-globals" "^7.28.0" + "@babel/helper-create-class-features-plugin" "^7.28.6" "@babel/helper-plugin-utils" "^7.28.6" - "@babel/helper-replace-supers" "^7.28.6" - "@babel/traverse" "^7.28.6" "@babel/plugin-transform-classes@7.28.4": version "7.28.4" @@ -569,6 +550,18 @@ "@babel/helper-replace-supers" "^7.27.1" "@babel/traverse" "^7.28.4" +"@babel/plugin-transform-classes@^7.25.4": + version "7.28.6" + resolved "https://registry.npmjs.org/@babel/plugin-transform-classes/-/plugin-transform-classes-7.28.6.tgz" + integrity sha512-EF5KONAqC5zAqT783iMGuM2ZtmEBy+mJMOKl2BCvPZ2lVrwvXnB6o+OBWCS+CoeCCpVRF2sA2RBKUxvT8tQT5Q== + dependencies: + "@babel/helper-annotate-as-pure" "^7.27.3" + "@babel/helper-compilation-targets" "^7.28.6" + "@babel/helper-globals" "^7.28.0" + "@babel/helper-plugin-utils" "^7.28.6" + "@babel/helper-replace-supers" "^7.28.6" + "@babel/traverse" "^7.28.6" + "@babel/plugin-transform-computed-properties@^7.24.7": version "7.28.6" resolved "https://registry.npmjs.org/@babel/plugin-transform-computed-properties/-/plugin-transform-computed-properties-7.28.6.tgz" @@ -647,13 +640,6 @@ "@babel/helper-create-regexp-features-plugin" "^7.27.1" "@babel/helper-plugin-utils" "^7.27.1" -"@babel/plugin-transform-nullish-coalescing-operator@^7.24.7": - version "7.28.6" - resolved "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.28.6.tgz" - integrity sha512-3wKbRgmzYbw24mDJXT7N+ADXw8BC/imU9yo9c9X9NKaLF1fW+e5H1U5QjMUBe4Qo4Ox/o++IyUkl1sVCLgevKg== - dependencies: - "@babel/helper-plugin-utils" "^7.28.6" - "@babel/plugin-transform-nullish-coalescing-operator@7.27.1": version "7.27.1" resolved "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.27.1.tgz" @@ -661,6 +647,13 @@ dependencies: "@babel/helper-plugin-utils" "^7.27.1" +"@babel/plugin-transform-nullish-coalescing-operator@^7.24.7": + version "7.28.6" + resolved "https://registry.npmjs.org/@babel/plugin-transform-nullish-coalescing-operator/-/plugin-transform-nullish-coalescing-operator-7.28.6.tgz" + integrity sha512-3wKbRgmzYbw24mDJXT7N+ADXw8BC/imU9yo9c9X9NKaLF1fW+e5H1U5QjMUBe4Qo4Ox/o++IyUkl1sVCLgevKg== + dependencies: + "@babel/helper-plugin-utils" "^7.28.6" + "@babel/plugin-transform-numeric-separator@^7.24.7": version "7.28.6" resolved "https://registry.npmjs.org/@babel/plugin-transform-numeric-separator/-/plugin-transform-numeric-separator-7.28.6.tgz" @@ -686,14 +679,6 @@ dependencies: "@babel/helper-plugin-utils" "^7.28.6" -"@babel/plugin-transform-optional-chaining@^7.24.8": - version "7.28.6" - resolved "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.28.6.tgz" - integrity sha512-A4zobikRGJTsX9uqVFdafzGkqD30t26ck2LmOzAuLL8b2x6k3TIqRiT2xVvA9fNmFeTX484VpsdgmKNA0bS23w== - dependencies: - "@babel/helper-plugin-utils" "^7.28.6" - "@babel/helper-skip-transparent-expression-wrappers" "^7.27.1" - "@babel/plugin-transform-optional-chaining@7.27.1": version "7.27.1" resolved "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.27.1.tgz" @@ -702,6 +687,14 @@ "@babel/helper-plugin-utils" "^7.27.1" "@babel/helper-skip-transparent-expression-wrappers" "^7.27.1" +"@babel/plugin-transform-optional-chaining@^7.24.8": + version "7.28.6" + resolved "https://registry.npmjs.org/@babel/plugin-transform-optional-chaining/-/plugin-transform-optional-chaining-7.28.6.tgz" + integrity sha512-A4zobikRGJTsX9uqVFdafzGkqD30t26ck2LmOzAuLL8b2x6k3TIqRiT2xVvA9fNmFeTX484VpsdgmKNA0bS23w== + dependencies: + "@babel/helper-plugin-utils" "^7.28.6" + "@babel/helper-skip-transparent-expression-wrappers" "^7.27.1" + "@babel/plugin-transform-parameters@^7.24.7", "@babel/plugin-transform-parameters@^7.27.7": version "7.27.7" resolved "https://registry.npmjs.org/@babel/plugin-transform-parameters/-/plugin-transform-parameters-7.27.7.tgz" @@ -792,7 +785,7 @@ babel-plugin-polyfill-regenerator "^0.6.5" semver "^6.3.1" -"@babel/plugin-transform-shorthand-properties@^7.24.7", "@babel/plugin-transform-shorthand-properties@7.27.1": +"@babel/plugin-transform-shorthand-properties@7.27.1", "@babel/plugin-transform-shorthand-properties@^7.24.7": version "7.27.1" resolved "https://registry.npmjs.org/@babel/plugin-transform-shorthand-properties/-/plugin-transform-shorthand-properties-7.27.1.tgz" integrity sha512-N/wH1vcn4oYawbJ13Y/FxcQrWk63jhfNa7jef0ih7PHSIHX2LB7GWE1rkPrOnka9kwMxb6hMl19p7lidA+EHmQ== @@ -832,7 +825,7 @@ "@babel/helper-skip-transparent-expression-wrappers" "^7.27.1" "@babel/plugin-syntax-typescript" "^7.28.6" -"@babel/plugin-transform-unicode-regex@^7.24.7", "@babel/plugin-transform-unicode-regex@7.27.1": +"@babel/plugin-transform-unicode-regex@7.27.1", "@babel/plugin-transform-unicode-regex@^7.24.7": version "7.27.1" resolved "https://registry.npmjs.org/@babel/plugin-transform-unicode-regex/-/plugin-transform-unicode-regex-7.27.1.tgz" integrity sha512-xvINq24TRojDuyt6JGtHmkVkrfVV3FPT16uytxImLeBZqW3/H52yN+kM1MGuyPkIQxrzKwPHs5U/MP3qKyzkGw== @@ -852,29 +845,29 @@ "@babel/plugin-transform-react-jsx-development" "^7.27.1" "@babel/plugin-transform-react-pure-annotations" "^7.27.1" -"@babel/preset-typescript@^7.23.0", "@babel/preset-typescript@^7.26.0": - version "7.28.5" - resolved "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.28.5.tgz" - integrity sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g== +"@babel/preset-typescript@7.27.1": + version "7.27.1" + resolved "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.27.1.tgz" + integrity sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ== dependencies: "@babel/helper-plugin-utils" "^7.27.1" "@babel/helper-validator-option" "^7.27.1" "@babel/plugin-syntax-jsx" "^7.27.1" "@babel/plugin-transform-modules-commonjs" "^7.27.1" - "@babel/plugin-transform-typescript" "^7.28.5" + "@babel/plugin-transform-typescript" "^7.27.1" -"@babel/preset-typescript@7.27.1": - version "7.27.1" - resolved "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.27.1.tgz" - integrity sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ== +"@babel/preset-typescript@^7.23.0", "@babel/preset-typescript@^7.26.0": + version "7.28.5" + resolved "https://registry.npmjs.org/@babel/preset-typescript/-/preset-typescript-7.28.5.tgz" + integrity sha512-+bQy5WOI2V6LJZpPVxY+yp66XdZ2yifu0Mc1aP5CQKgjn4QM5IN2i5fAZ4xKop47pr8rpVhiAeu+nDQa12C8+g== dependencies: "@babel/helper-plugin-utils" "^7.27.1" "@babel/helper-validator-option" "^7.27.1" "@babel/plugin-syntax-jsx" "^7.27.1" "@babel/plugin-transform-modules-commonjs" "^7.27.1" - "@babel/plugin-transform-typescript" "^7.27.1" + "@babel/plugin-transform-typescript" "^7.28.5" -"@babel/runtime@*", "@babel/runtime@^7.18.6", "@babel/runtime@^7.20.0", "@babel/runtime@^7.20.13", "@babel/runtime@^7.25.0": +"@babel/runtime@^7.18.6", "@babel/runtime@^7.20.0", "@babel/runtime@^7.20.13", "@babel/runtime@^7.25.0": version "7.28.6" resolved "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.6.tgz" integrity sha512-05WQkdpL9COIMz4LjTxGpPNCdlpyimKppYNoJ5Di5EUObifl8t4tuLuUBBZEpoLYOmfvIWrsp9fCl0HoPRVTdA== @@ -972,7 +965,7 @@ "@noble/curves" "1.5.0" "@noble/hashes" "1.4.0" -"@codemirror/autocomplete@^6.0.0", "@codemirror/autocomplete@^6.3.2", "@codemirror/autocomplete@^6.7.1", "@codemirror/autocomplete@6.20.0": +"@codemirror/autocomplete@6.20.0", "@codemirror/autocomplete@^6.0.0", "@codemirror/autocomplete@^6.3.2", "@codemirror/autocomplete@^6.7.1": version "6.20.0" resolved "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.0.tgz" integrity sha512-bOwvTOIJcG5FVo5gUUupiwYh8MioPLQ4UcqbcRf7UQ98X90tCa9E1kZ3Z7tqwpZxYyOvh1YTYbmZE9RTfTp5hg== @@ -1008,7 +1001,7 @@ "@codemirror/language" "^6.0.0" "@lezer/cpp" "^1.0.0" -"@codemirror/lang-css@^6.0.0", "@codemirror/lang-css@6.3.1": +"@codemirror/lang-css@6.3.1", "@codemirror/lang-css@^6.0.0": version "6.3.1" resolved "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz" integrity sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg== @@ -1030,7 +1023,7 @@ "@lezer/common" "^1.0.0" "@lezer/go" "^1.0.0" -"@codemirror/lang-html@^6.0.0", "@codemirror/lang-html@6.4.11": +"@codemirror/lang-html@6.4.11", "@codemirror/lang-html@^6.0.0": version "6.4.11" resolved "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.11.tgz" integrity sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw== @@ -1053,7 +1046,7 @@ "@codemirror/language" "^6.0.0" "@lezer/java" "^1.0.0" -"@codemirror/lang-javascript@^6.0.0", "@codemirror/lang-javascript@6.2.4": +"@codemirror/lang-javascript@6.2.4", "@codemirror/lang-javascript@^6.0.0": version "6.2.4" resolved "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.4.tgz" integrity sha512-0WVmhp1QOqZ4Rt6GlVGwKJN3KW7Xh4H2q8ZZNGZaP6lRdxXJzmjm4FqvmOojVj6khWJHIb9sp7U/72W7xQgqAA== @@ -1154,7 +1147,7 @@ "@lezer/lr" "^1.0.0" "@lezer/yaml" "^1.0.0" -"@codemirror/language@^6.0.0", "@codemirror/language@^6.3.0", "@codemirror/language@^6.4.0", "@codemirror/language@^6.6.0", "@codemirror/language@^6.8.0", "@codemirror/language@6.12.1": +"@codemirror/language@6.12.1", "@codemirror/language@^6.0.0", "@codemirror/language@^6.3.0", "@codemirror/language@^6.4.0", "@codemirror/language@^6.6.0", "@codemirror/language@^6.8.0": version "6.12.1" resolved "https://registry.npmjs.org/@codemirror/language/-/language-6.12.1.tgz" integrity sha512-Fa6xkSiuGKc8XC8Cn96T+TQHYj4ZZ7RdFmXA3i9xe/3hLHfwPZdM+dqfX0Cp0zQklBKhVD8Yzc8LS45rkqcwpQ== @@ -1189,32 +1182,35 @@ dependencies: "@codemirror/state" "^0.19.0" -"@codemirror/state@^0.19.0": - version "0.19.9" - resolved "https://registry.npmjs.org/@codemirror/state/-/state-0.19.9.tgz" - integrity sha512-psOzDolKTZkx4CgUqhBQ8T8gBc0xN5z4gzed109aF6x7D7umpDRoimacI/O6d9UGuyl4eYuDCZmDFr2Rq7aGOw== +"@codemirror/state@6.5.4", "@codemirror/state@^6.0.0", "@codemirror/state@^6.4.0", "@codemirror/state@^6.5.0": + version "6.5.4" + resolved "https://registry.npmjs.org/@codemirror/state/-/state-6.5.4.tgz" + integrity sha512-8y7xqG/hpB53l25CIoit9/ngxdfoG+fx+V3SHBrinnhOtLvKHRyAJJuHzkWrR4YXXLX8eXBsejgAAxHUOdW1yw== dependencies: - "@codemirror/text" "^0.19.0" + "@marijn/find-cluster-break" "^1.0.0" -"@codemirror/state@^0.19.2", "@codemirror/state@^0.19.3": +"@codemirror/state@^0.19.0", "@codemirror/state@^0.19.2", "@codemirror/state@^0.19.3": version "0.19.9" resolved "https://registry.npmjs.org/@codemirror/state/-/state-0.19.9.tgz" integrity sha512-psOzDolKTZkx4CgUqhBQ8T8gBc0xN5z4gzed109aF6x7D7umpDRoimacI/O6d9UGuyl4eYuDCZmDFr2Rq7aGOw== dependencies: "@codemirror/text" "^0.19.0" -"@codemirror/state@^6.0.0", "@codemirror/state@^6.4.0", "@codemirror/state@^6.5.0", "@codemirror/state@6.5.4": - version "6.5.4" - resolved "https://registry.npmjs.org/@codemirror/state/-/state-6.5.4.tgz" - integrity sha512-8y7xqG/hpB53l25CIoit9/ngxdfoG+fx+V3SHBrinnhOtLvKHRyAJJuHzkWrR4YXXLX8eXBsejgAAxHUOdW1yw== - dependencies: - "@marijn/find-cluster-break" "^1.0.0" - "@codemirror/text@^0.19.0": version "0.19.6" resolved "https://registry.npmjs.org/@codemirror/text/-/text-0.19.6.tgz" integrity sha512-T9jnREMIygx+TPC1bOuepz18maGq/92q2a+n4qTqObKwvNMg+8cMTslb8yxeEDEq7S3kpgGWxgO1UWbQRij0dA== +"@codemirror/view@6.39.15", "@codemirror/view@^6.0.0", "@codemirror/view@^6.17.0", "@codemirror/view@^6.23.0", "@codemirror/view@^6.27.0", "@codemirror/view@^6.35.0": + version "6.39.15" + resolved "https://registry.npmjs.org/@codemirror/view/-/view-6.39.15.tgz" + integrity sha512-aCWjgweIIXLBHh7bY6cACvXuyrZ0xGafjQ2VInjp4RM4gMfscK5uESiNdrH0pE+e1lZr2B4ONGsjchl2KsKZzg== + dependencies: + "@codemirror/state" "^6.5.0" + crelt "^1.0.6" + style-mod "^4.1.0" + w3c-keyname "^2.2.4" + "@codemirror/view@^0.19.0": version "0.19.48" resolved "https://registry.npmjs.org/@codemirror/view/-/view-0.19.48.tgz" @@ -1226,16 +1222,6 @@ style-mod "^4.0.0" w3c-keyname "^2.2.4" -"@codemirror/view@^6.0.0", "@codemirror/view@^6.17.0", "@codemirror/view@^6.23.0", "@codemirror/view@^6.27.0", "@codemirror/view@^6.35.0", "@codemirror/view@6.39.15": - version "6.39.15" - resolved "https://registry.npmjs.org/@codemirror/view/-/view-6.39.15.tgz" - integrity sha512-aCWjgweIIXLBHh7bY6cACvXuyrZ0xGafjQ2VInjp4RM4gMfscK5uESiNdrH0pE+e1lZr2B4ONGsjchl2KsKZzg== - dependencies: - "@codemirror/state" "^6.5.0" - crelt "^1.0.6" - style-mod "^4.1.0" - w3c-keyname "^2.2.4" - "@config-plugins/react-native-webrtc@^13.0.0": version "13.0.0" resolved "https://registry.npmjs.org/@config-plugins/react-native-webrtc/-/react-native-webrtc-13.0.0.tgz" @@ -1266,12 +1252,12 @@ "@csstools/color-helpers" "^5.1.0" "@csstools/css-calc" "^2.1.4" -"@csstools/css-parser-algorithms@^3.0.4", "@csstools/css-parser-algorithms@^3.0.5": +"@csstools/css-parser-algorithms@^3.0.4": version "3.0.5" resolved "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-3.0.5.tgz" integrity sha512-DaDeUkXZKjdGhgYaHNJTV9pV7Y9B3b644jCLs9Upc3VeNGg6LWARAT6O+Q+/COo+2gg/bM5rhpMAtf70WqfBdQ== -"@csstools/css-tokenizer@^3.0.3", "@csstools/css-tokenizer@^3.0.4": +"@csstools/css-tokenizer@^3.0.3": version "3.0.4" resolved "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-3.0.4.tgz" integrity sha512-Vd/9EVDiu6PPJt9yAh6roZP6El1xHrdvIVGjyBsHR0RYwNHgL7FJPyIIW4fANJNG6FtyZfvlRPpFI4ZM/lubvw== @@ -1293,7 +1279,7 @@ resolved "https://registry.npmjs.org/@electric-sql/pglite-socket/-/pglite-socket-0.0.20.tgz" integrity sha512-J5nLGsicnD9wJHnno9r+DGxfcZWh+YJMCe0q/aCgtG6XOm9Z7fKeite8IZSNXgZeGltSigM9U/vAWZQWdgcSFg== -"@electric-sql/pglite@^0.3.15", "@electric-sql/pglite@0.3.15": +"@electric-sql/pglite@^0.3.15": version "0.3.15" resolved "https://registry.npmjs.org/@electric-sql/pglite/-/pglite-0.3.15.tgz" integrity sha512-Cj++n1Mekf9ETfdc16TlDi+cDDQF0W7EcbyRHYOAeZdsAe8M/FJg18itDTSwyHfar2WIezawM9o0EKaRGVKygQ== @@ -1325,11 +1311,423 @@ resolved "https://registry.npmjs.org/@elevenlabs/types/-/types-0.4.0.tgz" integrity sha512-6CQb4r9DjepILhLybDxW0h8rvbsR8jPAp74HvFGYuvYdXcQRTkYTD9BQ4PV6VcuC1iqx0UljzJj8js3ADQ8EXw== +"@emnapi/core@^1.7.1": + version "1.8.1" + resolved "https://registry.yarnpkg.com/@emnapi/core/-/core-1.8.1.tgz#fd9efe721a616288345ffee17a1f26ac5dd01349" + integrity sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg== + dependencies: + "@emnapi/wasi-threads" "1.1.0" + tslib "^2.4.0" + +"@emnapi/runtime@^1.7.0", "@emnapi/runtime@^1.7.1": + version "1.8.1" + resolved "https://registry.yarnpkg.com/@emnapi/runtime/-/runtime-1.8.1.tgz#550fa7e3c0d49c5fb175a116e8cd70614f9a22a5" + integrity sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg== + dependencies: + tslib "^2.4.0" + +"@emnapi/wasi-threads@1.1.0", "@emnapi/wasi-threads@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@emnapi/wasi-threads/-/wasi-threads-1.1.0.tgz#60b2102fddc9ccb78607e4a3cf8403ea69be41bf" + integrity sha512-WI0DdZ8xFSbgMjR1sFsKABJ/C5OnRrjT06JXbZKexJGrDuPTzZdDYfFlsgcCXCyf+suG5QU2e/y1Wo2V/OapLQ== + dependencies: + tslib "^2.4.0" + "@epic-web/invariant@^1.0.0": version "1.0.0" resolved "https://registry.npmjs.org/@epic-web/invariant/-/invariant-1.0.0.tgz" integrity sha512-lrTPqgvfFQtR/eY/qkIzp98OGdNJu0m5ji3q/nJI8v3SXkRKEnWiOxMmbvcSoAIzv/cGiuvRy57k4suKQSAdwA== +"@esbuild/aix-ppc64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz#80fcbe36130e58b7670511e888b8e88a259ed76c" + integrity sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA== + +"@esbuild/aix-ppc64@0.26.0": + version "0.26.0" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.26.0.tgz#e1d4bed341913e20caf2c2cb3a77b9662a8e40f1" + integrity sha512-hj0sKNCQOOo2fgyII3clmJXP28VhgDfU5iy3GNHlWO76KG6N7x4D9ezH5lJtQTG+1J6MFDAJXC1qsI+W+LvZoA== + +"@esbuild/aix-ppc64@0.27.2": + version "0.27.2" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.27.2.tgz#521cbd968dcf362094034947f76fa1b18d2d403c" + integrity sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw== + +"@esbuild/android-arm64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz#8aa4965f8d0a7982dc21734bf6601323a66da752" + integrity sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg== + +"@esbuild/android-arm64@0.26.0": + version "0.26.0" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.26.0.tgz#0a871d1332467be244101f757213a4dfc32ee93c" + integrity sha512-DDnoJ5eoa13L8zPh87PUlRd/IyFaIKOlRbxiwcSbeumcJ7UZKdtuMCHa1Q27LWQggug6W4m28i4/O2qiQQ5NZQ== + +"@esbuild/android-arm64@0.27.2": + version "0.27.2" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.27.2.tgz#61ea550962d8aa12a9b33194394e007657a6df57" + integrity sha512-pvz8ZZ7ot/RBphf8fv60ljmaoydPU12VuXHImtAs0XhLLw+EXBi2BLe3OYSBslR4rryHvweW5gmkKFwTiFy6KA== + +"@esbuild/android-arm@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.25.12.tgz#300712101f7f50f1d2627a162e6e09b109b6767a" + integrity sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg== + +"@esbuild/android-arm@0.26.0": + version "0.26.0" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.26.0.tgz#6615de6d8fe792f8abd752cf0118305a171e15cd" + integrity sha512-C0hkDsYNHZkBtPxxDx177JN90/1MiCpvBNjz1f5yWJo1+5+c5zr8apjastpEG+wtPjo9FFtGG7owSsAxyKiHxA== + +"@esbuild/android-arm@0.27.2": + version "0.27.2" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.27.2.tgz#554887821e009dd6d853f972fde6c5143f1de142" + integrity sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA== + +"@esbuild/android-x64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.25.12.tgz#87dfb27161202bdc958ef48bb61b09c758faee16" + integrity sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg== + +"@esbuild/android-x64@0.26.0": + version "0.26.0" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.26.0.tgz#c9e69f9389893392aed36824677c2594ae2445e2" + integrity sha512-bKDkGXGZnj0T70cRpgmv549x38Vr2O3UWLbjT2qmIkdIWcmlg8yebcFWoT9Dku7b5OV3UqPEuNKRzlNhjwUJ9A== + +"@esbuild/android-x64@0.27.2": + version "0.27.2" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.27.2.tgz#a7ce9d0721825fc578f9292a76d9e53334480ba2" + integrity sha512-z8Ank4Byh4TJJOh4wpz8g2vDy75zFL0TlZlkUkEwYXuPSgX8yzep596n6mT7905kA9uHZsf/o2OJZubl2l3M7A== + +"@esbuild/darwin-arm64@0.25.12": + version "0.25.12" + resolved "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz" + integrity sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg== + +"@esbuild/darwin-arm64@0.26.0": + version "0.26.0" + resolved "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.26.0.tgz" + integrity sha512-6Z3naJgOuAIB0RLlJkYc81An3rTlQ/IeRdrU3dOea8h/PvZSgitZV+thNuIccw0MuK1GmIAnAmd5TrMZad8FTQ== + +"@esbuild/darwin-arm64@0.27.2": + version "0.27.2" + resolved "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.27.2.tgz" + integrity sha512-davCD2Zc80nzDVRwXTcQP/28fiJbcOwvdolL0sOiOsbwBa72kegmVU0Wrh1MYrbuCL98Omp5dVhQFWRKR2ZAlg== + +"@esbuild/darwin-x64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz#146400a8562133f45c4d2eadcf37ddd09718079e" + integrity sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA== + +"@esbuild/darwin-x64@0.26.0": + version "0.26.0" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.26.0.tgz#ea6d99e8c706318ce0171e85eb61ebb6e1a3e8f3" + integrity sha512-OPnYj0zpYW0tHusMefyaMvNYQX5pNQuSsHFTHUBNp3vVXupwqpxofcjVsUx11CQhGVkGeXjC3WLjh91hgBG2xw== + +"@esbuild/darwin-x64@0.27.2": + version "0.27.2" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.27.2.tgz#e741fa6b1abb0cd0364126ba34ca17fd5e7bf509" + integrity sha512-ZxtijOmlQCBWGwbVmwOF/UCzuGIbUkqB1faQRf5akQmxRJ1ujusWsb3CVfk/9iZKr2L5SMU5wPBi1UWbvL+VQA== + +"@esbuild/freebsd-arm64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz#1c5f9ba7206e158fd2b24c59fa2d2c8bb47ca0fe" + integrity sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg== + +"@esbuild/freebsd-arm64@0.26.0": + version "0.26.0" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.26.0.tgz#9acd33e588d2e1edddf6ee0e5a5272336b6ffcc1" + integrity sha512-jix2fa6GQeZhO1sCKNaNMjfj5hbOvoL2F5t+w6gEPxALumkpOV/wq7oUBMHBn2hY2dOm+mEV/K+xfZy3mrsxNQ== + +"@esbuild/freebsd-arm64@0.27.2": + version "0.27.2" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.27.2.tgz#2b64e7116865ca172d4ce034114c21f3c93e397c" + integrity sha512-lS/9CN+rgqQ9czogxlMcBMGd+l8Q3Nj1MFQwBZJyoEKI50XGxwuzznYdwcav6lpOGv5BqaZXqvBSiB/kJ5op+g== + +"@esbuild/freebsd-x64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz#ea631f4a36beaac4b9279fa0fcc6ca29eaeeb2b3" + integrity sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ== + +"@esbuild/freebsd-x64@0.26.0": + version "0.26.0" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.26.0.tgz#c187f1f56731ab17861b9d3b00a9ddd7fbf6e46d" + integrity sha512-tccJaH5xHJD/239LjbVvJwf6T4kSzbk6wPFerF0uwWlkw/u7HL+wnAzAH5GB2irGhYemDgiNTp8wJzhAHQ64oA== + +"@esbuild/freebsd-x64@0.27.2": + version "0.27.2" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.27.2.tgz#e5252551e66f499e4934efb611812f3820e990bb" + integrity sha512-tAfqtNYb4YgPnJlEFu4c212HYjQWSO/w/h/lQaBK7RbwGIkBOuNKQI9tqWzx7Wtp7bTPaGC6MJvWI608P3wXYA== + +"@esbuild/linux-arm64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz#e1066bce58394f1b1141deec8557a5f0a22f5977" + integrity sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ== + +"@esbuild/linux-arm64@0.26.0": + version "0.26.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.26.0.tgz#e516c7062d0b7f19cd139a5b597cd827d1e843ee" + integrity sha512-IMJYN7FSkLttYyTbsbme0Ra14cBO5z47kpamo16IwggzzATFY2lcZAwkbcNkWiAduKrTgFJP7fW5cBI7FzcuNQ== + +"@esbuild/linux-arm64@0.27.2": + version "0.27.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.27.2.tgz#dc4acf235531cd6984f5d6c3b13dbfb7ddb303cb" + integrity sha512-hYxN8pr66NsCCiRFkHUAsxylNOcAQaxSSkHMMjcpx0si13t1LHFphxJZUiGwojB1a/Hd5OiPIqDdXONia6bhTw== + +"@esbuild/linux-arm@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz#452cd66b20932d08bdc53a8b61c0e30baf4348b9" + integrity sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw== + +"@esbuild/linux-arm@0.26.0": + version "0.26.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.26.0.tgz#f26b22a0c80ac1ea73afd33c4e5034c69ae9ab14" + integrity sha512-JY8NyU31SyRmRpuc5W8PQarAx4TvuYbyxbPIpHAZdr/0g4iBr8KwQBS4kiiamGl2f42BBecHusYCsyxi7Kn8UQ== + +"@esbuild/linux-arm@0.27.2": + version "0.27.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.27.2.tgz#56a900e39240d7d5d1d273bc053daa295c92e322" + integrity sha512-vWfq4GaIMP9AIe4yj1ZUW18RDhx6EPQKjwe7n8BbIecFtCQG4CfHGaHuh7fdfq+y3LIA2vGS/o9ZBGVxIDi9hw== + +"@esbuild/linux-ia32@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz#b24f8acc45bcf54192c7f2f3be1b53e6551eafe0" + integrity sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA== + +"@esbuild/linux-ia32@0.26.0": + version "0.26.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.26.0.tgz#22b310b307f68b57a28d25c662758a865555b6dc" + integrity sha512-XITaGqGVLgk8WOHw8We9Z1L0lbLFip8LyQzKYFKO4zFo1PFaaSKsbNjvkb7O8kEXytmSGRkYpE8LLVpPJpsSlw== + +"@esbuild/linux-ia32@0.27.2": + version "0.27.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.27.2.tgz#d4a36d473360f6870efcd19d52bbfff59a2ed1cc" + integrity sha512-MJt5BRRSScPDwG2hLelYhAAKh9imjHK5+NE/tvnRLbIqUWa+0E9N4WNMjmp/kXXPHZGqPLxggwVhz7QP8CTR8w== + +"@esbuild/linux-loong64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz#f9cfffa7fc8322571fbc4c8b3268caf15bd81ad0" + integrity sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng== + +"@esbuild/linux-loong64@0.26.0": + version "0.26.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.26.0.tgz#39027e0050ec9b275432814f47d3325562c62565" + integrity sha512-MkggfbDIczStUJwq9wU7gQ7kO33d8j9lWuOCDifN9t47+PeI+9m2QVh51EI/zZQ1spZtFMC1nzBJ+qNGCjJnsg== + +"@esbuild/linux-loong64@0.27.2": + version "0.27.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.27.2.tgz#fcf0ab8c3eaaf45891d0195d4961cb18b579716a" + integrity sha512-lugyF1atnAT463aO6KPshVCJK5NgRnU4yb3FUumyVz+cGvZbontBgzeGFO1nF+dPueHD367a2ZXe1NtUkAjOtg== + +"@esbuild/linux-mips64el@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz#575a14bd74644ffab891adc7d7e60d275296f2cd" + integrity sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw== + +"@esbuild/linux-mips64el@0.26.0": + version "0.26.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.26.0.tgz#d30185dc4298bd2470b21e6358f4c0e42cd8ecfc" + integrity sha512-fUYup12HZWAeccNLhQ5HwNBPr4zXCPgUWzEq2Rfw7UwqwfQrFZ0SR/JljaURR8xIh9t+o1lNUFTECUTmaP7yKA== + +"@esbuild/linux-mips64el@0.27.2": + version "0.27.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.27.2.tgz#598b67d34048bb7ee1901cb12e2a0a434c381c10" + integrity sha512-nlP2I6ArEBewvJ2gjrrkESEZkB5mIoaTswuqNFRv/WYd+ATtUpe9Y09RnJvgvdag7he0OWgEZWhviS1OTOKixw== + +"@esbuild/linux-ppc64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz#75b99c70a95fbd5f7739d7692befe60601591869" + integrity sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA== + +"@esbuild/linux-ppc64@0.26.0": + version "0.26.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.26.0.tgz#8df55e65fd98a4244aaea649ba9745998f82a2e8" + integrity sha512-MzRKhM0Ip+//VYwC8tialCiwUQ4G65WfALtJEFyU0GKJzfTYoPBw5XNWf0SLbCUYQbxTKamlVwPmcw4DgZzFxg== + +"@esbuild/linux-ppc64@0.27.2": + version "0.27.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.27.2.tgz#3846c5df6b2016dab9bc95dde26c40f11e43b4c0" + integrity sha512-C92gnpey7tUQONqg1n6dKVbx3vphKtTHJaNG2Ok9lGwbZil6DrfyecMsp9CrmXGQJmZ7iiVXvvZH6Ml5hL6XdQ== + +"@esbuild/linux-riscv64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz#2e3259440321a44e79ddf7535c325057da875cd6" + integrity sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w== + +"@esbuild/linux-riscv64@0.26.0": + version "0.26.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.26.0.tgz#406e26b21646d19bf2e58a187a53fec719465512" + integrity sha512-QhCc32CwI1I4Jrg1enCv292sm3YJprW8WHHlyxJhae/dVs+KRWkbvz2Nynl5HmZDW/m9ZxrXayHzjzVNvQMGQA== + +"@esbuild/linux-riscv64@0.27.2": + version "0.27.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.27.2.tgz#173d4475b37c8d2c3e1707e068c174bb3f53d07d" + integrity sha512-B5BOmojNtUyN8AXlK0QJyvjEZkWwy/FKvakkTDCziX95AowLZKR6aCDhG7LeF7uMCXEJqwa8Bejz5LTPYm8AvA== + +"@esbuild/linux-s390x@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz#17676cabbfe5928da5b2a0d6df5d58cd08db2663" + integrity sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg== + +"@esbuild/linux-s390x@0.26.0": + version "0.26.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.26.0.tgz#a3c55edb6f19b2ff737f3d5d238bbcaf58815652" + integrity sha512-1D6vi6lfI18aNT1aTf2HV+RIlm6fxtlAp8eOJ4mmnbYmZ4boz8zYDar86sIYNh0wmiLJEbW/EocaKAX6Yso2fw== + +"@esbuild/linux-s390x@0.27.2": + version "0.27.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.27.2.tgz#f7a4790105edcab8a5a31df26fbfac1aa3dacfab" + integrity sha512-p4bm9+wsPwup5Z8f4EpfN63qNagQ47Ua2znaqGH6bqLlmJ4bx97Y9JdqxgGZ6Y8xVTixUnEkoKSHcpRlDnNr5w== + +"@esbuild/linux-x64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz#0583775685ca82066d04c3507f09524d3cd7a306" + integrity sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw== + +"@esbuild/linux-x64@0.26.0": + version "0.26.0" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.26.0.tgz#d60f139897b12e5af37113d6a433a7ef27c6d87f" + integrity sha512-rnDcepj7LjrKFvZkx+WrBv6wECeYACcFjdNPvVPojCPJD8nHpb3pv3AuR9CXgdnjH1O23btICj0rsp0L9wAnHA== + +"@esbuild/linux-x64@0.27.2": + version "0.27.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.27.2.tgz#2ecc1284b1904aeb41e54c9ddc7fcd349b18f650" + integrity sha512-uwp2Tip5aPmH+NRUwTcfLb+W32WXjpFejTIOWZFw/v7/KnpCDKG66u4DLcurQpiYTiYwQ9B7KOeMJvLCu/OvbA== + +"@esbuild/netbsd-arm64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz#f04c4049cb2e252fe96b16fed90f70746b13f4a4" + integrity sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg== + +"@esbuild/netbsd-arm64@0.26.0": + version "0.26.0" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.26.0.tgz#a89594f82f8172b127d7a59988dbde1a56a4d03e" + integrity sha512-FSWmgGp0mDNjEXXFcsf12BmVrb+sZBBBlyh3LwB/B9ac3Kkc8x5D2WimYW9N7SUkolui8JzVnVlWh7ZmjCpnxw== + +"@esbuild/netbsd-arm64@0.27.2": + version "0.27.2" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.27.2.tgz#e2863c2cd1501845995cb11adf26f7fe4be527b0" + integrity sha512-Kj6DiBlwXrPsCRDeRvGAUb/LNrBASrfqAIok+xB0LxK8CHqxZ037viF13ugfsIpePH93mX7xfJp97cyDuTZ3cw== + +"@esbuild/netbsd-x64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz#77da0d0a0d826d7c921eea3d40292548b258a076" + integrity sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ== + +"@esbuild/netbsd-x64@0.26.0": + version "0.26.0" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.26.0.tgz#22c396c61067d4057d3c8b1e0b7481422bcf70f5" + integrity sha512-0QfciUDFryD39QoSPUDshj4uNEjQhp73+3pbSAaxjV2qGOEDsM67P7KbJq7LzHoVl46oqhIhJ1S+skKGR7lMXA== + +"@esbuild/netbsd-x64@0.27.2": + version "0.27.2" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.27.2.tgz#93f7609e2885d1c0b5a1417885fba8d1fcc41272" + integrity sha512-HwGDZ0VLVBY3Y+Nw0JexZy9o/nUAWq9MlV7cahpaXKW6TOzfVno3y3/M8Ga8u8Yr7GldLOov27xiCnqRZf0tCA== + +"@esbuild/openbsd-arm64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz#6296f5867aedef28a81b22ab2009c786a952dccd" + integrity sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A== + +"@esbuild/openbsd-arm64@0.26.0": + version "0.26.0" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.26.0.tgz#302f9602161cf1556dfc3574f6ff895f92566221" + integrity sha512-vmAK+nHhIZWImwJ3RNw9hX3fU4UGN/OqbSE0imqljNbUQC3GvVJ1jpwYoTfD6mmXmQaxdJY6Hn4jQbLGJKg5Yw== + +"@esbuild/openbsd-arm64@0.27.2": + version "0.27.2" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.27.2.tgz#a1985604a203cdc325fd47542e106fafd698f02e" + integrity sha512-DNIHH2BPQ5551A7oSHD0CKbwIA/Ox7+78/AWkbS5QoRzaqlev2uFayfSxq68EkonB+IKjiuxBFoV8ESJy8bOHA== + +"@esbuild/openbsd-x64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz#f8d23303360e27b16cf065b23bbff43c14142679" + integrity sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw== + +"@esbuild/openbsd-x64@0.26.0": + version "0.26.0" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.26.0.tgz#2d702092fe56dcc9f77932733898900b077e195a" + integrity sha512-GPXF7RMkJ7o9bTyUsnyNtrFMqgM3X+uM/LWw4CeHIjqc32fm0Ir6jKDnWHpj8xHFstgWDUYseSABK9KCkHGnpg== + +"@esbuild/openbsd-x64@0.27.2": + version "0.27.2" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.27.2.tgz#8209e46c42f1ffbe6e4ef77a32e1f47d404ad42a" + integrity sha512-/it7w9Nb7+0KFIzjalNJVR5bOzA9Vay+yIPLVHfIQYG/j+j9VTH84aNB8ExGKPU4AzfaEvN9/V4HV+F+vo8OEg== + +"@esbuild/openharmony-arm64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz#49e0b768744a3924be0d7fd97dd6ce9b2923d88d" + integrity sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg== + +"@esbuild/openharmony-arm64@0.26.0": + version "0.26.0" + resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.26.0.tgz#b23c269bfe5583f2ffecf2778200194756652262" + integrity sha512-nUHZ5jEYqbBthbiBksbmHTlbb5eElyVfs/s1iHQ8rLBq1eWsd5maOnDpCocw1OM8kFK747d1Xms8dXJHtduxSw== + +"@esbuild/openharmony-arm64@0.27.2": + version "0.27.2" + resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.27.2.tgz#8fade4441893d9cc44cbd7dcf3776f508ab6fb2f" + integrity sha512-LRBbCmiU51IXfeXk59csuX/aSaToeG7w48nMwA6049Y4J4+VbWALAuXcs+qcD04rHDuSCSRKdmY63sruDS5qag== + +"@esbuild/sunos-x64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz#a6ed7d6778d67e528c81fb165b23f4911b9b13d6" + integrity sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w== + +"@esbuild/sunos-x64@0.26.0": + version "0.26.0" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.26.0.tgz#8579737e0e147af566070fdcf691b79cf508666c" + integrity sha512-TMg3KCTCYYaVO+R6P5mSORhcNDDlemUVnUbb8QkboUtOhb5JWKAzd5uMIMECJQOxHZ/R+N8HHtDF5ylzLfMiLw== + +"@esbuild/sunos-x64@0.27.2": + version "0.27.2" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.27.2.tgz#980d4b9703a16f0f07016632424fc6d9a789dfc2" + integrity sha512-kMtx1yqJHTmqaqHPAzKCAkDaKsffmXkPHThSfRwZGyuqyIeBvf08KSsYXl+abf5HDAPMJIPnbBfXvP2ZC2TfHg== + +"@esbuild/win32-arm64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz#9ac14c378e1b653af17d08e7d3ce34caef587323" + integrity sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg== + +"@esbuild/win32-arm64@0.26.0": + version "0.26.0" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.26.0.tgz#6761aab78c8b7d397a82d73c545de0410e61a5f7" + integrity sha512-apqYgoAUd6ZCb9Phcs8zN32q6l0ZQzQBdVXOofa6WvHDlSOhwCWgSfVQabGViThS40Y1NA4SCvQickgZMFZRlA== + +"@esbuild/win32-arm64@0.27.2": + version "0.27.2" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.27.2.tgz#1c09a3633c949ead3d808ba37276883e71f6111a" + integrity sha512-Yaf78O/B3Kkh+nKABUF++bvJv5Ijoy9AN1ww904rOXZFLWVc5OLOfL56W+C8F9xn5JQZa3UX6m+IktJnIb1Jjg== + +"@esbuild/win32-ia32@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz#918942dcbbb35cc14fca39afb91b5e6a3d127267" + integrity sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ== + +"@esbuild/win32-ia32@0.26.0": + version "0.26.0" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.26.0.tgz#22bef8d60489e4edd3646154ef23d0bdab54b12a" + integrity sha512-FGJAcImbJNZzLWu7U6WB0iKHl4RuY4TsXEwxJPl9UZLS47agIZuILZEX3Pagfw7I4J3ddflomt9f0apfaJSbaw== + +"@esbuild/win32-ia32@0.27.2": + version "0.27.2" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.27.2.tgz#1b1e3a63ad4bef82200fef4e369e0fff7009eee5" + integrity sha512-Iuws0kxo4yusk7sw70Xa2E2imZU5HoixzxfGCdxwBdhiDgt9vX9VUCBhqcwY7/uh//78A1hMkkROMJq9l27oLQ== + +"@esbuild/win32-x64@0.25.12": + version "0.25.12" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz#9bdad8176be7811ad148d1f8772359041f46c6c5" + integrity sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA== + +"@esbuild/win32-x64@0.26.0": + version "0.26.0" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.26.0.tgz#5fdb98bc8d6f767132363ab9527184c073773853" + integrity sha512-WAckBKaVnmFqbEhbymrPK7M086DQMpL1XoRbpmN0iW8k5JSXjDRQBhcZNa0VweItknLq9eAeCL34jK7/CDcw7A== + +"@esbuild/win32-x64@0.27.2": + version "0.27.2" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.27.2.tgz#9e585ab6086bef994c6e8a5b3a0481219ada862b" + integrity sha512-sRdU18mcKf7F+YgheI/zGf5alZatMUTKj/jNS6l744f9u3WFu4v7twcUI9vu4mknF4Y9aDlblIie0IM+5xxaqQ== + "@eslint-community/eslint-utils@^4.8.0": version "4.9.1" resolved "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz" @@ -1481,7 +1879,7 @@ dependencies: node-forge "^1.3.3" -"@expo/config-plugins@*", "@expo/config-plugins@~54.0.1", "@expo/config-plugins@~54.0.4": +"@expo/config-plugins@~54.0.1", "@expo/config-plugins@~54.0.4": version "54.0.4" resolved "https://registry.npmjs.org/@expo/config-plugins/-/config-plugins-54.0.4.tgz" integrity sha512-g2yXGICdoOw5i3LkQSDxl2Q5AlQCrG7oniu0pCPPO+UxGb7He4AFqSvPSy8HpRUj55io17hT62FTjYRD+d6j3Q== @@ -1592,7 +1990,7 @@ "@babel/code-frame" "~7.10.4" json5 "^2.2.3" -"@expo/metro-config@~54.0.14", "@expo/metro-config@54.0.14": +"@expo/metro-config@54.0.14", "@expo/metro-config@~54.0.14": version "54.0.14" resolved "https://registry.npmjs.org/@expo/metro-config/-/metro-config-54.0.14.tgz" integrity sha512-hxpLyDfOR4L23tJ9W1IbJJsG7k4lv2sotohBm/kTYyiG+pe1SYCAWsRmgk+H42o/wWf/HQjE5k45S5TomGLxNA== @@ -1619,7 +2017,7 @@ postcss "~8.4.32" resolve-from "^5.0.0" -"@expo/metro-runtime@*", "@expo/metro-runtime@^6.1.2": +"@expo/metro-runtime@^6.1.2": version "6.1.2" resolved "https://registry.npmjs.org/@expo/metro-runtime/-/metro-runtime-6.1.2.tgz" integrity sha512-nvM+Qv45QH7pmYvP8JB1G8JpScrWND3KrMA6ZKe62cwwNiX/BjHU28Ear0v/4bQWXlOY0mv6B8CDIm8JxXde9g== @@ -1649,6 +2047,61 @@ metro-transform-plugins "0.83.3" metro-transform-worker "0.83.3" +"@expo/ngrok-bin-darwin-arm64@2.3.41": + version "2.3.41" + resolved "https://registry.npmjs.org/@expo/ngrok-bin-darwin-arm64/-/ngrok-bin-darwin-arm64-2.3.41.tgz" + integrity sha512-TPf95xp6SkvbRONZjltTOFcCJbmzAH7lrQ36Dv+djrOckWGPVq4HCur48YAeiGDqspmFEmqZ7ykD5c/bDfRFOA== + +"@expo/ngrok-bin-darwin-x64@2.3.41": + version "2.3.41" + resolved "https://registry.yarnpkg.com/@expo/ngrok-bin-darwin-x64/-/ngrok-bin-darwin-x64-2.3.41.tgz#ff006f92c01887070d2302c67455c53d43ccb9f1" + integrity sha512-29QZHfX4Ec0p0pQF5UrqiP2/Qe7t2rI96o+5b8045VCEl9AEAKHceGuyo+jfUDR4FSQBGFLSDb06xy8ghL3ZYA== + +"@expo/ngrok-bin-freebsd-ia32@2.3.41": + version "2.3.41" + resolved "https://registry.yarnpkg.com/@expo/ngrok-bin-freebsd-ia32/-/ngrok-bin-freebsd-ia32-2.3.41.tgz#c488ce27823c199e2876eb80c5049856a0541b1d" + integrity sha512-YYXgwNZ+p0aIrwgb+1/RxJbsWhGEzBDBhZulKg1VB7tKDAd2C8uGnbK1rOCuZy013iOUsJDXaj9U5QKc13iIXw== + +"@expo/ngrok-bin-freebsd-x64@2.3.41": + version "2.3.41" + resolved "https://registry.yarnpkg.com/@expo/ngrok-bin-freebsd-x64/-/ngrok-bin-freebsd-x64-2.3.41.tgz#9e17cf0b4cd76dd1050db40463dfc9fc9b43938c" + integrity sha512-1Ei6K8BB+3etmmBT0tXYC4dyVkJMigT4ELbRTF5jKfw1pblqeXM9Qpf3p8851PTlH142S3bockCeO39rSkOnkg== + +"@expo/ngrok-bin-linux-arm64@2.3.41": + version "2.3.41" + resolved "https://registry.yarnpkg.com/@expo/ngrok-bin-linux-arm64/-/ngrok-bin-linux-arm64-2.3.41.tgz#dc7c761b7f12d8baae997565c360939b34da78f0" + integrity sha512-eC8GA/xPcmQJy4h+g2FlkuQB3lf5DjITy8Y6GyydmPYMByjUYAGEXe0brOcP893aalAzRqbNOAjSuAw1lcCLSQ== + +"@expo/ngrok-bin-linux-arm@2.3.41": + version "2.3.41" + resolved "https://registry.yarnpkg.com/@expo/ngrok-bin-linux-arm/-/ngrok-bin-linux-arm-2.3.41.tgz#c914a3e86d166e0595d13b3e501535bf441834ca" + integrity sha512-B6+rW/+tEi7ZrKWQGkRzlwmKo7c1WJhNODFBSgkF/Sj9PmmNhBz67mer91S2+6nNt5pfcwLLd61CjtWfR1LUHQ== + +"@expo/ngrok-bin-linux-ia32@2.3.41": + version "2.3.41" + resolved "https://registry.yarnpkg.com/@expo/ngrok-bin-linux-ia32/-/ngrok-bin-linux-ia32-2.3.41.tgz#5079fc5aa2f886d6e33a25e913ec240be57eac73" + integrity sha512-w5Cy31wSz4jYnygEHS7eRizR1yt8s9TX6kHlkjzayIiRTFRb2E1qD2l0/4T2w0LJpBjM5ZFPaaKqsNWgCUIEow== + +"@expo/ngrok-bin-linux-x64@2.3.41": + version "2.3.41" + resolved "https://registry.yarnpkg.com/@expo/ngrok-bin-linux-x64/-/ngrok-bin-linux-x64-2.3.41.tgz#34c117c14b3d82f6d51cd25df025d9679d866577" + integrity sha512-LcU3MbYHv7Sn2eFz8Yzo2rXduufOvX1/hILSirwCkH+9G8PYzpwp2TeGqVWuO+EmvtBe6NEYwgdQjJjN6I4L1A== + +"@expo/ngrok-bin-sunos-x64@2.3.41": + version "2.3.41" + resolved "https://registry.yarnpkg.com/@expo/ngrok-bin-sunos-x64/-/ngrok-bin-sunos-x64-2.3.41.tgz#8ab01f96344d5c2aae2944009fbea67aed74c022" + integrity sha512-bcOj45BLhiV2PayNmLmEVZlFMhEiiGpOr36BXC0XSL+cHUZHd6uNaS28AaZdz95lrRzGpeb0hAF8cuJjo6nq4g== + +"@expo/ngrok-bin-win32-ia32@2.3.41": + version "2.3.41" + resolved "https://registry.yarnpkg.com/@expo/ngrok-bin-win32-ia32/-/ngrok-bin-win32-ia32-2.3.41.tgz#b5fbcd49f2ae38fdb4105f8248159dc75c9ba9e3" + integrity sha512-0+vPbKvUA+a9ERgiAknmZCiWA3AnM5c6beI+51LqmjKEM4iAAlDmfXNJ89aAbvZMUtBNwEPHzJHnaM4s2SeBhA== + +"@expo/ngrok-bin-win32-x64@2.3.41": + version "2.3.41" + resolved "https://registry.yarnpkg.com/@expo/ngrok-bin-win32-x64/-/ngrok-bin-win32-x64-2.3.41.tgz#91c56b695c9c8d81424a335795e204d7bd011fcf" + integrity sha512-mncsPRaG462LiYrM8mQT8OYe3/i44m3N/NzUeieYpGi8+pCOo8TIC23kR9P93CVkbM9mmXsy3X6hq91a8FWBdA== + "@expo/ngrok-bin@2.3.42": version "2.3.42" resolved "https://registry.npmjs.org/@expo/ngrok-bin/-/ngrok-bin-2.3.42.tgz" @@ -1839,7 +2292,7 @@ fastify-plugin "^5.0.0" toad-cache "^3.7.0" -"@fastify/swagger@>=9.5.1", "@fastify/swagger@9.7.0": +"@fastify/swagger@9.7.0": version "9.7.0" resolved "https://registry.npmjs.org/@fastify/swagger/-/swagger-9.7.0.tgz" integrity sha512-Vp1SC1GC2Hrkd3faFILv86BzUNyFz5N4/xdExqtCgkGASOzn/x+eMe4qXIGq7cdT6wif/P/oa6r1Ruqx19paZA== @@ -1850,18 +2303,19 @@ rfdc "^1.3.1" yaml "^2.4.2" -"@floating-ui/core@^1.7.3": +"@floating-ui/core@^1.7.3", "@floating-ui/core@^1.7.4": version "1.7.4" resolved "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.4.tgz" integrity sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg== dependencies: "@floating-ui/utils" "^0.2.10" -"@floating-ui/core@^1.7.4": +"@floating-ui/dom@1.7.4": version "1.7.4" - resolved "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.4.tgz" - integrity sha512-C3HlIdsBxszvm5McXlB8PeOEWfBhcGBTZGkGlWc2U0KFY5IwG5OQEuQ8rq52DZmcHDlPLd+YFBK+cZcytwIFWg== + resolved "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz" + integrity sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA== dependencies: + "@floating-ui/core" "^1.7.3" "@floating-ui/utils" "^0.2.10" "@floating-ui/dom@^1.7.5": @@ -1872,14 +2326,6 @@ "@floating-ui/core" "^1.7.4" "@floating-ui/utils" "^0.2.10" -"@floating-ui/dom@1.7.4": - version "1.7.4" - resolved "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.4.tgz" - integrity sha512-OOchDgh4F2CchOX94cRVqhvy7b3AFb+/rQXyswmzmGakRfkMgoWVjfnLWkRirfLEfuD4ysVW16eXzwt3jHIzKA== - dependencies: - "@floating-ui/core" "^1.7.3" - "@floating-ui/utils" "^0.2.10" - "@floating-ui/react-dom@^2.0.0": version "2.1.7" resolved "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.7.tgz" @@ -1916,301 +2362,13 @@ postcss-selector-parser "^7.1.1" tailwind-merge "^3.4.0" -"@happier-dev/agents@0.0.0", "@happier-dev/agents@file:/Users/cdenneen/code/workspace/work/happier-dev/packages/agents": - version "0.0.0" - resolved "file:packages/agents" - dependencies: - "@happier-dev/protocol" "0.0.0" - zod "4.3.6" - -"@happier-dev/app@file:/Users/cdenneen/code/workspace/work/happier-dev/apps/ui": - version "0.1.0" - resolved "file:apps/ui" - dependencies: - "@codemirror/autocomplete" "6.20.0" - "@codemirror/commands" "6.10.2" - "@codemirror/history" "0.19.2" - "@codemirror/lang-cpp" "6.0.3" - "@codemirror/lang-css" "6.3.1" - "@codemirror/lang-go" "6.0.1" - "@codemirror/lang-html" "6.4.11" - "@codemirror/lang-java" "6.0.2" - "@codemirror/lang-javascript" "6.2.4" - "@codemirror/lang-json" "6.0.2" - "@codemirror/lang-markdown" "6.5.0" - "@codemirror/lang-php" "6.0.2" - "@codemirror/lang-python" "6.2.1" - "@codemirror/lang-rust" "6.0.2" - "@codemirror/lang-sql" "6.10.0" - "@codemirror/lang-xml" "6.1.0" - "@codemirror/lang-yaml" "6.1.2" - "@codemirror/language" "6.12.1" - "@codemirror/legacy-modes" "6.5.2" - "@codemirror/state" "6.5.4" - "@codemirror/view" "6.39.15" - "@config-plugins/react-native-webrtc" "^13.0.0" - "@elevenlabs/react" "^0.12.3" - "@elevenlabs/react-native" "^0.5.7" - "@expo/vector-icons" "^15.0.2" - "@happier-dev/agents" "0.0.0" - "@happier-dev/audio-stream-native" "0.0.0" - "@happier-dev/protocol" "0.0.0" - "@happier-dev/sherpa-native" "0.0.0" - "@legendapp/list" "2.0.0-beta.3" - "@livekit/react-native" "^2.9.0" - "@livekit/react-native-expo-plugin" "^1.0.1" - "@livekit/react-native-webrtc" "^137.0.0" - "@more-tech/react-native-libsodium" "^1.5.5" - "@peoplesgrocers/seti-ui-file-icons" "1.11.3" - "@pierre/diffs" "1.1.0-beta.13" - "@radix-ui/react-dialog" "^1.1.15" - "@radix-ui/react-dismissable-layer" "^1.1.11" - "@react-native-async-storage/async-storage" "2.2.0" - "@react-native-masked-view/masked-view" "0.3.2" - "@react-navigation/drawer" "^7.3.9" - "@react-navigation/native" "^7.1.8" - "@revenuecat/purchases-js" "^1.11.1" - "@sentry/react-native" "^8.1.0" - "@shopify/flash-list" "2.0.2" - "@shopify/react-native-skia" "2.2.12" - "@tauri-apps/api" "~2.8.0" - "@tauri-apps/cli" "~2.8.2" - "@tauri-apps/plugin-http" "^2.5.2" - "@types/libsodium-wrappers" "^0.7.14" - "@types/qrcode" "^1.5.5" - "@types/uuid" "^10.0.0" - axios "^1.10.0" - color "^5.0.0" - diff "^8.0.2" - expo "^54.0.0" - expo-application "~7.0.7" - expo-asset "~12.0.9" - expo-audio "~1.0.13" - expo-blur "~15.0.7" - expo-camera "~17.0.8" - expo-clipboard "^8.0.7" - expo-constants "~18.0.9" - expo-crypto "~15.0.7" - expo-dev-client "~6.0.12" - expo-device "~8.0.8" - expo-document-picker "~14.0.8" - expo-file-system "~19.0.14" - expo-font "~14.0.8" - expo-haptics "~15.0.7" - expo-image "~3.0.8" - expo-linear-gradient "~15.0.7" - expo-linking "~8.0.8" - expo-localization "~17.0.7" - expo-mail-composer "~15.0.7" - expo-notifications "~0.32.11" - expo-router "6.0.22" - expo-secure-store "~15.0.7" - expo-speech "~14.0.8" - expo-speech-recognition "^3.1.0" - expo-splash-screen "~31.0.10" - expo-status-bar "~3.0.8" - expo-store-review "~9.0.7" - expo-system-ui "~6.0.7" - expo-updates "~29.0.11" - expo-web-browser "~15.0.7" - fuse.js "^7.1.0" - kokoro-js "^1.2.1" - libsodium-wrappers "0.8.2" - livekit-client "^2.15.4" - mermaid "^11.12.1" - monaco-editor "^0.55.1" - posthog-react-native "^4.16.2" - qrcode "^1.5.4" - react "19.1.0" - react-dom "19.1.0" - react-native "0.81.4" - react-native-audio-api "^0.8.2" - react-native-device-info "^14.1.1" - react-native-gesture-handler "~2.28.0" - react-native-keyboard-controller "1.18.5" - react-native-localize "^3.5.1" - react-native-mmkv "^3.3.3" - react-native-nitro-modules "0.33.2" - react-native-purchases "^9.4.2" - react-native-purchases-ui "^9.4.2" - react-native-quick-base64 "^2.2.1" - react-native-reanimated "^4.2.1" - react-native-safe-area-context "~5.6.0" - react-native-screen-transitions "^1.2.0" - react-native-screens "~4.16.0" - react-native-svg "15.12.1" - react-native-typography "^1.4.1" - react-native-unistyles "^3.0.21" - react-native-vision-camera "^4.7.3" - react-native-web "^0.21.0" - react-native-webview "13.15.0" - react-native-worklets "^0.7.1" - react-textarea-autosize "^8.5.9" - rn-encryption "^2.5.0" - shiki "3.22.0" - socket.io-client "^4.8.1" - uuid "^11.1.0" - vitest "^3.2.4" - zod "4.3.6" - zustand "^5.0.6" - -"@happier-dev/audio-stream-native@0.0.0", "@happier-dev/audio-stream-native@file:/Users/cdenneen/code/workspace/work/happier-dev/packages/audio-stream-native": - version "0.0.0" - resolved "file:packages/audio-stream-native" - dependencies: - expo-modules-core "*" - -"@happier-dev/cli-common@0.0.0", "@happier-dev/cli-common@file:/Users/cdenneen/code/workspace/work/happier-dev/packages/cli-common": - version "0.0.0" - resolved "file:packages/cli-common" - dependencies: - "@happier-dev/agents" "0.0.0" - -"@happier-dev/cli@file:/Users/cdenneen/code/workspace/work/happier-dev/apps/cli": - version "0.1.0" - resolved "file:apps/cli" - dependencies: - "@agentclientprotocol/sdk" "^0.14.1" - "@anthropic-ai/claude-agent-sdk" "^0.2.34" - "@fastify/swagger" "9.7.0" - "@happier-dev/agents" "0.0.0" - "@happier-dev/cli-common" "0.0.0" - "@happier-dev/protocol" "0.0.0" - "@happier-dev/release-runtime" "0.0.0" - "@huggingface/transformers" "^3.8.1" - "@modelcontextprotocol/sdk" "^1.25.3" - "@stablelib/base64" "^2.0.1" - "@stablelib/hex" "^2.0.1" - "@types/cross-spawn" "^6.0.6" - "@types/http-proxy" "^1.17.17" - "@types/ps-list" "^6.2.1" - "@types/qrcode-terminal" "^0.12.2" - "@types/react" "^19.2.7" - "@types/tmp" "^0.2.6" - ai "^5.0.107" - axios "^1.13.2" - chalk "^5.6.2" - cross-spawn "^7.0.6" - expo-server-sdk "^3.15.0" - fastify "^5.6.2" - fastify-type-provider-zod "6.1.0" - http-proxy "^1.18.1" - http-proxy-middleware "^3.0.5" - https-proxy-agent "^7.0.6" - ink "^6.5.1" - open "^10.2.0" - openapi-types "12.1.3" - ps-list "^8.1.1" - qrcode-terminal "^0.12.0" - react "^19.2.0" - react-devtools-core "^6.1.5" - socket.io-client "^4.8.1" - tar "^7.5.2" - tmp "^0.2.5" - tweetnacl "^1.0.3" - zod "4.3.6" - -"@happier-dev/protocol@0.0.0", "@happier-dev/protocol@file:/Users/cdenneen/code/workspace/work/happier-dev/packages/protocol": - version "0.0.0" - resolved "file:packages/protocol" - dependencies: - "@noble/hashes" "^1.8.0" - base64-js "^1.5.1" - tweetnacl "^1.0.3" - zod "4.3.6" - zod-to-json-schema "3.25.1" - -"@happier-dev/relay-server@file:/Users/cdenneen/code/workspace/work/happier-dev/packages/relay-server": - version "0.1.2" - resolved "file:packages/relay-server" - dependencies: - "@happier-dev/release-runtime" "0.0.0" - -"@happier-dev/release-runtime@0.0.0", "@happier-dev/release-runtime@file:/Users/cdenneen/code/workspace/work/happier-dev/packages/release-runtime": - version "0.0.0" - resolved "file:packages/release-runtime" - -"@happier-dev/server@file:/Users/cdenneen/code/workspace/work/happier-dev/apps/server": - version "0.1.2" - resolved "file:apps/server" - dependencies: - "@date-fns/tz" "^1.2.0" - "@electric-sql/pglite" "^0.3.15" - "@electric-sql/pglite-socket" "^0.0.20" - "@fastify/bearer-auth" "^10.1.1" - "@fastify/cors" "^10.0.1" - "@fastify/rate-limit" "^10.3.0" - "@fastify/swagger" "9.7.0" - "@happier-dev/agents" "0.0.0" - "@happier-dev/protocol" "0.0.0" - "@prisma/client" "^6.11.1" - "@sentry/node" "10.39.0" - "@sentry/profiling-node" "^10.39.0" - "@socket.io/redis-streams-adapter" "^0.2.2" - "@types/jsonwebtoken" "^9.0.10" - "@types/semver" "^7.7.0" - axios "^1.6.8" - chalk "4.1.2" - cron-parser "^5.5.0" - date-fns "^4.1.0" - dotenv "^16.4.5" - elevenlabs "^1.54.0" - fastify "^5.2.0" - fastify-type-provider-zod "6.1.0" - ioredis "^5.6.1" - jsonwebtoken "^9.0.2" - minio "^8.0.5" - octokit "^5.0.3" - openapi-types "12.1.3" - openid-client "^6.0.0" - pino-pretty "^13.0.0" - prisma "^6.11.1" - prisma-json-types-generator "^3.5.1" - privacy-kit "^0.0.25" - prom-client "^15.1.3" - reflect-metadata "^0.2.2" - semver "^7.7.2" - sharp "^0.34.3" - socket.io "^4.8.1" - socket.io-adapter "^2.5.5" - tmp "^0.2.3" - tsx "^4.19.2" - tweetnacl "^1.0.3" - uuid "^9.0.1" - vite-tsconfig-paths "^5.1.4" - vitest "^3.2.0" - zod "4.3.6" - zod-to-json-schema "^3.24.3" - -"@happier-dev/sherpa-native@0.0.0", "@happier-dev/sherpa-native@file:/Users/cdenneen/code/workspace/work/happier-dev/packages/sherpa-native": - version "0.0.0" - resolved "file:packages/sherpa-native" - dependencies: - expo-modules-core "*" - -"@happier-dev/stack@file:/Users/cdenneen/code/workspace/work/happier-dev/apps/stack": - version "0.1.0" - resolved "file:apps/stack" - dependencies: - "@happier-dev/agents" "0.0.0" - "@happier-dev/cli-common" "0.0.0" - "@happier-dev/protocol" "0.0.0" - "@happier-dev/release-runtime" "0.0.0" - qrcode "^1.5.4" - qrcode-terminal "^0.12.0" - -"@happier-dev/tests@file:/Users/cdenneen/code/workspace/work/happier-dev/packages/tests": - version "0.0.0" - resolved "file:packages/tests" +"@homebridge/node-pty-prebuilt-multiarch@^0.13.1": + version "0.13.1" + resolved "https://registry.npmjs.org/@homebridge/node-pty-prebuilt-multiarch/-/node-pty-prebuilt-multiarch-0.13.1.tgz" + integrity sha512-ccQ60nMcbEGrQh0U9E6x0ajW9qJNeazpcM/9CH6J8leyNtJgb+gu24WTBAfBUVeO486ZhscnaxLEITI2HXwhow== dependencies: - "@happier-dev/protocol" "0.0.0" - privacy-kit "^0.0.25" - socket.io-client "^4.8.1" - tweetnacl "^1.0.3" - -"@happier-dev/website@file:/Users/cdenneen/code/workspace/work/happier-dev/apps/website": - version "0.1.0" - resolved "file:apps/website" + node-addon-api "^7.1.0" + prebuild-install "^7.1.2" "@hono/node-server@^1.19.9": version "1.19.9" @@ -2269,15 +2427,157 @@ "@iconify/types" "^2.0.0" mlly "^1.8.0" -"@ide/backoff@^1.0.0": - version "1.0.0" - resolved "https://registry.npmjs.org/@ide/backoff/-/backoff-1.0.0.tgz" - integrity sha512-F0YfUDjvT+Mtt/R4xdl2X0EYCHMMiJqNLdxHD++jDT5ydEFIyqbCHh51Qx2E211dgZprPKhV7sHmnXKpLuvc5g== +"@ide/backoff@^1.0.0": + version "1.0.0" + resolved "https://registry.npmjs.org/@ide/backoff/-/backoff-1.0.0.tgz" + integrity sha512-F0YfUDjvT+Mtt/R4xdl2X0EYCHMMiJqNLdxHD++jDT5ydEFIyqbCHh51Qx2E211dgZprPKhV7sHmnXKpLuvc5g== + +"@img/colour@^1.0.0": + version "1.0.0" + resolved "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz" + integrity sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw== + +"@img/sharp-darwin-arm64@0.34.5", "@img/sharp-darwin-arm64@^0.34.2": + version "0.34.5" + resolved "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz" + integrity sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w== + optionalDependencies: + "@img/sharp-libvips-darwin-arm64" "1.2.4" + +"@img/sharp-darwin-x64@0.34.5", "@img/sharp-darwin-x64@^0.34.2": + version "0.34.5" + resolved "https://registry.yarnpkg.com/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz#19bc1dd6eba6d5a96283498b9c9f401180ee9c7b" + integrity sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw== + optionalDependencies: + "@img/sharp-libvips-darwin-x64" "1.2.4" + +"@img/sharp-libvips-darwin-arm64@1.2.4": + version "1.2.4" + resolved "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz" + integrity sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g== + +"@img/sharp-libvips-darwin-x64@1.2.4": + version "1.2.4" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz#e63681f4539a94af9cd17246ed8881734386f8cc" + integrity sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg== + +"@img/sharp-libvips-linux-arm64@1.2.4": + version "1.2.4" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz#b1b288b36864b3bce545ad91fa6dadcf1a4ad318" + integrity sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw== + +"@img/sharp-libvips-linux-arm@1.2.4": + version "1.2.4" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz#b9260dd1ebe6f9e3bdbcbdcac9d2ac125f35852d" + integrity sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A== + +"@img/sharp-libvips-linux-ppc64@1.2.4": + version "1.2.4" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz#4b83ecf2a829057222b38848c7b022e7b4d07aa7" + integrity sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA== + +"@img/sharp-libvips-linux-riscv64@1.2.4": + version "1.2.4" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz#880b4678009e5a2080af192332b00b0aaf8a48de" + integrity sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA== + +"@img/sharp-libvips-linux-s390x@1.2.4": + version "1.2.4" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz#74f343c8e10fad821b38f75ced30488939dc59ec" + integrity sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ== + +"@img/sharp-libvips-linux-x64@1.2.4": + version "1.2.4" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz#df4183e8bd8410f7d61b66859a35edeab0a531ce" + integrity sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw== + +"@img/sharp-libvips-linuxmusl-arm64@1.2.4": + version "1.2.4" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz#c8d6b48211df67137541007ee8d1b7b1f8ca8e06" + integrity sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw== + +"@img/sharp-libvips-linuxmusl-x64@1.2.4": + version "1.2.4" + resolved "https://registry.yarnpkg.com/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz#be11c75bee5b080cbee31a153a8779448f919f75" + integrity sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg== + +"@img/sharp-linux-arm64@0.34.5", "@img/sharp-linux-arm64@^0.34.2": + version "0.34.5" + resolved "https://registry.yarnpkg.com/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz#7aa7764ef9c001f15e610546d42fce56911790cc" + integrity sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg== + optionalDependencies: + "@img/sharp-libvips-linux-arm64" "1.2.4" + +"@img/sharp-linux-arm@0.34.5", "@img/sharp-linux-arm@^0.34.2": + version "0.34.5" + resolved "https://registry.yarnpkg.com/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz#5fb0c3695dd12522d39c3ff7a6bc816461780a0d" + integrity sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw== + optionalDependencies: + "@img/sharp-libvips-linux-arm" "1.2.4" + +"@img/sharp-linux-ppc64@0.34.5": + version "0.34.5" + resolved "https://registry.yarnpkg.com/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz#9c213a81520a20caf66978f3d4c07456ff2e0813" + integrity sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA== + optionalDependencies: + "@img/sharp-libvips-linux-ppc64" "1.2.4" + +"@img/sharp-linux-riscv64@0.34.5": + version "0.34.5" + resolved "https://registry.yarnpkg.com/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz#cdd28182774eadbe04f62675a16aabbccb833f60" + integrity sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw== + optionalDependencies: + "@img/sharp-libvips-linux-riscv64" "1.2.4" + +"@img/sharp-linux-s390x@0.34.5": + version "0.34.5" + resolved "https://registry.yarnpkg.com/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz#93eac601b9f329bb27917e0e19098c722d630df7" + integrity sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg== + optionalDependencies: + "@img/sharp-libvips-linux-s390x" "1.2.4" + +"@img/sharp-linux-x64@0.34.5", "@img/sharp-linux-x64@^0.34.2": + version "0.34.5" + resolved "https://registry.yarnpkg.com/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz#55abc7cd754ffca5002b6c2b719abdfc846819a8" + integrity sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ== + optionalDependencies: + "@img/sharp-libvips-linux-x64" "1.2.4" + +"@img/sharp-linuxmusl-arm64@0.34.5", "@img/sharp-linuxmusl-arm64@^0.34.2": + version "0.34.5" + resolved "https://registry.yarnpkg.com/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz#d6515ee971bb62f73001a4829b9d865a11b77086" + integrity sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg== + optionalDependencies: + "@img/sharp-libvips-linuxmusl-arm64" "1.2.4" + +"@img/sharp-linuxmusl-x64@0.34.5", "@img/sharp-linuxmusl-x64@^0.34.2": + version "0.34.5" + resolved "https://registry.yarnpkg.com/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz#d97978aec7c5212f999714f2f5b736457e12ee9f" + integrity sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q== + optionalDependencies: + "@img/sharp-libvips-linuxmusl-x64" "1.2.4" + +"@img/sharp-wasm32@0.34.5": + version "0.34.5" + resolved "https://registry.yarnpkg.com/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz#2f15803aa626f8c59dd7c9d0bbc766f1ab52cfa0" + integrity sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw== + dependencies: + "@emnapi/runtime" "^1.7.0" + +"@img/sharp-win32-arm64@0.34.5", "@img/sharp-win32-arm64@^0.34.2": + version "0.34.5" + resolved "https://registry.yarnpkg.com/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz#3706e9e3ac35fddfc1c87f94e849f1b75307ce0a" + integrity sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g== -"@img/colour@^1.0.0": - version "1.0.0" - resolved "https://registry.npmjs.org/@img/colour/-/colour-1.0.0.tgz" - integrity sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw== +"@img/sharp-win32-ia32@0.34.5": + version "0.34.5" + resolved "https://registry.yarnpkg.com/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz#0b71166599b049e032f085fb9263e02f4e4788de" + integrity sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg== + +"@img/sharp-win32-x64@0.34.5", "@img/sharp-win32-x64@^0.34.2": + version "0.34.5" + resolved "https://registry.yarnpkg.com/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz#a81ffb00e69267cd0a1d626eaedb8a8430b2b2f8" + integrity sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw== "@inquirer/ansi@^1.0.2": version "1.0.2" @@ -2584,14 +2884,6 @@ resolved "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz" integrity sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og== -"@jridgewell/trace-mapping@^0.3.18", "@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.25", "@jridgewell/trace-mapping@^0.3.28": - version "0.3.31" - resolved "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz" - integrity sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw== - dependencies: - "@jridgewell/resolve-uri" "^3.1.0" - "@jridgewell/sourcemap-codec" "^1.4.14" - "@jridgewell/trace-mapping@0.3.9": version "0.3.9" resolved "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz" @@ -2600,6 +2892,14 @@ "@jridgewell/resolve-uri" "^3.0.3" "@jridgewell/sourcemap-codec" "^1.4.10" +"@jridgewell/trace-mapping@^0.3.18", "@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.25", "@jridgewell/trace-mapping@^0.3.28": + version "0.3.31" + resolved "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz" + integrity sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw== + dependencies: + "@jridgewell/resolve-uri" "^3.1.0" + "@jridgewell/sourcemap-codec" "^1.4.14" + "@legendapp/list@2.0.0-beta.3": version "2.0.0-beta.3" resolved "https://registry.npmjs.org/@legendapp/list/-/list-2.0.0-beta.3.tgz" @@ -2762,7 +3062,7 @@ jose "^6.0.12" usehooks-ts "3.1.1" -"@livekit/mutex@^1.1.1", "@livekit/mutex@1.1.1": +"@livekit/mutex@1.1.1", "@livekit/mutex@^1.1.1": version "1.1.1" resolved "https://registry.npmjs.org/@livekit/mutex/-/mutex-1.1.1.tgz" integrity sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw== @@ -2779,7 +3079,7 @@ resolved "https://registry.npmjs.org/@livekit/react-native-expo-plugin/-/react-native-expo-plugin-1.0.1.tgz" integrity sha512-CSPjjzgDDlBH1ZyFyaw7/FW2Ql1S51eUkIxv/vjGwVshn+lUD6eQ9VgfUh7ha84itvjXi9X87FvP0XWKn9CiFQ== -"@livekit/react-native-webrtc@^137.0.0", "@livekit/react-native-webrtc@^137.0.2": +"@livekit/react-native-webrtc@^137.0.0": version "137.0.2" resolved "https://registry.npmjs.org/@livekit/react-native-webrtc/-/react-native-webrtc-137.0.2.tgz" integrity sha512-0aXYATcBraOMDTteKzmfH5ICNHw8xFyMPHmhKg14+94fAGZ2hGjdHZUSkzL14+e508W486aIAmbXipuSQCCJgA== @@ -2788,7 +3088,7 @@ debug "4.3.4" event-target-shim "6.0.2" -"@livekit/react-native@^2.1.0", "@livekit/react-native@^2.9.0", "@livekit/react-native@^2.9.2": +"@livekit/react-native@^2.9.0": version "2.9.6" resolved "https://registry.npmjs.org/@livekit/react-native/-/react-native-2.9.6.tgz" integrity sha512-4DOFJ/OO4yftZbm90QnVMDjwFXqSw2hcruSVO2idgwFxylDmH6NN2e1rs5ZCQPsnqlfwuMGz8vNfuYt++G4C/A== @@ -2897,19 +3197,80 @@ resolved "https://registry.npmjs.org/@msgpack/msgpack/-/msgpack-2.8.0.tgz" integrity sha512-h9u4u/jiIRKbq25PM+zymTyW6bhTzELvOoUd+AvYriWOAKpLGnIamaET3pnHYoI5iYphAHBI4ayx0MehR+VVPQ== +"@napi-rs/wasm-runtime@^1.1.0": + version "1.1.1" + resolved "https://registry.yarnpkg.com/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.1.tgz#c3705ab549d176b8dc5172723d6156c3dc426af2" + integrity sha512-p64ah1M1ld8xjWv3qbvFwHiFVWrq1yFvV4f7w+mzaqiR4IlSgkqhcRdHwsGgomwzBH51sRY4NEowLxnaBjcW/A== + dependencies: + "@emnapi/core" "^1.7.1" + "@emnapi/runtime" "^1.7.1" + "@tybys/wasm-util" "^0.10.1" + "@next/env@16.1.4": version "16.1.4" resolved "https://registry.npmjs.org/@next/env/-/env-16.1.4.tgz" integrity sha512-gkrXnZyxPUy0Gg6SrPQPccbNVLSP3vmW8LU5dwEttEEC1RwDivk8w4O+sZIjFvPrSICXyhQDCG+y3VmjlJf+9A== -"@noble/curves@^1.9.0", "@noble/curves@1.5.0": +"@next/swc-darwin-arm64@16.1.4": + version "16.1.4" + resolved "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.1.4.tgz" + integrity sha512-T8atLKuvk13XQUdVLCv1ZzMPgLPW0+DWWbHSQXs0/3TjPrKNxTmUIhOEaoEyl3Z82k8h/gEtqyuoZGv6+Ugawg== + +"@next/swc-darwin-x64@16.1.4": + version "16.1.4" + resolved "https://registry.yarnpkg.com/@next/swc-darwin-x64/-/swc-darwin-x64-16.1.4.tgz#2f8d4462f48d4cb3c927de1962ca7a7b2f8a5b03" + integrity sha512-AKC/qVjUGUQDSPI6gESTx0xOnOPQ5gttogNS3o6bA83yiaSZJek0Am5yXy82F1KcZCx3DdOwdGPZpQCluonuxg== + +"@next/swc-linux-arm64-gnu@16.1.4": + version "16.1.4" + resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.1.4.tgz#79fecac25ad4a0ee1081110f4c8863b87e754943" + integrity sha512-POQ65+pnYOkZNdngWfMEt7r53bzWiKkVNbjpmCt1Zb3V6lxJNXSsjwRuTQ8P/kguxDC8LRkqaL3vvsFrce4dMQ== + +"@next/swc-linux-arm64-musl@16.1.4": + version "16.1.4" + resolved "https://registry.yarnpkg.com/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.1.4.tgz#e9a99b1ea9a68908c3d36a847a6fe367b4fc3855" + integrity sha512-3Wm0zGYVCs6qDFAiSSDL+Z+r46EdtCv/2l+UlIdMbAq9hPJBvGu/rZOeuvCaIUjbArkmXac8HnTyQPJFzFWA0Q== + +"@next/swc-linux-x64-gnu@16.1.4": + version "16.1.4" + resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.1.4.tgz#4804de5f42ac8333e0049ab538473cbd996507f6" + integrity sha512-lWAYAezFinaJiD5Gv8HDidtsZdT3CDaCeqoPoJjeB57OqzvMajpIhlZFce5sCAH6VuX4mdkxCRqecCJFwfm2nQ== + +"@next/swc-linux-x64-musl@16.1.4": + version "16.1.4" + resolved "https://registry.yarnpkg.com/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.1.4.tgz#4aa01e59b0e0fd19ab493ee239e3904c42419ca6" + integrity sha512-fHaIpT7x4gA6VQbdEpYUXRGyge/YbRrkG6DXM60XiBqDM2g2NcrsQaIuj375egnGFkJow4RHacgBOEsHfGbiUw== + +"@next/swc-win32-arm64-msvc@16.1.4": + version "16.1.4" + resolved "https://registry.yarnpkg.com/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.1.4.tgz#67652a5c57889f44c11e145d49f777ac2e6cde58" + integrity sha512-MCrXxrTSE7jPN1NyXJr39E+aNFBrQZtO154LoCz7n99FuKqJDekgxipoodLNWdQP7/DZ5tKMc/efybx1l159hw== + +"@next/swc-win32-x64-msvc@16.1.4": + version "16.1.4" + resolved "https://registry.yarnpkg.com/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.1.4.tgz#3c51597eb64a96b8fcade74ab3f21ef3ad278a33" + integrity sha512-JSVlm9MDhmTXw/sO2PE/MRj+G6XOSMZB+BcZ0a7d6KwVFZVpkHcb2okyoYFBaco6LeiL53BBklRlOrDDbOeE5w== + +"@noble/curves@1.5.0": + version "1.5.0" + resolved "https://registry.npmjs.org/@noble/curves/-/curves-1.5.0.tgz" + integrity sha512-J5EKamIHnKPyClwVrzmaf5wSdQXgdHcPZIZLu3bwnbeCx8/7NPK5q2ZBWF+5FvYGByjiQQsJYX6jfgB2wDPn3A== + dependencies: + "@noble/hashes" "1.4.0" + +"@noble/curves@^1.9.0": version "1.9.7" resolved "https://registry.npmjs.org/@noble/curves/-/curves-1.9.7.tgz" integrity sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw== dependencies: "@noble/hashes" "1.8.0" -"@noble/hashes@^1.3.2", "@noble/hashes@^1.8.0", "@noble/hashes@1.4.0", "@noble/hashes@1.8.0": +"@noble/hashes@1.4.0": + version "1.4.0" + resolved "https://registry.npmjs.org/@noble/hashes/-/hashes-1.4.0.tgz" + integrity sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg== + +"@noble/hashes@1.8.0", "@noble/hashes@^1.3.2", "@noble/hashes@^1.8.0": version "1.8.0" resolved "https://registry.npmjs.org/@noble/hashes/-/hashes-1.8.0.tgz" integrity sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A== @@ -2922,7 +3283,7 @@ "@nodelib/fs.stat" "2.0.5" run-parallel "^1.1.9" -"@nodelib/fs.stat@^2.0.2", "@nodelib/fs.stat@2.0.5": +"@nodelib/fs.stat@2.0.5", "@nodelib/fs.stat@^2.0.2": version "2.0.5" resolved "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz" integrity sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A== @@ -3014,7 +3375,7 @@ "@octokit/request-error" "^7.0.2" "@octokit/types" "^16.0.0" -"@octokit/core@^7.0.0", "@octokit/core@^7.0.5", "@octokit/core@^7.0.6", "@octokit/core@>=6", "@octokit/core@>=7": +"@octokit/core@^7.0.5", "@octokit/core@^7.0.6": version "7.0.6" resolved "https://registry.npmjs.org/@octokit/core/-/core-7.0.6.tgz" integrity sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q== @@ -3194,23 +3555,16 @@ dependencies: "@opentelemetry/api" "^1.3.0" -"@opentelemetry/api@^1.1.0", "@opentelemetry/api@^1.3.0", "@opentelemetry/api@^1.4.0", "@opentelemetry/api@^1.7.0", "@opentelemetry/api@^1.8", "@opentelemetry/api@^1.9.0", "@opentelemetry/api@>=1.0.0 <1.10.0", "@opentelemetry/api@>=1.3.0 <1.10.0", "@opentelemetry/api@1.9.0": +"@opentelemetry/api@1.9.0", "@opentelemetry/api@^1.3.0", "@opentelemetry/api@^1.4.0", "@opentelemetry/api@^1.9.0": version "1.9.0" resolved "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz" integrity sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg== -"@opentelemetry/context-async-hooks@^1.30.1 || ^2.1.0", "@opentelemetry/context-async-hooks@^2.5.0", "@opentelemetry/context-async-hooks@^2.5.1": +"@opentelemetry/context-async-hooks@^2.5.0", "@opentelemetry/context-async-hooks@^2.5.1": version "2.5.1" resolved "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.5.1.tgz" integrity sha512-MHbu8XxCHcBn6RwvCt2Vpn1WnLMNECfNKYB14LI5XypcgH4IE0/DiVifVR9tAkwPMyLXN8dOoPJfya3IryLQVw== -"@opentelemetry/core@^1.30.1 || ^2.1.0", "@opentelemetry/core@^2.0.0", "@opentelemetry/core@^2.5.0", "@opentelemetry/core@^2.5.1", "@opentelemetry/core@2.5.1": - version "2.5.1" - resolved "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.1.tgz" - integrity sha512-Dwlc+3HAZqpgTYq0MUyZABjFkcrKTePwuiFVLjahGD8cx3enqihmpAmdgNFO1R4m/sIe5afjJrA25Prqy4NXlA== - dependencies: - "@opentelemetry/semantic-conventions" "^1.29.0" - "@opentelemetry/core@2.5.0": version "2.5.0" resolved "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.0.tgz" @@ -3218,6 +3572,13 @@ dependencies: "@opentelemetry/semantic-conventions" "^1.29.0" +"@opentelemetry/core@2.5.1", "@opentelemetry/core@^2.0.0", "@opentelemetry/core@^2.5.0", "@opentelemetry/core@^2.5.1": + version "2.5.1" + resolved "https://registry.npmjs.org/@opentelemetry/core/-/core-2.5.1.tgz" + integrity sha512-Dwlc+3HAZqpgTYq0MUyZABjFkcrKTePwuiFVLjahGD8cx3enqihmpAmdgNFO1R4m/sIe5afjJrA25Prqy4NXlA== + dependencies: + "@opentelemetry/semantic-conventions" "^1.29.0" + "@opentelemetry/instrumentation-amqplib@0.58.0": version "0.58.0" resolved "https://registry.npmjs.org/@opentelemetry/instrumentation-amqplib/-/instrumentation-amqplib-0.58.0.tgz" @@ -3352,23 +3713,23 @@ "@opentelemetry/instrumentation" "^0.211.0" "@opentelemetry/semantic-conventions" "^1.33.0" -"@opentelemetry/instrumentation-mysql@0.57.0": +"@opentelemetry/instrumentation-mysql2@0.57.0": version "0.57.0" - resolved "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql/-/instrumentation-mysql-0.57.0.tgz" - integrity sha512-HFS/+FcZ6Q7piM7Il7CzQ4VHhJvGMJWjx7EgCkP5AnTntSN5rb5Xi3TkYJHBKeR27A0QqPlGaCITi93fUDs++Q== + resolved "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql2/-/instrumentation-mysql2-0.57.0.tgz" + integrity sha512-nHSrYAwF7+aV1E1V9yOOP9TchOodb6fjn4gFvdrdQXiRE7cMuffyLLbCZlZd4wsspBzVwOXX8mpURdRserAhNA== dependencies: "@opentelemetry/instrumentation" "^0.211.0" "@opentelemetry/semantic-conventions" "^1.33.0" - "@types/mysql" "2.15.27" + "@opentelemetry/sql-common" "^0.41.2" -"@opentelemetry/instrumentation-mysql2@0.57.0": +"@opentelemetry/instrumentation-mysql@0.57.0": version "0.57.0" - resolved "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql2/-/instrumentation-mysql2-0.57.0.tgz" - integrity sha512-nHSrYAwF7+aV1E1V9yOOP9TchOodb6fjn4gFvdrdQXiRE7cMuffyLLbCZlZd4wsspBzVwOXX8mpURdRserAhNA== + resolved "https://registry.npmjs.org/@opentelemetry/instrumentation-mysql/-/instrumentation-mysql-0.57.0.tgz" + integrity sha512-HFS/+FcZ6Q7piM7Il7CzQ4VHhJvGMJWjx7EgCkP5AnTntSN5rb5Xi3TkYJHBKeR27A0QqPlGaCITi93fUDs++Q== dependencies: "@opentelemetry/instrumentation" "^0.211.0" "@opentelemetry/semantic-conventions" "^1.33.0" - "@opentelemetry/sql-common" "^0.41.2" + "@types/mysql" "2.15.27" "@opentelemetry/instrumentation-pg@0.63.0": version "0.63.0" @@ -3409,6 +3770,15 @@ "@opentelemetry/instrumentation" "^0.211.0" "@opentelemetry/semantic-conventions" "^1.24.0" +"@opentelemetry/instrumentation@0.211.0", "@opentelemetry/instrumentation@^0.211.0": + version "0.211.0" + resolved "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.211.0.tgz" + integrity sha512-h0nrZEC/zvI994nhg7EgQ8URIHt0uDTwN90r3qQUdZORS455bbx+YebnGeEuFghUT0HlJSrLF4iHw67f+odY+Q== + dependencies: + "@opentelemetry/api-logs" "0.211.0" + import-in-the-middle "^2.0.0" + require-in-the-middle "^8.0.0" + "@opentelemetry/instrumentation@^0.207.0": version "0.207.0" resolved "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.207.0.tgz" @@ -3427,21 +3797,12 @@ import-in-the-middle "^2.0.0" require-in-the-middle "^8.0.0" -"@opentelemetry/instrumentation@^0.211.0", "@opentelemetry/instrumentation@>=0.57.1 <1", "@opentelemetry/instrumentation@0.211.0": - version "0.211.0" - resolved "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.211.0.tgz" - integrity sha512-h0nrZEC/zvI994nhg7EgQ8URIHt0uDTwN90r3qQUdZORS455bbx+YebnGeEuFghUT0HlJSrLF4iHw67f+odY+Q== - dependencies: - "@opentelemetry/api-logs" "0.211.0" - import-in-the-middle "^2.0.0" - require-in-the-middle "^8.0.0" - "@opentelemetry/redis-common@^0.38.2": version "0.38.2" resolved "https://registry.npmjs.org/@opentelemetry/redis-common/-/redis-common-0.38.2.tgz" integrity sha512-1BCcU93iwSRZvDAgwUxC/DV4T/406SkMfxGqu5ojc3AvNI+I9GhV7v0J1HljsczuuhcnFLYqD5VmwVXfCGHzxA== -"@opentelemetry/resources@^1.30.1 || ^2.1.0", "@opentelemetry/resources@^2.5.0", "@opentelemetry/resources@^2.5.1", "@opentelemetry/resources@2.5.1": +"@opentelemetry/resources@2.5.1", "@opentelemetry/resources@^2.5.0", "@opentelemetry/resources@^2.5.1": version "2.5.1" resolved "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.5.1.tgz" integrity sha512-BViBCdE/GuXRlp9k7nS1w6wJvY5fnFX5XvuEtWsTAOQFIO89Eru7lGW3WbfbxtCuZ/GbrJfAziXG0w0dpxL7eQ== @@ -3449,7 +3810,7 @@ "@opentelemetry/core" "2.5.1" "@opentelemetry/semantic-conventions" "^1.29.0" -"@opentelemetry/sdk-trace-base@^1.30.1 || ^2.1.0", "@opentelemetry/sdk-trace-base@^2.5.0", "@opentelemetry/sdk-trace-base@^2.5.1": +"@opentelemetry/sdk-trace-base@^2.5.0", "@opentelemetry/sdk-trace-base@^2.5.1": version "2.5.1" resolved "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.5.1.tgz" integrity sha512-iZH3Gw8cxQn0gjpOjJMmKLd9GIaNh/E3v3ST67vyzLSxHBs14HsG4dy7jMYyC5WXGdBVEcM7U/XTF5hCQxjDMw== @@ -3629,7 +3990,12 @@ resolved "https://registry.npmjs.org/@pinojs/redact/-/redact-0.4.0.tgz" integrity sha512-k2ENnmBugE/rzQfEcdWHcCY+/FM3VLzH9cYEsbdsoqrvzAKRhUZeRNhAZvB8OitQJ1TBed3yqWtdjzS6wJKBwg== -"@playwright/test@^1.51.1", "@playwright/test@^1.56.0": +"@pkgjs/parseargs@^0.11.0": + version "0.11.0" + resolved "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz" + integrity sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg== + +"@playwright/test@^1.56.0": version "1.58.2" resolved "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz" integrity sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA== @@ -3643,7 +4009,7 @@ dependencies: cross-spawn "^7.0.6" -"@prisma/client@^6.11.1", "@prisma/client@^6.14": +"@prisma/client@^6.11.1": version "6.19.2" resolved "https://registry.npmjs.org/@prisma/client/-/client-6.19.2.tgz" integrity sha512-gR2EMvfK/aTxsuooaDA32D8v+us/8AAet+C3J1cc04SW35FPdZYgLF+iN4NDLUgAaUGTKdAB0CYenu1TAgGdMg== @@ -3805,7 +4171,7 @@ dependencies: "@radix-ui/react-primitive" "2.1.3" -"@radix-ui/react-collapsible@^1.1.12", "@radix-ui/react-collapsible@1.1.12": +"@radix-ui/react-collapsible@1.1.12", "@radix-ui/react-collapsible@^1.1.12": version "1.1.12" resolved "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz" integrity sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA== @@ -3859,12 +4225,12 @@ aria-hidden "^1.2.4" react-remove-scroll "^2.6.3" -"@radix-ui/react-direction@^1.1.1", "@radix-ui/react-direction@1.1.1": +"@radix-ui/react-direction@1.1.1", "@radix-ui/react-direction@^1.1.1": version "1.1.1" resolved "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz" integrity sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw== -"@radix-ui/react-dismissable-layer@^1.1.11", "@radix-ui/react-dismissable-layer@1.1.11": +"@radix-ui/react-dismissable-layer@1.1.11", "@radix-ui/react-dismissable-layer@^1.1.11": version "1.1.11" resolved "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz" integrity sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg== @@ -3961,7 +4327,7 @@ "@radix-ui/react-primitive" "2.1.3" "@radix-ui/react-use-layout-effect" "1.1.1" -"@radix-ui/react-presence@^1.1.5", "@radix-ui/react-presence@1.1.5": +"@radix-ui/react-presence@1.1.5", "@radix-ui/react-presence@^1.1.5": version "1.1.5" resolved "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz" integrity sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ== @@ -4006,13 +4372,6 @@ "@radix-ui/react-use-callback-ref" "1.1.1" "@radix-ui/react-use-layout-effect" "1.1.1" -"@radix-ui/react-slot@^1.2.4": - version "1.2.4" - resolved "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz" - integrity sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA== - dependencies: - "@radix-ui/react-compose-refs" "1.1.2" - "@radix-ui/react-slot@1.2.0": version "1.2.0" resolved "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.0.tgz" @@ -4027,21 +4386,14 @@ dependencies: "@radix-ui/react-compose-refs" "1.1.2" -"@radix-ui/react-tabs@^1.1.12": - version "1.1.13" - resolved "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz" - integrity sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A== +"@radix-ui/react-slot@^1.2.4": + version "1.2.4" + resolved "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz" + integrity sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA== dependencies: - "@radix-ui/primitive" "1.1.3" - "@radix-ui/react-context" "1.1.2" - "@radix-ui/react-direction" "1.1.1" - "@radix-ui/react-id" "1.1.1" - "@radix-ui/react-presence" "1.1.5" - "@radix-ui/react-primitive" "2.1.3" - "@radix-ui/react-roving-focus" "1.1.11" - "@radix-ui/react-use-controllable-state" "1.2.2" + "@radix-ui/react-compose-refs" "1.1.2" -"@radix-ui/react-tabs@^1.1.13": +"@radix-ui/react-tabs@^1.1.12", "@radix-ui/react-tabs@^1.1.13": version "1.1.13" resolved "https://registry.npmjs.org/@radix-ui/react-tabs/-/react-tabs-1.1.13.tgz" integrity sha512-7xdcatg7/U+7+Udyoj2zodtI9H/IIopqo+YOIcZOq1nJwXWBZ9p8xiu5llXlekDbZkca79a/fozEYQXIA4sW6A== @@ -4118,14 +4470,14 @@ resolved "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz" integrity sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw== -"@react-native-async-storage/async-storage@>=1.0.0", "@react-native-async-storage/async-storage@2.2.0": +"@react-native-async-storage/async-storage@2.2.0": version "2.2.0" resolved "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-2.2.0.tgz" integrity sha512-gvRvjR5JAaUZF8tv2Kcq/Gbt3JHwbKFYfmb445rhOj6NUMx3qPLixmDx5pZAyb9at1bYvJ4/eTUipU5aki45xw== dependencies: merge-options "^3.0.4" -"@react-native-masked-view/masked-view@>= 0.2.0", "@react-native-masked-view/masked-view@0.3.2": +"@react-native-masked-view/masked-view@0.3.2": version "0.3.2" resolved "https://registry.npmjs.org/@react-native-masked-view/masked-view/-/masked-view-0.3.2.tgz" integrity sha512-XwuQoW7/GEgWRMovOQtX3A4PrXhyaZm0lVUiY8qJDvdngjLms9Cpdck6SmGAUNqQwcj2EadHC1HwL0bEyoa/SQ== @@ -4287,21 +4639,21 @@ resolved "https://registry.npmjs.org/@react-native/js-polyfills/-/js-polyfills-0.81.4.tgz" integrity sha512-sr42FaypKXJHMVHhgSbu2f/ZJfrLzgaoQ+HdpRvKEiEh2mhFf6XzZwecyLBvWqf2pMPZa+CpPfNPiejXjKEy8w== -"@react-native/normalize-colors@*", "@react-native/normalize-colors@0.81.4": +"@react-native/normalize-colors@0.81.4": version "0.81.4" resolved "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.81.4.tgz" integrity sha512-9nRRHO1H+tcFqjb9gAM105Urtgcanbta2tuqCVY0NATHeFPDEAB7gPyiLxCHKMi1NbhP6TH0kxgSWXKZl1cyRg== -"@react-native/normalize-colors@^0.74.1": - version "0.74.89" - resolved "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.74.89.tgz" - integrity sha512-qoMMXddVKVhZ8PA1AbUCk83trpd6N+1nF2A6k1i6LsQObyS92fELuk8kU/lQs6M7BsMHwqyLCpQJ1uFgNvIQXg== - "@react-native/normalize-colors@0.81.5": version "0.81.5" resolved "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.81.5.tgz" integrity sha512-0HuJ8YtqlTVRXGZuGeBejLE04wSQsibpTI+RGOyVqxZvgtlLLC/Ssw0UmbHhT4lYMp2fhdtvKZSs5emWB1zR/g== +"@react-native/normalize-colors@^0.74.1": + version "0.74.89" + resolved "https://registry.npmjs.org/@react-native/normalize-colors/-/normalize-colors-0.74.89.tgz" + integrity sha512-qoMMXddVKVhZ8PA1AbUCk83trpd6N+1nF2A6k1i6LsQObyS92fELuk8kU/lQs6M7BsMHwqyLCpQJ1uFgNvIQXg== + "@react-native/virtualized-lists@0.81.4": version "0.81.4" resolved "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.81.4.tgz" @@ -4333,7 +4685,7 @@ use-latest-callback "^0.2.4" use-sync-external-store "^1.5.0" -"@react-navigation/drawer@^7.3.9", "@react-navigation/drawer@^7.5.0": +"@react-navigation/drawer@^7.3.9": version "7.7.13" resolved "https://registry.npmjs.org/@react-navigation/drawer/-/drawer-7.7.13.tgz" integrity sha512-O//bZbZLUYhYIZLE+1K3XU02nBnAJR/mbKhp4pH6QBFITy6JXlCsiXwE4vVNsxcn6Y5GBgo47goEVCy/gsaofw== @@ -4352,7 +4704,7 @@ use-latest-callback "^0.2.4" use-sync-external-store "^1.5.0" -"@react-navigation/native-stack@^7.3.16", "@react-navigation/native-stack@>=7.0.0": +"@react-navigation/native-stack@^7.3.16": version "7.11.0" resolved "https://registry.npmjs.org/@react-navigation/native-stack/-/native-stack-7.11.0.tgz" integrity sha512-yNx9Wr4dfpOHpqjf2sGog4eH6KCYwTAEPlUPrKbvWlQbCRm5bglwPmaTXw9hTovX9v3HIa42yo7bXpbYfq4jzg== @@ -4362,7 +4714,7 @@ sf-symbols-typescript "^2.1.0" warn-once "^0.1.1" -"@react-navigation/native@^7.1.28", "@react-navigation/native@^7.1.8", "@react-navigation/native@>= 5.0.0", "@react-navigation/native@>=6.0.0": +"@react-navigation/native@^7.1.8": version "7.1.28" resolved "https://registry.npmjs.org/@react-navigation/native/-/native-7.1.28.tgz" integrity sha512-d1QDn+KNHfHGt3UIwOZvupvdsDdiHYZBEj7+wL2yDVo3tMezamYy60H9s3EnNVE1Ae1ty0trc7F2OKqo/RmsdQ== @@ -4399,7 +4751,7 @@ dependencies: "@revenuecat/purchases-js" "1.24.3" -"@revenuecat/purchases-js@^1.11.1", "@revenuecat/purchases-js@1.24.3": +"@revenuecat/purchases-js@1.24.3", "@revenuecat/purchases-js@^1.11.1": version "1.24.3" resolved "https://registry.npmjs.org/@revenuecat/purchases-js/-/purchases-js-1.24.3.tgz" integrity sha512-shkbf0zBG23rabEMDnHh8RZt6dqTey0eU1YJyRLTAB0Mr7ZqfiND/8h8K9PQ8SNmwgdma+NuuEgRbyk/AeFr2A== @@ -4453,43 +4805,283 @@ dependencies: "@rollup/pluginutils" "^5.1.0" -"@rollup/plugin-node-resolve@^16.0.3": - version "16.0.3" - resolved "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz" - integrity sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg== - dependencies: - "@rollup/pluginutils" "^5.0.1" - "@types/resolve" "1.20.2" - deepmerge "^4.2.2" - is-module "^1.0.0" - resolve "^1.22.1" +"@rollup/plugin-node-resolve@^16.0.3": + version "16.0.3" + resolved "https://registry.npmjs.org/@rollup/plugin-node-resolve/-/plugin-node-resolve-16.0.3.tgz" + integrity sha512-lUYM3UBGuM93CnMPG1YocWu7X802BrNF3jW2zny5gQyLQgRFJhV1Sq0Zi74+dh/6NBx1DxFC4b4GXg9wUCG5Qg== + dependencies: + "@rollup/pluginutils" "^5.0.1" + "@types/resolve" "1.20.2" + deepmerge "^4.2.2" + is-module "^1.0.0" + resolve "^1.22.1" + +"@rollup/plugin-typescript@^12.1.2": + version "12.3.0" + resolved "https://registry.npmjs.org/@rollup/plugin-typescript/-/plugin-typescript-12.3.0.tgz" + integrity sha512-7DP0/p7y3t67+NabT9f8oTBFE6gGkto4SA6Np2oudYmZE/m1dt8RB0SjL1msMxFpLo631qjRCcBlAbq1ml/Big== + dependencies: + "@rollup/pluginutils" "^5.1.0" + resolve "^1.22.1" + +"@rollup/pluginutils@^5.0.1", "@rollup/pluginutils@^5.1.0": + version "5.3.0" + resolved "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz" + integrity sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q== + dependencies: + "@types/estree" "^1.0.0" + estree-walker "^2.0.2" + picomatch "^4.0.2" + +"@rollup/rollup-android-arm-eabi@4.57.0": + version "4.57.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.0.tgz#f762035679a6b168138c94c960fda0b0cdb00d98" + integrity sha512-tPgXB6cDTndIe1ah7u6amCI1T0SsnlOuKgg10Xh3uizJk4e5M1JGaUMk7J4ciuAUcFpbOiNhm2XIjP9ON0dUqA== + +"@rollup/rollup-android-arm-eabi@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.57.1.tgz#add5e608d4e7be55bc3ca3d962490b8b1890e088" + integrity sha512-A6ehUVSiSaaliTxai040ZpZ2zTevHYbvu/lDoeAteHI8QnaosIzm4qwtezfRg1jOYaUmnzLX1AOD6Z+UJjtifg== + +"@rollup/rollup-android-arm64@4.57.0": + version "4.57.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.0.tgz#1061ce0bfa6a6da361bda52a2949612769cd22ef" + integrity sha512-sa4LyseLLXr1onr97StkU1Nb7fWcg6niokTwEVNOO7awaKaoRObQ54+V/hrF/BP1noMEaaAW6Fg2d/CfLiq3Mg== + +"@rollup/rollup-android-arm64@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.57.1.tgz#10bd0382b73592beee6e9800a69401a29da625c4" + integrity sha512-dQaAddCY9YgkFHZcFNS/606Exo8vcLHwArFZ7vxXq4rigo2bb494/xKMMwRRQW6ug7Js6yXmBZhSBRuBvCCQ3w== + +"@rollup/rollup-darwin-arm64@4.57.0": + version "4.57.0" + resolved "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.0.tgz" + integrity sha512-/NNIj9A7yLjKdmkx5dC2XQ9DmjIECpGpwHoGmA5E1AhU0fuICSqSWScPhN1yLCkEdkCwJIDu2xIeLPs60MNIVg== + +"@rollup/rollup-darwin-arm64@4.57.1": + version "4.57.1" + resolved "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz" + integrity sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg== + +"@rollup/rollup-darwin-x64@4.57.0": + version "4.57.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.0.tgz#2a805303beb4cd44bfef993c39582cb0f1794f90" + integrity sha512-xoh8abqgPrPYPr7pTYipqnUi1V3em56JzE/HgDgitTqZBZ3yKCWI+7KUkceM6tNweyUKYru1UMi7FC060RyKwA== + +"@rollup/rollup-darwin-x64@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.57.1.tgz#69e741aeb2839d2e8f0da2ce7a33d8bd23632423" + integrity sha512-Ji8g8ChVbKrhFtig5QBV7iMaJrGtpHelkB3lsaKzadFBe58gmjfGXAOfI5FV0lYMH8wiqsxKQ1C9B0YTRXVy4w== + +"@rollup/rollup-freebsd-arm64@4.57.0": + version "4.57.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.0.tgz#7cf26a60d7245e9207a253ac07f11ddfcc47d622" + integrity sha512-PCkMh7fNahWSbA0OTUQ2OpYHpjZZr0hPr8lId8twD7a7SeWrvT3xJVyza+dQwXSSq4yEQTMoXgNOfMCsn8584g== + +"@rollup/rollup-freebsd-arm64@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.57.1.tgz#3736c232a999c7bef7131355d83ebdf9651a0839" + integrity sha512-R+/WwhsjmwodAcz65guCGFRkMb4gKWTcIeLy60JJQbXrJ97BOXHxnkPFrP+YwFlaS0m+uWJTstrUA9o+UchFug== + +"@rollup/rollup-freebsd-x64@4.57.0": + version "4.57.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.0.tgz#2b1acc1e624b47f676f526df30bb4357ea21f9b6" + integrity sha512-1j3stGx+qbhXql4OCDZhnK7b01s6rBKNybfsX+TNrEe9JNq4DLi1yGiR1xW+nL+FNVvI4D02PUnl6gJ/2y6WJA== + +"@rollup/rollup-freebsd-x64@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.57.1.tgz#227dcb8f466684070169942bd3998901c9bfc065" + integrity sha512-IEQTCHeiTOnAUC3IDQdzRAGj3jOAYNr9kBguI7MQAAZK3caezRrg0GxAb6Hchg4lxdZEI5Oq3iov/w/hnFWY9Q== + +"@rollup/rollup-linux-arm-gnueabihf@4.57.0": + version "4.57.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.0.tgz#1ba1ef444365a51687c7af2824b370791a1e3aaf" + integrity sha512-eyrr5W08Ms9uM0mLcKfM/Uzx7hjhz2bcjv8P2uynfj0yU8GGPdz8iYrBPhiLOZqahoAMB8ZiolRZPbbU2MAi6Q== + +"@rollup/rollup-linux-arm-gnueabihf@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.57.1.tgz#ba004b30df31b724f99ce66e7128248bea17cb0c" + integrity sha512-F8sWbhZ7tyuEfsmOxwc2giKDQzN3+kuBLPwwZGyVkLlKGdV1nvnNwYD0fKQ8+XS6hp9nY7B+ZeK01EBUE7aHaw== + +"@rollup/rollup-linux-arm-musleabihf@4.57.0": + version "4.57.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.0.tgz#e49863b683644bbbb9abc5b051c9b9d59774c3a0" + integrity sha512-Xds90ITXJCNyX9pDhqf85MKWUI4lqjiPAipJ8OLp8xqI2Ehk+TCVhF9rvOoN8xTbcafow3QOThkNnrM33uCFQA== + +"@rollup/rollup-linux-arm-musleabihf@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.57.1.tgz#6929f3e07be6b6da5991f63c6b68b3e473d0a65a" + integrity sha512-rGfNUfn0GIeXtBP1wL5MnzSj98+PZe/AXaGBCRmT0ts80lU5CATYGxXukeTX39XBKsxzFpEeK+Mrp9faXOlmrw== + +"@rollup/rollup-linux-arm64-gnu@4.57.0": + version "4.57.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.0.tgz#fda3bfd43d2390d2d99bc7d9617c2db2941da52b" + integrity sha512-Xws2KA4CLvZmXjy46SQaXSejuKPhwVdaNinldoYfqruZBaJHqVo6hnRa8SDo9z7PBW5x84SH64+izmldCgbezw== + +"@rollup/rollup-linux-arm64-gnu@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.57.1.tgz#06e89fd4a25d21fe5575d60b6f913c0e65297bfa" + integrity sha512-MMtej3YHWeg/0klK2Qodf3yrNzz6CGjo2UntLvk2RSPlhzgLvYEB3frRvbEF2wRKh1Z2fDIg9KRPe1fawv7C+g== + +"@rollup/rollup-linux-arm64-musl@4.57.0": + version "4.57.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.0.tgz#aea6199031404f80a0ccf33d5d3a63de53819da0" + integrity sha512-hrKXKbX5FdaRJj7lTMusmvKbhMJSGWJ+w++4KmjiDhpTgNlhYobMvKfDoIWecy4O60K6yA4SnztGuNTQF+Lplw== + +"@rollup/rollup-linux-arm64-musl@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.57.1.tgz#fddabf395b90990d5194038e6cd8c00156ed8ac0" + integrity sha512-1a/qhaaOXhqXGpMFMET9VqwZakkljWHLmZOX48R0I/YLbhdxr1m4gtG1Hq7++VhVUmf+L3sTAf9op4JlhQ5u1Q== + +"@rollup/rollup-linux-loong64-gnu@4.57.0": + version "4.57.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.0.tgz#f467333a5691f69a18295a7051e1cebb6815fdfe" + integrity sha512-6A+nccfSDGKsPm00d3xKcrsBcbqzCTAukjwWK6rbuAnB2bHaL3r9720HBVZ/no7+FhZLz/U3GwwZZEh6tOSI8Q== + +"@rollup/rollup-linux-loong64-gnu@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.57.1.tgz#04c10bb764bbf09a3c1bd90432e92f58d6603c36" + integrity sha512-QWO6RQTZ/cqYtJMtxhkRkidoNGXc7ERPbZN7dVW5SdURuLeVU7lwKMpo18XdcmpWYd0qsP1bwKPf7DNSUinhvA== + +"@rollup/rollup-linux-loong64-musl@4.57.0": + version "4.57.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.0.tgz#e46dffc29692caa743140636eb0d1d9a24ed0fc3" + integrity sha512-4P1VyYUe6XAJtQH1Hh99THxr0GKMMwIXsRNOceLrJnaHTDgk1FTcTimDgneRJPvB3LqDQxUmroBclQ1S0cIJwQ== + +"@rollup/rollup-linux-loong64-musl@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.57.1.tgz#f2450361790de80581d8687ea19142d8a4de5c0f" + integrity sha512-xpObYIf+8gprgWaPP32xiN5RVTi/s5FCR+XMXSKmhfoJjrpRAjCuuqQXyxUa/eJTdAE6eJ+KDKaoEqjZQxh3Gw== + +"@rollup/rollup-linux-ppc64-gnu@4.57.0": + version "4.57.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.0.tgz#be5b4494047ccbaadf1542fe9ac45b7788e73968" + integrity sha512-8Vv6pLuIZCMcgXre6c3nOPhE0gjz1+nZP6T+hwWjr7sVH8k0jRkH+XnfjjOTglyMBdSKBPPz54/y1gToSKwrSQ== + +"@rollup/rollup-linux-ppc64-gnu@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.57.1.tgz#0474f4667259e407eee1a6d38e29041b708f6a30" + integrity sha512-4BrCgrpZo4hvzMDKRqEaW1zeecScDCR+2nZ86ATLhAoJ5FQ+lbHVD3ttKe74/c7tNT9c6F2viwB3ufwp01Oh2w== + +"@rollup/rollup-linux-ppc64-musl@4.57.0": + version "4.57.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.0.tgz#b14ce2b0fe9c37fd0646ec3095087c1d64c791f4" + integrity sha512-r1te1M0Sm2TBVD/RxBPC6RZVwNqUTwJTA7w+C/IW5v9Ssu6xmxWEi+iJQlpBhtUiT1raJ5b48pI8tBvEjEFnFA== + +"@rollup/rollup-linux-ppc64-musl@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.57.1.tgz#9f32074819eeb1ddbe51f50ea9dcd61a6745ec33" + integrity sha512-NOlUuzesGauESAyEYFSe3QTUguL+lvrN1HtwEEsU2rOwdUDeTMJdO5dUYl/2hKf9jWydJrO9OL/XSSf65R5+Xw== + +"@rollup/rollup-linux-riscv64-gnu@4.57.0": + version "4.57.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.0.tgz#b78357f88ee7a34f677b118714594e37a2362a8c" + integrity sha512-say0uMU/RaPm3CDQLxUUTF2oNWL8ysvHkAjcCzV2znxBr23kFfaxocS9qJm+NdkRhF8wtdEEAJuYcLPhSPbjuQ== + +"@rollup/rollup-linux-riscv64-gnu@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.57.1.tgz#3fdb9d4b1e29fb6b6a6da9f15654d42eb77b99b2" + integrity sha512-ptA88htVp0AwUUqhVghwDIKlvJMD/fmL/wrQj99PRHFRAG6Z5nbWoWG4o81Nt9FT+IuqUQi+L31ZKAFeJ5Is+A== + +"@rollup/rollup-linux-riscv64-musl@4.57.0": + version "4.57.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.0.tgz#f44107ec0c30d691552c89eb3e4f287c33c56c3c" + integrity sha512-/MU7/HizQGsnBREtRpcSbSV1zfkoxSTR7wLsRmBPQ8FwUj5sykrP1MyJTvsxP5KBq9SyE6kH8UQQQwa0ASeoQQ== + +"@rollup/rollup-linux-riscv64-musl@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.57.1.tgz#1de780d64e6be0e3e8762035c22e0d8ea68df8ed" + integrity sha512-S51t7aMMTNdmAMPpBg7OOsTdn4tySRQvklmL3RpDRyknk87+Sp3xaumlatU+ppQ+5raY7sSTcC2beGgvhENfuw== + +"@rollup/rollup-linux-s390x-gnu@4.57.0": + version "4.57.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.0.tgz#ddb1cf80fb21b376a45a4e93ffdbeb15205d38f3" + integrity sha512-Q9eh+gUGILIHEaJf66aF6a414jQbDnn29zeu0eX3dHMuysnhTvsUvZTCAyZ6tJhUjnvzBKE4FtuaYxutxRZpOg== + +"@rollup/rollup-linux-s390x-gnu@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.57.1.tgz#1da022ffd2d9e9f0fd8344ea49e113001fbcac64" + integrity sha512-Bl00OFnVFkL82FHbEqy3k5CUCKH6OEJL54KCyx2oqsmZnFTR8IoNqBF+mjQVcRCT5sB6yOvK8A37LNm/kPJiZg== + +"@rollup/rollup-linux-x64-gnu@4.57.0": + version "4.57.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.0.tgz#0da46a644c87e1d8b13da5e2901037193caea8d3" + integrity sha512-OR5p5yG5OKSxHReWmwvM0P+VTPMwoBS45PXTMYaskKQqybkS3Kmugq1W+YbNWArF8/s7jQScgzXUhArzEQ7x0A== + +"@rollup/rollup-linux-x64-gnu@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.57.1.tgz#78c16eef9520bd10e1ea7a112593bb58e2842622" + integrity sha512-ABca4ceT4N+Tv/GtotnWAeXZUZuM/9AQyCyKYyKnpk4yoA7QIAuBt6Hkgpw8kActYlew2mvckXkvx0FfoInnLg== + +"@rollup/rollup-linux-x64-musl@4.57.0": + version "4.57.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.0.tgz#e561c93b6a23114a308396806551c25e28d3e303" + integrity sha512-XeatKzo4lHDsVEbm1XDHZlhYZZSQYym6dg2X/Ko0kSFgio+KXLsxwJQprnR48GvdIKDOpqWqssC3iBCjoMcMpw== + +"@rollup/rollup-linux-x64-musl@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.57.1.tgz#a7598591b4d9af96cb3167b50a5bf1e02dfea06c" + integrity sha512-HFps0JeGtuOR2convgRRkHCekD7j+gdAuXM+/i6kGzQtFhlCtQkpwtNzkNj6QhCDp7DRJ7+qC/1Vg2jt5iSOFw== + +"@rollup/rollup-openbsd-x64@4.57.0": + version "4.57.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.0.tgz#52490600775364a0476f26be7ddc416dfa11439b" + integrity sha512-Lu71y78F5qOfYmubYLHPcJm74GZLU6UJ4THkf/a1K7Tz2ycwC2VUbsqbJAXaR6Bx70SRdlVrt2+n5l7F0agTUw== + +"@rollup/rollup-openbsd-x64@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.57.1.tgz#c51d48c07cd6c466560e5bed934aec688ce02614" + integrity sha512-H+hXEv9gdVQuDTgnqD+SQffoWoc0Of59AStSzTEj/feWTBAnSfSD3+Dql1ZruJQxmykT/JVY0dE8Ka7z0DH1hw== + +"@rollup/rollup-openharmony-arm64@4.57.0": + version "4.57.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.0.tgz#c25988aae57bd21fa7d0fcb014ef85ec8987ad2c" + integrity sha512-v5xwKDWcu7qhAEcsUubiav7r+48Uk/ENWdr82MBZZRIm7zThSxCIVDfb3ZeRRq9yqk+oIzMdDo6fCcA5DHfMyA== + +"@rollup/rollup-openharmony-arm64@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.57.1.tgz#f09921d0b2a0b60afbf3586d2a7a7f208ba6df17" + integrity sha512-4wYoDpNg6o/oPximyc/NG+mYUejZrCU2q+2w6YZqrAs2UcNUChIZXjtafAiiZSUc7On8v5NyNj34Kzj/Ltk6dQ== + +"@rollup/rollup-win32-arm64-msvc@4.57.0": + version "4.57.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.0.tgz#572a8cd78442441121f1a6b5ad686ab723c31ae4" + integrity sha512-XnaaaSMGSI6Wk8F4KK3QP7GfuuhjGchElsVerCplUuxRIzdvZ7hRBpLR0omCmw+kI2RFJB80nenhOoGXlJ5TfQ== + +"@rollup/rollup-win32-arm64-msvc@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.57.1.tgz#08d491717135376e4a99529821c94ecd433d5b36" + integrity sha512-O54mtsV/6LW3P8qdTcamQmuC990HDfR71lo44oZMZlXU4tzLrbvTii87Ni9opq60ds0YzuAlEr/GNwuNluZyMQ== + +"@rollup/rollup-win32-ia32-msvc@4.57.0": + version "4.57.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.0.tgz#431fa95c0be8377907fe4e7070aaa4016c7b7e3b" + integrity sha512-3K1lP+3BXY4t4VihLw5MEg6IZD3ojSYzqzBG571W3kNQe4G4CcFpSUQVgurYgib5d+YaCjeFow8QivWp8vuSvA== -"@rollup/plugin-typescript@^12.1.2": - version "12.3.0" - resolved "https://registry.npmjs.org/@rollup/plugin-typescript/-/plugin-typescript-12.3.0.tgz" - integrity sha512-7DP0/p7y3t67+NabT9f8oTBFE6gGkto4SA6Np2oudYmZE/m1dt8RB0SjL1msMxFpLo631qjRCcBlAbq1ml/Big== - dependencies: - "@rollup/pluginutils" "^5.1.0" - resolve "^1.22.1" +"@rollup/rollup-win32-ia32-msvc@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.57.1.tgz#b0c12aac1104a8b8f26a5e0098e5facbb3e3964a" + integrity sha512-P3dLS+IerxCT/7D2q2FYcRdWRl22dNbrbBEtxdWhXrfIMPP9lQhb5h4Du04mdl5Woq05jVCDPCMF7Ub0NAjIew== -"@rollup/pluginutils@^5.0.1", "@rollup/pluginutils@^5.1.0": - version "5.3.0" - resolved "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-5.3.0.tgz" - integrity sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q== - dependencies: - "@types/estree" "^1.0.0" - estree-walker "^2.0.2" - picomatch "^4.0.2" +"@rollup/rollup-win32-x64-gnu@4.57.0": + version "4.57.0" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.0.tgz#19db67feb9c5fe09b1358efd1d97c5f6b299d347" + integrity sha512-MDk610P/vJGc5L5ImE4k5s+GZT3en0KoK1MKPXCRgzmksAMk79j4h3k1IerxTNqwDLxsGxStEZVBqG0gIqZqoA== -"@rollup/rollup-darwin-arm64@4.57.0": +"@rollup/rollup-win32-x64-gnu@4.57.1": + version "4.57.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.57.1.tgz#b9cccef26f5e6fdc013bf3c0911a3c77428509d0" + integrity sha512-VMBH2eOOaKGtIJYleXsi2B8CPVADrh+TyNxJ4mWPnKfLB/DBUmzW+5m1xUrcwWoMfSLagIRpjUFeW5CO5hyciQ== + +"@rollup/rollup-win32-x64-msvc@4.57.0": version "4.57.0" - resolved "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.0.tgz" - integrity sha512-/NNIj9A7yLjKdmkx5dC2XQ9DmjIECpGpwHoGmA5E1AhU0fuICSqSWScPhN1yLCkEdkCwJIDu2xIeLPs60MNIVg== + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.0.tgz#6f38851da1123ac0380121108abd31ff21205c3d" + integrity sha512-Zv7v6q6aV+VslnpwzqKAmrk5JdVkLUzok2208ZXGipjb+msxBr/fJPZyeEXiFgH7k62Ak0SLIfxQRZQvTuf7rQ== -"@rollup/rollup-darwin-arm64@4.57.1": +"@rollup/rollup-win32-x64-msvc@4.57.1": version "4.57.1" - resolved "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.57.1.tgz" - integrity sha512-crNPrwJOrRxagUYeMn/DZwqN88SDmwaJ8Cvi/TN1HnWBU7GwknckyosC2gd0IqYRsHDEnXf328o9/HC6OkPgOg== + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.57.1.tgz#a03348e7b559c792b6277cc58874b89ef46e1e72" + integrity sha512-mxRFDdHIWRxg3UfIIAwCm6NzvxG0jDX/wBN6KsQFTvKFqqg9vTrWUE68qEjHt19A5wwx5X5aUi2zuZT7YR0jrA== "@sentry-internal/browser-utils@10.39.0": version "10.39.0" @@ -4555,24 +5147,75 @@ resolved "https://registry.npmjs.org/@sentry/cli-darwin/-/cli-darwin-3.2.2.tgz" integrity sha512-y1uglMBbo9dYqC92hTQBkuGk7SegLPo1cVwJzX0dhplJoBMuanLMhOMYd1J20qhkDdBhguflCHGf0tOzNTGWhg== -"@sentry/cli@^3.2.2": +"@sentry/cli-linux-arm64@3.2.0": + version "3.2.0" + resolved "https://registry.yarnpkg.com/@sentry/cli-linux-arm64/-/cli-linux-arm64-3.2.0.tgz#767e4f8328ad17570ef9fd4b95fa91e1c3bf057a" + integrity sha512-YFpYIfYpXYzpSrIspmPOOPjXbxWp/Ve+F2hSIQb1tSKmcxFgaUm6WfuniYF8EIaLNa8YOLECf0xo78yGXBa+ug== + +"@sentry/cli-linux-arm64@3.2.2": version "3.2.2" - resolved "https://registry.npmjs.org/@sentry/cli/-/cli-3.2.2.tgz" - integrity sha512-qmjsm9+Bq/3QGTnIfOsJdhq+8LI3imxAPbGNBpRj4R0YFk+b1ry9huRHCLgkMcRFWtPkJmGZwEq2Z7e+02QPLA== - dependencies: - progress "^2.0.3" - proxy-from-env "^1.1.0" - undici "^6.22.0" - which "^2.0.2" - optionalDependencies: - "@sentry/cli-darwin" "3.2.2" - "@sentry/cli-linux-arm" "3.2.2" - "@sentry/cli-linux-arm64" "3.2.2" - "@sentry/cli-linux-i686" "3.2.2" - "@sentry/cli-linux-x64" "3.2.2" - "@sentry/cli-win32-arm64" "3.2.2" - "@sentry/cli-win32-i686" "3.2.2" - "@sentry/cli-win32-x64" "3.2.2" + resolved "https://registry.yarnpkg.com/@sentry/cli-linux-arm64/-/cli-linux-arm64-3.2.2.tgz#bc1e1bca7e7b7dc21dc0430621899f6270f80402" + integrity sha512-SIGJknEQNDw9S/8QPTl8QLVe2IEiTKH3NeeHQ/Q2XWXig1ZebJfm4iTrdu47ypszIfxHeLvQkkVrr8mRKq16xA== + +"@sentry/cli-linux-arm@3.2.0": + version "3.2.0" + resolved "https://registry.yarnpkg.com/@sentry/cli-linux-arm/-/cli-linux-arm-3.2.0.tgz#8b6eaa900849e5b6d1675cd19c42155bd7c6b9b5" + integrity sha512-NVcGcS9mceivVhi8W6iEnlcK96OlPMzOdM6xkIm+7+J0uXHgUyMyc+rT6p2QG3j8jI5I7qAl7fTHWluSX2lx3g== + +"@sentry/cli-linux-arm@3.2.2": + version "3.2.2" + resolved "https://registry.yarnpkg.com/@sentry/cli-linux-arm/-/cli-linux-arm-3.2.2.tgz#14628eb95c4bb9c8d37f685248f4275fd991c76e" + integrity sha512-CC7N3hjOgs3cwrW0T9hqirFVUpKO6ASjdd0JT4DQHaAn34pruv8J+OoSnj1jkrT2DHxDkNNZPOFSK05AnHr8wA== + +"@sentry/cli-linux-i686@3.2.0": + version "3.2.0" + resolved "https://registry.yarnpkg.com/@sentry/cli-linux-i686/-/cli-linux-i686-3.2.0.tgz#4a745785cd3f1777b7686c8c3d487ae2701b4fdf" + integrity sha512-6SOxlF37NqRRKxEuvWryBM7MAgTisq0G6ZQzhI2iuWbcrmlLUDUW2Yssz3gAR01eYJ4jfpqvojSzPRDwqB0K+Q== + +"@sentry/cli-linux-i686@3.2.2": + version "3.2.2" + resolved "https://registry.yarnpkg.com/@sentry/cli-linux-i686/-/cli-linux-i686-3.2.2.tgz#cf764010230f310f9dbc313cec250b4e61ab97ad" + integrity sha512-W2hQ2DvIlZI05j2JN/87lfeo51F24zmQOJU6Uz+fZz/mkSvpnjeWxjAvfDNVGlLxp7XSoDbhHfrLBxdIh6jMeg== + +"@sentry/cli-linux-x64@3.2.0": + version "3.2.0" + resolved "https://registry.yarnpkg.com/@sentry/cli-linux-x64/-/cli-linux-x64-3.2.0.tgz#90081c9c8fb11112530350429162935a6d36b83e" + integrity sha512-7LRd0A74ma/1ejlTRPOoBiFiJr4LWkoE6CnA2XwAoYd0r62WUjGHtg6gIC+yElOCwtxXP7I9fOiV2BJIAq2DHw== + +"@sentry/cli-linux-x64@3.2.2": + version "3.2.2" + resolved "https://registry.yarnpkg.com/@sentry/cli-linux-x64/-/cli-linux-x64-3.2.2.tgz#7ced3f60c508c8ef8ecc71a9d9f925a370d084f8" + integrity sha512-4mh3yvOUxO63lq3teexRvalD1mWaRVjpgL2cCMKA2wkB69lcL5nK2gkdzDUKx2y/elluVdvGPPZaqOr1bfNI0w== + +"@sentry/cli-win32-arm64@3.2.0": + version "3.2.0" + resolved "https://registry.yarnpkg.com/@sentry/cli-win32-arm64/-/cli-win32-arm64-3.2.0.tgz#12a92d0d3c43e0e258530a6477cfad697dc46a59" + integrity sha512-uKRi/++gnGepsaGi4goIcC13YGNEHgOUcROkz4L3H8vAR7QxWv2SHy7SCN+2S6c+Q2mOd0DfY35cMW5MzTpb7g== + +"@sentry/cli-win32-arm64@3.2.2": + version "3.2.2" + resolved "https://registry.yarnpkg.com/@sentry/cli-win32-arm64/-/cli-win32-arm64-3.2.2.tgz#a884f8d9c18d2fdbcdb0a3c61c3bccaa662007a6" + integrity sha512-TQgfkdJgd8Y/lPzDibqc5Hamg8Hl5rN1sZwX80n4r9Ly46Yzu8Bv6KUhoNL/ktAvw9Aeko6Bx54rwZnzxFZHwg== + +"@sentry/cli-win32-i686@3.2.0": + version "3.2.0" + resolved "https://registry.yarnpkg.com/@sentry/cli-win32-i686/-/cli-win32-i686-3.2.0.tgz#952dd24be42c129821d302d4a98bbaa7197a19a8" + integrity sha512-/84NEPeFQne2bQWfDZ+3EzioxFR8ojSwExCgVsyTfqa/4PzmaerAGUAxiD+VcJLJTyJEHAjWXTmSZGbYjIgdyQ== + +"@sentry/cli-win32-i686@3.2.2": + version "3.2.2" + resolved "https://registry.yarnpkg.com/@sentry/cli-win32-i686/-/cli-win32-i686-3.2.2.tgz#7470e16be9abe4b55f97f1e58bd66b765c3f2b29" + integrity sha512-vAcnq0SdYuvwIdREgF5APocjW3d9Z17xLwugpaAz8wpOjCeC1iMEFWqbz5k49i4iDkDVNFRMENiVvWVSu1kEnA== + +"@sentry/cli-win32-x64@3.2.0": + version "3.2.0" + resolved "https://registry.yarnpkg.com/@sentry/cli-win32-x64/-/cli-win32-x64-3.2.0.tgz#3479f637887a9c28ebf33f930d45d057cb8c2a3f" + integrity sha512-N9qGGT91awsh4IMyXrRHH+qIWmv9MJk9tPPAWPvLMWgdlTHT/40WYwvA4tYj80uZXU0TN8ppBWbd3ebDyOjwxA== + +"@sentry/cli-win32-x64@3.2.2": + version "3.2.2" + resolved "https://registry.yarnpkg.com/@sentry/cli-win32-x64/-/cli-win32-x64-3.2.2.tgz#87db0a4b6de30c9b27d92a30b379d954e00c6204" + integrity sha512-xWPTXjSSdmoyG/0ee7A9KSfsScGHCdaXMP6ASt4bMx3yYJO7ziEoZzfJE2M6oglz+woAm0LV9+O/n7g80tixlQ== "@sentry/cli@3.2.0": version "3.2.0" @@ -4593,6 +5236,25 @@ "@sentry/cli-win32-i686" "3.2.0" "@sentry/cli-win32-x64" "3.2.0" +"@sentry/cli@^3.2.2": + version "3.2.2" + resolved "https://registry.npmjs.org/@sentry/cli/-/cli-3.2.2.tgz" + integrity sha512-qmjsm9+Bq/3QGTnIfOsJdhq+8LI3imxAPbGNBpRj4R0YFk+b1ry9huRHCLgkMcRFWtPkJmGZwEq2Z7e+02QPLA== + dependencies: + progress "^2.0.3" + proxy-from-env "^1.1.0" + undici "^6.22.0" + which "^2.0.2" + optionalDependencies: + "@sentry/cli-darwin" "3.2.2" + "@sentry/cli-linux-arm" "3.2.2" + "@sentry/cli-linux-arm64" "3.2.2" + "@sentry/cli-linux-i686" "3.2.2" + "@sentry/cli-linux-x64" "3.2.2" + "@sentry/cli-win32-arm64" "3.2.2" + "@sentry/cli-win32-i686" "3.2.2" + "@sentry/cli-win32-x64" "3.2.2" + "@sentry/core@10.39.0": version "10.39.0" resolved "https://registry.npmjs.org/@sentry/core/-/core-10.39.0.tgz" @@ -4835,7 +5497,7 @@ dependencies: tslib "2.8.1" -"@shopify/react-native-skia@*", "@shopify/react-native-skia@2.2.12": +"@shopify/react-native-skia@2.2.12": version "2.2.12" resolved "https://registry.npmjs.org/@shopify/react-native-skia/-/react-native-skia-2.2.12.tgz" integrity sha512-P5wZSMPTp00hM0do+awNFtb5aPh5hSpodMGwy7NaxK90AV+SmUu7wZe6NGevzQIwgFa89Epn6xK3j4jKWdQi+A== @@ -4927,6 +5589,73 @@ source-map-js "^1.2.1" tailwindcss "4.1.18" +"@tailwindcss/oxide-android-arm64@4.1.18": + version "4.1.18" + resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.1.18.tgz#79717f87e90135e5d3d23a3d3aecde4ca5595dd5" + integrity sha512-dJHz7+Ugr9U/diKJA0W6N/6/cjI+ZTAoxPf9Iz9BFRF2GzEX8IvXxFIi/dZBloVJX/MZGvRuFA9rqwdiIEZQ0Q== + +"@tailwindcss/oxide-darwin-arm64@4.1.18": + version "4.1.18" + resolved "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.1.18.tgz" + integrity sha512-Gc2q4Qhs660bhjyBSKgq6BYvwDz4G+BuyJ5H1xfhmDR3D8HnHCmT/BSkvSL0vQLy/nkMLY20PQ2OoYMO15Jd0A== + +"@tailwindcss/oxide-darwin-x64@4.1.18": + version "4.1.18" + resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.1.18.tgz#c05991c85aa2af47bf9d1f8172fe9e4636591e79" + integrity sha512-FL5oxr2xQsFrc3X9o1fjHKBYBMD1QZNyc1Xzw/h5Qu4XnEBi3dZn96HcHm41c/euGV+GRiXFfh2hUCyKi/e+yw== + +"@tailwindcss/oxide-freebsd-x64@4.1.18": + version "4.1.18" + resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.1.18.tgz#3d48e8d79fd08ece0e02af8e72d5059646be34d0" + integrity sha512-Fj+RHgu5bDodmV1dM9yAxlfJwkkWvLiRjbhuO2LEtwtlYlBgiAT4x/j5wQr1tC3SANAgD+0YcmWVrj8R9trVMA== + +"@tailwindcss/oxide-linux-arm-gnueabihf@4.1.18": + version "4.1.18" + resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.1.18.tgz#982ecd1a65180807ccfde67dc17c6897f2e50aa8" + integrity sha512-Fp+Wzk/Ws4dZn+LV2Nqx3IilnhH51YZoRaYHQsVq3RQvEl+71VGKFpkfHrLM/Li+kt5c0DJe/bHXK1eHgDmdiA== + +"@tailwindcss/oxide-linux-arm64-gnu@4.1.18": + version "4.1.18" + resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.1.18.tgz#df49357bc9737b2e9810ea950c1c0647ba6573c3" + integrity sha512-S0n3jboLysNbh55Vrt7pk9wgpyTTPD0fdQeh7wQfMqLPM/Hrxi+dVsLsPrycQjGKEQk85Kgbx+6+QnYNiHalnw== + +"@tailwindcss/oxide-linux-arm64-musl@4.1.18": + version "4.1.18" + resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.1.18.tgz#b266c12822bf87883cf152615f8fffb8519d689c" + integrity sha512-1px92582HkPQlaaCkdRcio71p8bc8i/ap5807tPRDK/uw953cauQBT8c5tVGkOwrHMfc2Yh6UuxaH4vtTjGvHg== + +"@tailwindcss/oxide-linux-x64-gnu@4.1.18": + version "4.1.18" + resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.1.18.tgz#5c737f13dd9529b25b314e6000ff54e05b3811da" + integrity sha512-v3gyT0ivkfBLoZGF9LyHmts0Isc8jHZyVcbzio6Wpzifg/+5ZJpDiRiUhDLkcr7f/r38SWNe7ucxmGW3j3Kb/g== + +"@tailwindcss/oxide-linux-x64-musl@4.1.18": + version "4.1.18" + resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.1.18.tgz#3380e17f7be391f1ef924be9f0afe1f304fe3478" + integrity sha512-bhJ2y2OQNlcRwwgOAGMY0xTFStt4/wyU6pvI6LSuZpRgKQwxTec0/3Scu91O8ir7qCR3AuepQKLU/kX99FouqQ== + +"@tailwindcss/oxide-wasm32-wasi@4.1.18": + version "4.1.18" + resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.1.18.tgz#9464df0e28a499aab1c55e97682be37b3a656c88" + integrity sha512-LffYTvPjODiP6PT16oNeUQJzNVyJl1cjIebq/rWWBF+3eDst5JGEFSc5cWxyRCJ0Mxl+KyIkqRxk1XPEs9x8TA== + dependencies: + "@emnapi/core" "^1.7.1" + "@emnapi/runtime" "^1.7.1" + "@emnapi/wasi-threads" "^1.1.0" + "@napi-rs/wasm-runtime" "^1.1.0" + "@tybys/wasm-util" "^0.10.1" + tslib "^2.4.0" + +"@tailwindcss/oxide-win32-arm64-msvc@4.1.18": + version "4.1.18" + resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.1.18.tgz#bbcdd59c628811f6a0a4d5b09616967d8fb0c4d4" + integrity sha512-HjSA7mr9HmC8fu6bdsZvZ+dhjyGCLdotjVOgLA2vEqxEBZaQo9YTX4kwgEvPCpRh8o4uWc4J/wEoFzhEmjvPbA== + +"@tailwindcss/oxide-win32-x64-msvc@4.1.18": + version "4.1.18" + resolved "https://registry.yarnpkg.com/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.1.18.tgz#9c628d04623aa4c3536c508289f58d58ba4b3fb1" + integrity sha512-bJWbyYpUlqamC8dpR7pfjA0I7vdF6t5VpUGMWRkXVE3AXgIZjYUYAK7II1GNaxR8J1SSrSrppRar8G++JekE3Q== + "@tailwindcss/oxide@4.1.18": version "4.1.18" resolved "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.1.18.tgz" @@ -4956,11 +5685,66 @@ postcss "^8.4.41" tailwindcss "4.1.18" -"@tauri-apps/api@^2.8.0", "@tauri-apps/api@~2.8.0": +"@tauri-apps/api@2.8.0", "@tauri-apps/api@^2.8.0", "@tauri-apps/api@~2.8.0": version "2.8.0" - resolved "https://registry.npmjs.org/@tauri-apps/api/-/api-2.8.0.tgz" + resolved "https://registry.yarnpkg.com/@tauri-apps/api/-/api-2.8.0.tgz#0348a2b3ba5982ec67a7d569f329b4a55d7d5f1e" integrity sha512-ga7zdhbS2GXOMTIZRT0mYjKJtR9fivsXzsyq5U3vjDL0s6DTMwYRm0UHNjzTY5dh4+LSC68Sm/7WEiimbQNYlw== +"@tauri-apps/cli-darwin-arm64@2.8.4": + version "2.8.4" + resolved "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.8.4.tgz" + integrity sha512-BKu8HRkYV01SMTa7r4fLx+wjgtRK8Vep7lmBdHDioP6b8XH3q2KgsAyPWfEZaZIkZ2LY4SqqGARaE9oilNe0oA== + +"@tauri-apps/cli-darwin-x64@2.8.4": + version "2.8.4" + resolved "https://registry.yarnpkg.com/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.8.4.tgz#b9c274fedce570da1910559add68657d264019db" + integrity sha512-imb9PfSd/7G6VAO7v1bQ2A3ZH4NOCbhGJFLchxzepGcXf9NKkfun157JH9mko29K6sqAwuJ88qtzbKCbWJTH9g== + +"@tauri-apps/cli-linux-arm-gnueabihf@2.8.4": + version "2.8.4" + resolved "https://registry.yarnpkg.com/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.8.4.tgz#657131a05f422b9141277f0668d370e8d671bdc0" + integrity sha512-Ml215UnDdl7/fpOrF1CNovym/KjtUbCuPgrcZ4IhqUCnhZdXuphud/JT3E8X97Y03TZ40Sjz8raXYI2ET0exzw== + +"@tauri-apps/cli-linux-arm64-gnu@2.8.4": + version "2.8.4" + resolved "https://registry.yarnpkg.com/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.8.4.tgz#35a14541e09b6548b811626d1a5d2574932116ef" + integrity sha512-pbcgBpMyI90C83CxE5REZ9ODyIlmmAPkkJXtV398X3SgZEIYy5TACYqlyyv2z5yKgD8F8WH4/2fek7+jH+ZXAw== + +"@tauri-apps/cli-linux-arm64-musl@2.8.4": + version "2.8.4" + resolved "https://registry.yarnpkg.com/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.8.4.tgz#bdd9deea17e0c2e4edf511a071c87670616af8a3" + integrity sha512-zumFeaU1Ws5Ay872FTyIm7z8kfzEHu8NcIn8M6TxbJs0a7GRV21KBdpW1zNj2qy7HynnpQCqjAYXTUUmm9JAOw== + +"@tauri-apps/cli-linux-riscv64-gnu@2.8.4": + version "2.8.4" + resolved "https://registry.yarnpkg.com/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.8.4.tgz#ac3c751ce5727fbd1da280f0aa2fb444fcd706b5" + integrity sha512-qiqbB3Zz6IyO201f+1ojxLj65WYj8mixL5cOMo63nlg8CIzsP23cPYUrx1YaDPsCLszKZo7tVs14pc7BWf+/aQ== + +"@tauri-apps/cli-linux-x64-gnu@2.8.4": + version "2.8.4" + resolved "https://registry.yarnpkg.com/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.8.4.tgz#7b2000b5e6597dc62f48cb67ee98f61b54493a19" + integrity sha512-TaqaDd9Oy6k45Hotx3pOf+pkbsxLaApv4rGd9mLuRM1k6YS/aw81YrsMryYPThrxrScEIUcmNIHaHsLiU4GMkw== + +"@tauri-apps/cli-linux-x64-musl@2.8.4": + version "2.8.4" + resolved "https://registry.yarnpkg.com/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.8.4.tgz#5dd9f6e666e004e00313d86a5d71480f7ac1269a" + integrity sha512-ot9STAwyezN8w+bBHZ+bqSQIJ0qPZFlz/AyscpGqB/JnJQVDFQcRDmUPFEaAtt2UUHSWzN3GoTJ5ypqLBp2WQA== + +"@tauri-apps/cli-win32-arm64-msvc@2.8.4": + version "2.8.4" + resolved "https://registry.yarnpkg.com/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.8.4.tgz#75eadbad9ae5726cc53139bbeae4c4a8fc4a92be" + integrity sha512-+2aJ/g90dhLiOLFSD1PbElXX3SoMdpO7HFPAZB+xot3CWlAZD1tReUFy7xe0L5GAR16ZmrxpIDM9v9gn5xRy/w== + +"@tauri-apps/cli-win32-ia32-msvc@2.8.4": + version "2.8.4" + resolved "https://registry.yarnpkg.com/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.8.4.tgz#60e6cdad4fb59d91a194652581742d08c952f0a7" + integrity sha512-yj7WDxkL1t9Uzr2gufQ1Hl7hrHuFKTNEOyascbc109EoiAqCp0tgZ2IykQqOZmZOHU884UAWI1pVMqBhS/BfhA== + +"@tauri-apps/cli-win32-x64-msvc@2.8.4": + version "2.8.4" + resolved "https://registry.yarnpkg.com/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.8.4.tgz#44461625197c531537ccba84b64c804a0d7228ae" + integrity sha512-XuvGB4ehBdd7QhMZ9qbj/8icGEatDuBNxyYHbLKsTYh90ggUlPa/AtaqcC1Fo69lGkTmq9BOKrs1aWSi7xDonA== + "@tauri-apps/cli@~2.8.2": version "2.8.4" resolved "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.8.4.tgz" @@ -4985,6 +5769,13 @@ dependencies: "@tauri-apps/api" "^2.8.0" +"@tauri-apps/plugin-notification@^2.3.1": + version "2.3.3" + resolved "https://registry.yarnpkg.com/@tauri-apps/plugin-notification/-/plugin-notification-2.3.3.tgz#01581e3abd3bd18200121b213d3e6ebff2967fe2" + integrity sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg== + dependencies: + "@tauri-apps/api" "^2.8.0" + "@tootallnate/quickjs-emscripten@^0.23.0": version "0.23.0" resolved "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz" @@ -5010,6 +5801,20 @@ resolved "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz" integrity sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA== +"@tybys/wasm-util@^0.10.1": + version "0.10.1" + resolved "https://registry.yarnpkg.com/@tybys/wasm-util/-/wasm-util-0.10.1.tgz#ecddd3205cf1e2d5274649ff0eedd2991ed7f414" + integrity sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg== + dependencies: + tslib "^2.4.0" + +"@types/archiver@^7.0.0": + version "7.0.0" + resolved "https://registry.npmjs.org/@types/archiver/-/archiver-7.0.0.tgz" + integrity sha512-/3vwGwx9n+mCQdYZ2IKGGHEFL30I96UgBlk8EtRDDFQ9uxM1l4O5Ci6r00EMAkiDaTqD9DQ6nVrWRICnBPtzzg== + dependencies: + "@types/readdir-glob" "*" + "@types/aws-lambda@^8.10.83": version "8.10.160" resolved "https://registry.npmjs.org/@types/aws-lambda/-/aws-lambda-8.10.160.tgz" @@ -5312,7 +6117,7 @@ "@types/d3-transition" "*" "@types/d3-zoom" "*" -"@types/debug@^4.0.0", "@types/debug@^4.1.12": +"@types/debug@^4.0.0": version "4.1.12" resolved "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz" integrity sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ== @@ -5331,7 +6136,7 @@ dependencies: "@types/estree" "*" -"@types/estree@*", "@types/estree@^1.0.0", "@types/estree@^1.0.6", "@types/estree@1.0.8": +"@types/estree@*", "@types/estree@1.0.8", "@types/estree@^1.0.0", "@types/estree@^1.0.6": version "1.0.8" resolved "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz" integrity sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w== @@ -5484,13 +6289,20 @@ dependencies: "@types/node" "*" -"@types/node@*", "@types/node@^18.0.0 || ^20.0.0 || >=22.0.0", "@types/node@^20.19.0 || >=22.12.0", "@types/node@>=10.0.0", "@types/node@>=18", "@types/node@>=20": +"@types/node@*", "@types/node@>=10.0.0", "@types/node@>=20": version "25.0.10" resolved "https://registry.npmjs.org/@types/node/-/node-25.0.10.tgz" integrity sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg== dependencies: undici-types "~7.16.0" +"@types/node@>=13.7.0": + version "25.2.3" + resolved "https://registry.npmjs.org/@types/node/-/node-25.2.3.tgz" + integrity sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ== + dependencies: + undici-types "~7.16.0" + "@types/node@^20.12.3": version "20.19.30" resolved "https://registry.npmjs.org/@types/node/-/node-20.19.30.tgz" @@ -5505,13 +6317,6 @@ dependencies: undici-types "~7.16.0" -"@types/node@>=13.7.0": - version "25.2.3" - resolved "https://registry.npmjs.org/@types/node/-/node-25.2.3.tgz" - integrity sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ== - dependencies: - undici-types "~7.16.0" - "@types/parse-path@^7.0.0": version "7.0.3" resolved "https://registry.npmjs.org/@types/parse-path/-/parse-path-7.0.3.tgz" @@ -5571,7 +6376,7 @@ resolved "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz" integrity sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ== -"@types/react-dom@*", "@types/react-dom@^19.2.3": +"@types/react-dom@^19.2.3": version "19.2.3" resolved "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz" integrity sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ== @@ -5583,26 +6388,26 @@ dependencies: "@types/react" "*" -"@types/react@*", "@types/react@^19.2.0", "@types/react@^19.2.9": +"@types/react@*", "@types/react@^19.2.7", "@types/react@^19.2.9": version "19.2.10" resolved "https://registry.npmjs.org/@types/react/-/react-19.2.10.tgz" integrity sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw== dependencies: csstype "^3.2.2" -"@types/react@^19.1.0", "@types/react@>=18.0.0", "@types/react@~19.1.10": +"@types/react@~19.1.10": version "19.1.17" resolved "https://registry.npmjs.org/@types/react/-/react-19.1.17.tgz" integrity sha512-Qec1E3mhALmaspIrhWt9jkQMNdw6bReVu64mjvhbhq2NFPftLPVr+l1SZgmw/66WwBNpDh7ao5AT6gF5v41PFA== dependencies: csstype "^3.0.2" -"@types/react@^19.2.7", "@types/react@>=19.0.0": - version "19.2.10" - resolved "https://registry.npmjs.org/@types/react/-/react-19.2.10.tgz" - integrity sha512-WPigyYuGhgZ/cTPRXB2EwUw+XvsRA3GqHlsP4qteqrnnjDrApbS7MxcGr/hke5iUoeB7E/gQtrs9I37zAJ0Vjw== +"@types/readdir-glob@*": + version "1.1.5" + resolved "https://registry.npmjs.org/@types/readdir-glob/-/readdir-glob-1.1.5.tgz" + integrity sha512-raiuEPUYqXu+nvtY2Pe8s8FEmZ3x5yAH4VkLdihcPdalvsHltomrRC9BzuStrJ9yk06470hS0Crw0f1pXqD+Hg== dependencies: - csstype "^3.2.2" + "@types/node" "*" "@types/resolve@1.20.2": version "1.20.2" @@ -5662,6 +6467,11 @@ resolved "https://registry.npmjs.org/@types/tmp/-/tmp-0.2.6.tgz" integrity sha512-chhaNf2oKHlRkDGt+tiKE2Z5aJ6qalm7Z9rlLdBwmOiAAf09YQvvoLXjWK4HWPF1xU/fqvMgfNfpVoBscA/tKA== +"@types/trusted-types@^2.0.7": + version "2.0.7" + resolved "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz" + integrity sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw== + "@types/unist@*", "@types/unist@^3.0.0": version "3.0.3" resolved "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz" @@ -5694,6 +6504,13 @@ dependencies: "@types/yargs-parser" "*" +"@types/yauzl@^2.9.1": + version "2.10.3" + resolved "https://registry.npmjs.org/@types/yauzl/-/yauzl-2.10.3.tgz" + integrity sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q== + dependencies: + "@types/node" "*" + "@ungap/structured-clone@^1.0.0", "@ungap/structured-clone@^1.3.0": version "1.3.0" resolved "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz" @@ -5740,7 +6557,7 @@ estree-walker "^3.0.3" magic-string "^0.30.17" -"@vitest/pretty-format@^3.2.4", "@vitest/pretty-format@3.2.4": +"@vitest/pretty-format@3.2.4", "@vitest/pretty-format@^3.2.4": version "3.2.4" resolved "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-3.2.4.tgz" integrity sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA== @@ -5791,11 +6608,36 @@ resolved "https://registry.npmjs.org/@xmldom/xmldom/-/xmldom-0.8.11.tgz" integrity sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw== +"@xterm/addon-fit@^0.10.0": + version "0.10.0" + resolved "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz" + integrity sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ== + +"@xterm/addon-web-links@^0.11.0": + version "0.11.0" + resolved "https://registry.npmjs.org/@xterm/addon-web-links/-/addon-web-links-0.11.0.tgz" + integrity sha512-nIHQ38pQI+a5kXnRaTgwqSHnX7KE6+4SVoceompgHL26unAxdfP6IPqUTSYPQgSwM56hsElfoNrrW5V7BUED/Q== + +"@xterm/addon-webgl@^0.18.0": + version "0.18.0" + resolved "https://registry.npmjs.org/@xterm/addon-webgl/-/addon-webgl-0.18.0.tgz" + integrity sha512-xCnfMBTI+/HKPdRnSOHaJDRqEpq2Ugy8LEj9GiY4J3zJObo3joylIFaMvzBwbYRg8zLtkO0KQaStCeSfoaI2/w== + +"@xterm/xterm@^5.5.0": + version "5.5.0" + resolved "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz" + integrity sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A== + "@yarnpkg/lockfile@^1.1.0": version "1.1.0" resolved "https://registry.npmjs.org/@yarnpkg/lockfile/-/lockfile-1.1.0.tgz" integrity sha512-GpSwvyXOcOOlV70vbnzjj4fW5xW/FdUF6nQEt1ENy7m4ZCczi1+/buVUPAqmGfqznsORNFzUMjctTIp8a9tuCQ== +"@zxing/text-encoding@0.9.0": + version "0.9.0" + resolved "https://registry.npmjs.org/@zxing/text-encoding/-/text-encoding-0.9.0.tgz" + integrity sha512-U/4aVJ2mxI0aDNI8Uq0wEhMgY+u4CNtEb0om3+y3+niDAsoTCOB33UF0sxpzqzdqXLqmvc+vZyAt4O8pPdfkwA== + abort-controller@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz" @@ -5816,14 +6658,6 @@ accepts@^1.3.7, accepts@^1.3.8, accepts@~1.3.4: mime-types "~2.1.34" negotiator "0.6.3" -accepts@^1.3.8: - version "1.3.8" - resolved "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz" - integrity sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw== - dependencies: - mime-types "~2.1.34" - negotiator "0.6.3" - accepts@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz" @@ -5849,7 +6683,7 @@ acorn-walk@^8.1.1: dependencies: acorn "^8.11.0" -"acorn@^6.0.0 || ^7.0.0 || ^8.0.0", acorn@^8, acorn@^8.0.0, acorn@^8.11.0, acorn@^8.15.0, acorn@^8.4.1: +acorn@^8.0.0, acorn@^8.11.0, acorn@^8.15.0, acorn@^8.4.1: version "8.15.0" resolved "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz" integrity sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg== @@ -5959,7 +6793,7 @@ any-promise@^1.0.0: resolved "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz" integrity sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A== -anymatch@^3.0.3: +anymatch@^3.0.3, anymatch@~3.1.2: version "3.1.3" resolved "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz" integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw== @@ -5967,13 +6801,36 @@ anymatch@^3.0.3: normalize-path "^3.0.0" picomatch "^2.0.4" -anymatch@~3.1.2: - version "3.1.3" - resolved "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz" - integrity sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw== +archiver-utils@^5.0.0, archiver-utils@^5.0.2: + version "5.0.2" + resolved "https://registry.npmjs.org/archiver-utils/-/archiver-utils-5.0.2.tgz" + integrity sha512-wuLJMmIBQYCsGZgYLTy5FIB2pF6Lfb6cXMSF8Qywwk3t20zWnAi7zLcQFdKQmIB8wyZpY5ER38x08GbwtR2cLA== dependencies: + glob "^10.0.0" + graceful-fs "^4.2.0" + is-stream "^2.0.1" + lazystream "^1.0.0" + lodash "^4.17.15" normalize-path "^3.0.0" - picomatch "^2.0.4" + readable-stream "^4.0.0" + +archiver@^7.0.1: + version "7.0.1" + resolved "https://registry.npmjs.org/archiver/-/archiver-7.0.1.tgz" + integrity sha512-ZcbTaIqJOfCc03QwD468Unz/5Ir8ATtvAHsK+FdXbDIbGfihqh9mrvdcYunQzqn4HrvWWaFyaxJhGZagaJJpPQ== + dependencies: + archiver-utils "^5.0.2" + async "^3.2.4" + buffer-crc32 "^1.0.0" + readable-stream "^4.0.0" + readdir-glob "^1.1.2" + tar-stream "^3.0.0" + zip-stream "^6.0.1" + +arg@4.1.0: + version "4.1.0" + resolved "https://registry.npmjs.org/arg/-/arg-4.1.0.tgz" + integrity sha512-ZWc51jO3qegGkVh8Hwpv636EkbesNV5ZNQPCtRa+0qytRYPEs9IYT9qITY9buezqUH5uqyzlWLcufrzU2rffdg== arg@^4.1.0: version "4.1.3" @@ -5985,11 +6842,6 @@ arg@^5.0.2: resolved "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz" integrity sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg== -arg@4.1.0: - version "4.1.0" - resolved "https://registry.npmjs.org/arg/-/arg-4.1.0.tgz" - integrity sha512-ZWc51jO3qegGkVh8Hwpv636EkbesNV5ZNQPCtRa+0qytRYPEs9IYT9qITY9buezqUH5uqyzlWLcufrzU2rffdg== - argparse@^1.0.7: version "1.0.10" resolved "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz" @@ -6054,12 +6906,7 @@ arraybuffer.prototype.slice@^1.0.4: get-intrinsic "^1.2.6" is-array-buffer "^3.0.4" -asap@~2.0.3: - version "2.0.6" - resolved "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz" - integrity sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA== - -asap@~2.0.6: +asap@~2.0.3, asap@~2.0.6: version "2.0.6" resolved "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz" integrity sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA== @@ -6173,6 +7020,11 @@ axios@^1.10.0, axios@^1.13.2, axios@^1.6.8: form-data "^4.0.4" proxy-from-env "^1.1.0" +b4a@^1.6.4: + version "1.8.0" + resolved "https://registry.npmjs.org/b4a/-/b4a-1.8.0.tgz" + integrity sha512-qRuSmNSkGQaHwNbM7J78Wwy+ghLEYF1zNrSeMxj4Kgw6y33O3mXcQ6Ie9fRvfU/YnxWkOchPXbaLb73TkIsfdg== + babel-jest@^29.7.0: version "29.7.0" resolved "https://registry.npmjs.org/babel-jest/-/babel-jest-29.7.0.tgz" @@ -6242,7 +7094,7 @@ babel-plugin-polyfill-regenerator@^0.6.5: dependencies: "@babel/helper-define-polyfill-provider" "^0.6.6" -babel-plugin-react-compiler@*, babel-plugin-react-compiler@^1.0.0: +babel-plugin-react-compiler@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/babel-plugin-react-compiler/-/babel-plugin-react-compiler-1.0.0.tgz" integrity sha512-Ixm8tFfoKKIPYdCCKYTsqv+Fd4IJ0DQqMyEimo+pxUOMUR9cVPlwTrFt9Avu+3cb6Zp3mAzl+t1MrG2fxxKsxw== @@ -6254,7 +7106,7 @@ babel-plugin-react-native-web@~0.21.0: resolved "https://registry.npmjs.org/babel-plugin-react-native-web/-/babel-plugin-react-native-web-0.21.2.tgz" integrity sha512-SPD0J6qjJn8231i0HZhlAGH6NORe+QvRSQM2mwQEzJ2Fb3E4ruWTiiicPlHjmeWShDXLcvoorOCXjeR7k/lyWA== -babel-plugin-syntax-hermes-parser@^0.29.1, babel-plugin-syntax-hermes-parser@0.29.1: +babel-plugin-syntax-hermes-parser@0.29.1, babel-plugin-syntax-hermes-parser@^0.29.1: version "0.29.1" resolved "https://registry.npmjs.org/babel-plugin-syntax-hermes-parser/-/babel-plugin-syntax-hermes-parser-0.29.1.tgz" integrity sha512-2WFYnoWGdmih1I1J5eIqxATOeycOqRwYxAQBu3cUu/rhwInwHUg7k60AFNbuGjSDL8tje5GDrAnxzRLcu2pYcA== @@ -6350,12 +7202,55 @@ balanced-match@^4.0.2: resolved "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz" integrity sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA== -base64-js@^1.2.3, base64-js@^1.3.0, base64-js@^1.3.1, base64-js@^1.5.1, base64-js@1.5.1: +bare-events@^2.5.4, bare-events@^2.7.0: + version "2.8.2" + resolved "https://registry.npmjs.org/bare-events/-/bare-events-2.8.2.tgz" + integrity sha512-riJjyv1/mHLIPX4RwiK+oW9/4c3TEUeORHKefKAKnZ5kyslbN+HXowtbaVEqt4IMUB7OXlfixcs6gsFeo/jhiQ== + +bare-fs@^4.5.5: + version "4.5.5" + resolved "https://registry.npmjs.org/bare-fs/-/bare-fs-4.5.5.tgz" + integrity sha512-XvwYM6VZqKoqDll8BmSww5luA5eflDzY0uEFfBJtFKe4PAAtxBjU3YIxzIBzhyaEQBy1VXEQBto4cpN5RZJw+w== + dependencies: + bare-events "^2.5.4" + bare-path "^3.0.0" + bare-stream "^2.6.4" + bare-url "^2.2.2" + fast-fifo "^1.3.2" + +bare-os@^3.0.1: + version "3.7.1" + resolved "https://registry.npmjs.org/bare-os/-/bare-os-3.7.1.tgz" + integrity sha512-ebvMaS5BgZKmJlvuWh14dg9rbUI84QeV3WlWn6Ph6lFI8jJoh7ADtVTyD2c93euwbe+zgi0DVrl4YmqXeM9aIA== + +bare-path@^3.0.0: + version "3.0.0" + resolved "https://registry.npmjs.org/bare-path/-/bare-path-3.0.0.tgz" + integrity sha512-tyfW2cQcB5NN8Saijrhqn0Zh7AnFNsnczRcuWODH0eYAXBsJ5gVxAUuNr7tsHSC6IZ77cA0SitzT+s47kot8Mw== + dependencies: + bare-os "^3.0.1" + +bare-stream@^2.6.4: + version "2.8.0" + resolved "https://registry.npmjs.org/bare-stream/-/bare-stream-2.8.0.tgz" + integrity sha512-reUN0M2sHRqCdG4lUK3Fw8w98eeUIZHL5c3H7Mbhk2yVBL+oofgaIp0ieLfD5QXwPCypBpmEEKU2WZKzbAk8GA== + dependencies: + streamx "^2.21.0" + teex "^1.0.1" + +bare-url@^2.2.2: + version "2.3.2" + resolved "https://registry.npmjs.org/bare-url/-/bare-url-2.3.2.tgz" + integrity sha512-ZMq4gd9ngV5aTMa5p9+UfY0b3skwhHELaDkhEHetMdX0LRkW9kzaym4oo/Eh+Ghm0CCDuMTsRIGM/ytUc1ZYmw== + dependencies: + bare-path "^3.0.0" + +base64-js@1.5.1, base64-js@^1.2.3, base64-js@^1.3.0, base64-js@^1.3.1, base64-js@^1.5.1: version "1.5.1" resolved "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz" integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== -base64id@~2.0.0, base64id@2.0.0: +base64id@2.0.0, base64id@~2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz" integrity sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog== @@ -6402,6 +7297,15 @@ bintrees@1.0.2: resolved "https://registry.npmjs.org/bintrees/-/bintrees-1.0.2.tgz" integrity sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw== +bl@^4.0.3: + version "4.1.0" + resolved "https://registry.npmjs.org/bl/-/bl-4.1.0.tgz" + integrity sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w== + dependencies: + buffer "^5.5.0" + inherits "^2.0.4" + readable-stream "^3.4.0" + block-stream2@^2.1.0: version "2.1.0" resolved "https://registry.npmjs.org/block-stream2/-/block-stream2-2.1.0.tgz" @@ -6446,13 +7350,6 @@ bplist-creator@0.1.0: dependencies: stream-buffers "2.2.x" -bplist-parser@^0.3.1: - version "0.3.2" - resolved "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.3.2.tgz" - integrity sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ== - dependencies: - big-integer "1.6.x" - bplist-parser@0.3.1: version "0.3.1" resolved "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.3.1.tgz" @@ -6460,6 +7357,13 @@ bplist-parser@0.3.1: dependencies: big-integer "1.6.x" +bplist-parser@^0.3.1: + version "0.3.2" + resolved "https://registry.npmjs.org/bplist-parser/-/bplist-parser-0.3.2.tgz" + integrity sha512-apC2+fspHGI3mMKj+dGevkGo/tCqVB8jMb6i+OX+E29p0Iposz07fABkRIfVUPNd5A5VbuOz1bZbnmkKLYF+wQ== + dependencies: + big-integer "1.6.x" + brace-expansion@^1.1.7: version "1.1.12" resolved "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz" @@ -6494,7 +7398,7 @@ browser-or-node@^2.1.1: resolved "https://registry.npmjs.org/browser-or-node/-/browser-or-node-2.1.1.tgz" integrity sha512-8CVjaLJGuSKMVTxJ2DpBl5XnlNDiT4cQFeuCJJrvJmts9YrTZDizTX7PjC2s6W4x+MBGZeEY6dGMrF04/6Hgqg== -browserslist@^4.24.0, browserslist@^4.25.0, browserslist@^4.28.1, "browserslist@>= 4.21.0": +browserslist@^4.24.0, browserslist@^4.25.0, browserslist@^4.28.1: version "4.28.1" resolved "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz" integrity sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA== @@ -6532,7 +7436,7 @@ buffer-from@^1.0.0: resolved "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz" integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== -buffer@^5.4.3: +buffer@^5.4.3, buffer@^5.5.0: version "5.7.1" resolved "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz" integrity sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ== @@ -6555,7 +7459,7 @@ bundle-name@^4.1.0: dependencies: run-applescript "^7.0.0" -bytes@^3.1.2, bytes@~3.1.2, bytes@3.1.2: +bytes@3.1.2, bytes@^3.1.2, bytes@~3.1.2: version "3.1.2" resolved "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz" integrity sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg== @@ -6655,22 +7559,12 @@ camelcase-css@^2.0.1: resolved "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz" integrity sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA== -camelcase@^5.0.0: - version "5.3.1" - resolved "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz" - integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== - -camelcase@^5.3.1: +camelcase@^5.0.0, camelcase@^5.3.1: version "5.3.1" - resolved "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz" - integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== - -camelcase@^6.2.0: - version "6.3.0" - resolved "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz" - integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== + resolved "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz" + integrity sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg== -camelcase@^6.3.0: +camelcase@^6.2.0, camelcase@^6.3.0: version "6.3.0" resolved "https://registry.npmjs.org/camelcase/-/camelcase-6.3.0.tgz" integrity sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA== @@ -6703,21 +7597,20 @@ chai@^5.2.0: loupe "^3.1.0" pathval "^2.0.0" -chalk@*: +chalk@*, chalk@^5.6.0, chalk@^5.6.2: version "5.6.2" resolved "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz" integrity sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA== -chalk@^2.0.1: - version "2.4.2" - resolved "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz" - integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== +chalk@4.1.2, chalk@^4.0.0, chalk@^4.1.0, chalk@^4.1.2: + version "4.1.2" + resolved "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz" + integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== dependencies: - ansi-styles "^3.2.1" - escape-string-regexp "^1.0.5" - supports-color "^5.3.0" + ansi-styles "^4.1.0" + supports-color "^7.1.0" -chalk@^2.4.2: +chalk@^2.0.1, chalk@^2.4.2: version "2.4.2" resolved "https://registry.npmjs.org/chalk/-/chalk-2.4.2.tgz" integrity sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ== @@ -6726,19 +7619,6 @@ chalk@^2.4.2: escape-string-regexp "^1.0.5" supports-color "^5.3.0" -chalk@^4.0.0, chalk@^4.1.0, chalk@^4.1.2, chalk@4.1.2: - version "4.1.2" - resolved "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz" - integrity sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA== - dependencies: - ansi-styles "^4.1.0" - supports-color "^7.1.0" - -chalk@^5.6.0, chalk@^5.6.2: - version "5.6.2" - resolved "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz" - integrity sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA== - character-entities-html4@^2.0.0: version "2.1.0" resolved "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz" @@ -6776,7 +7656,7 @@ chevrotain-allstar@~0.3.0: dependencies: lodash-es "^4.17.21" -chevrotain@^11.0.0, chevrotain@~11.0.3: +chevrotain@~11.0.3: version "11.0.3" resolved "https://registry.npmjs.org/chevrotain/-/chevrotain-11.0.3.tgz" integrity sha512-ci2iJH6LeIkvP9eJW6gpueU8cnZhv85ELY8w8WiFtNjMHA5ad6pQLaJo9mEly/9qUyCpvqX8/POVUTf18/HFdw== @@ -6817,6 +7697,11 @@ chokidar@^5.0.0: dependencies: readdirp "^5.0.0" +chownr@^1.1.1: + version "1.1.4" + resolved "https://registry.npmjs.org/chownr/-/chownr-1.1.4.tgz" + integrity sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg== + chownr@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz" @@ -6854,11 +7739,6 @@ ci-info@^3.2.0, ci-info@^3.3.0, ci-info@^3.7.0: resolved "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz" integrity sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ== -ci-info@^3.3.0: - version "3.9.0" - resolved "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz" - integrity sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ== - ci-info@^4.3.1: version "4.3.1" resolved "https://registry.npmjs.org/ci-info/-/ci-info-4.3.1.tgz" @@ -6937,7 +7817,7 @@ cli-width@^4.1.0: resolved "https://registry.npmjs.org/cli-width/-/cli-width-4.1.0.tgz" integrity sha512-ouuZd4/dm2Sw5Gmqy6bGyNNNe1qt9RpmxveLSO7KcgsTnU7RXfsw+/bukWGo1abgBiMAic068rclZsO4IWmmxQ== -client-only@^0.0.1, client-only@0.0.1: +client-only@0.0.1, client-only@^0.0.1: version "0.0.1" resolved "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz" integrity sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA== @@ -6972,12 +7852,7 @@ clone@^1.0.2: resolved "https://registry.npmjs.org/clone/-/clone-1.0.4.tgz" integrity sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg== -clsx@^2.1.1: - version "2.1.1" - resolved "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz" - integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA== - -clsx@2.1.1: +clsx@2.1.1, clsx@^2.1.1: version "2.1.1" resolved "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz" integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA== @@ -7020,6 +7895,11 @@ color-convert@^3.1.3: dependencies: color-name "^2.0.0" +color-name@1.1.3: + version "1.1.3" + resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz" + integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw== + color-name@^1.0.0, color-name@~1.1.4: version "1.1.4" resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz" @@ -7030,11 +7910,6 @@ color-name@^2.0.0: resolved "https://registry.npmjs.org/color-name/-/color-name-2.1.0.tgz" integrity sha512-1bPaDNFm0axzE4MEAzKPuqKWeRaT43U/hyxKPBdqTfmPF+d6n7FSoTFxLVULUJOmiLp01KjhIPPH+HrXZJN4Rg== -color-name@1.1.3: - version "1.1.3" - resolved "https://registry.npmjs.org/color-name/-/color-name-1.1.3.tgz" - integrity sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw== - color-string@^1.9.0: version "1.9.1" resolved "https://registry.npmjs.org/color-string/-/color-string-1.9.1.tgz" @@ -7088,6 +7963,11 @@ command-exists@^1.2.9: resolved "https://registry.npmjs.org/command-exists/-/command-exists-1.2.9.tgz" integrity sha512-LTQ/SGc+s0Xc0Fu5WaKnR0YiygZkm9eKFvyS+fRsU7/ZWFF8ykFM6Pc9aCVf1+xasOOZpO3BAVgVrKvsqKHV7w== +commander@7, commander@^7.2.0: + version "7.2.0" + resolved "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz" + integrity sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw== + commander@^12.0.0: version "12.1.0" resolved "https://registry.npmjs.org/commander/-/commander-12.1.0.tgz" @@ -7103,11 +7983,6 @@ commander@^4.0.0: resolved "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz" integrity sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA== -commander@^7.2.0, commander@7: - version "7.2.0" - resolved "https://registry.npmjs.org/commander/-/commander-7.2.0.tgz" - integrity sha512-QrWXB+ZQSVPmIWIhtEO9H+gwHaMGYiF5ChvoJ+K9ZGHG/sVsa6yiesAD1GC/x46sET00Xlwo1u49RVVVzvcSkw== - commander@^8.3.0: version "8.3.0" resolved "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz" @@ -7118,6 +7993,17 @@ commondir@^1.0.1: resolved "https://registry.npmjs.org/commondir/-/commondir-1.0.1.tgz" integrity sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg== +compress-commons@^6.0.2: + version "6.0.2" + resolved "https://registry.npmjs.org/compress-commons/-/compress-commons-6.0.2.tgz" + integrity sha512-6FqVXeETqWPoGcfzrXb37E50NP0LXT8kAMu5ooZayhWWdgEY4lBEEcbQNXtkuKQsGduxiIcI4gOTsxTmuq/bSg== + dependencies: + crc-32 "^1.2.0" + crc32-stream "^6.0.0" + is-stream "^2.0.1" + normalize-path "^3.0.0" + readable-stream "^4.0.0" + compressible@~2.0.18: version "2.0.18" resolved "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz" @@ -7168,16 +8054,6 @@ connect@^3.6.5, connect@^3.7.0: parseurl "~1.3.3" utils-merge "1.0.1" -connect@^3.7.0: - version "3.7.0" - resolved "https://registry.npmjs.org/connect/-/connect-3.7.0.tgz" - integrity sha512-ZqRXc+tZukToSNmh5C2iWMSoV3X1YUcPbqEM4DkEG5tNQXrQUZCNVGGv3IuicnkMtPfGf3Xtp8WCXs295iQ1pQ== - dependencies: - debug "2.6.9" - finalhandler "1.1.2" - parseurl "~1.3.3" - utils-merge "1.0.1" - consola@^3.2.3, consola@^3.4.0: version "3.4.2" resolved "https://registry.npmjs.org/consola/-/consola-3.4.2.tgz" @@ -7193,7 +8069,7 @@ content-type@^1.0.5: resolved "https://registry.npmjs.org/content-type/-/content-type-1.0.5.tgz" integrity sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA== -convert-source-map@^2.0.0, convert-source-map@2.0.0: +convert-source-map@2.0.0, convert-source-map@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz" integrity sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg== @@ -7225,6 +8101,11 @@ core-js-compat@^3.43.0: dependencies: browserslist "^4.28.1" +core-util-is@~1.0.0: + version "1.0.3" + resolved "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz" + integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ== + cors@^2.8.5, cors@~2.8.5: version "2.8.6" resolved "https://registry.npmjs.org/cors/-/cors-2.8.6.tgz" @@ -7247,6 +8128,19 @@ cose-base@^2.2.0: dependencies: layout-base "^2.0.0" +crc-32@^1.2.0: + version "1.2.2" + resolved "https://registry.npmjs.org/crc-32/-/crc-32-1.2.2.tgz" + integrity sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ== + +crc32-stream@^6.0.0: + version "6.0.0" + resolved "https://registry.npmjs.org/crc32-stream/-/crc32-stream-6.0.0.tgz" + integrity sha512-piICUB6ei4IlTv1+653yq5+KoqfBYmj9bw6LqXoOneTMDXk5nM1qt12mFW1caG3LlJXEKW1Bp0WggEmIfQB34g== + dependencies: + crc-32 "^1.2.0" + readable-stream "^4.0.0" + create-require@^1.1.0: version "1.1.1" resolved "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz" @@ -7356,18 +8250,11 @@ cytoscape-fcose@^2.2.0: dependencies: cose-base "^2.2.0" -cytoscape@^3.2.0, cytoscape@^3.29.3: +cytoscape@^3.29.3: version "3.33.1" resolved "https://registry.npmjs.org/cytoscape/-/cytoscape-3.33.1.tgz" integrity sha512-iJc4TwyANnOGR1OmWhsS9ayRS3s+XQ185FmuHObThD+5AeJCakAAbWv8KimMTt08xCCLNgneQwFp+JRJOr9qGQ== -d3-array@^3.2.0, "d3-array@2 - 3", "d3-array@2.10.0 - 3", "d3-array@2.5.0 - 3", d3-array@3: - version "3.2.4" - resolved "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz" - integrity sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg== - dependencies: - internmap "1 - 2" - "d3-array@1 - 2": version "2.12.1" resolved "https://registry.npmjs.org/d3-array/-/d3-array-2.12.1.tgz" @@ -7375,6 +8262,13 @@ d3-array@^3.2.0, "d3-array@2 - 3", "d3-array@2.10.0 - 3", "d3-array@2.5.0 - 3", dependencies: internmap "^1.0.0" +"d3-array@2 - 3", "d3-array@2.10.0 - 3", "d3-array@2.5.0 - 3", d3-array@3, d3-array@^3.2.0: + version "3.2.4" + resolved "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz" + integrity sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg== + dependencies: + internmap "1 - 2" + d3-axis@3: version "3.0.0" resolved "https://registry.npmjs.org/d3-axis/-/d3-axis-3.0.0.tgz" @@ -7484,16 +8378,16 @@ d3-hierarchy@3: dependencies: d3-color "1 - 3" -d3-path@^3.1.0, "d3-path@1 - 3", d3-path@3: - version "3.1.0" - resolved "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz" - integrity sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ== - d3-path@1: version "1.0.9" resolved "https://registry.npmjs.org/d3-path/-/d3-path-1.0.9.tgz" integrity sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg== +"d3-path@1 - 3", d3-path@3, d3-path@^3.1.0: + version "3.1.0" + resolved "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz" + integrity sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ== + d3-polygon@3: version "3.0.1" resolved "https://registry.npmjs.org/d3-polygon/-/d3-polygon-3.0.1.tgz" @@ -7541,13 +8435,6 @@ d3-scale@4: resolved "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz" integrity sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ== -d3-shape@^1.2.0: - version "1.3.7" - resolved "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz" - integrity sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw== - dependencies: - d3-path "1" - d3-shape@3: version "3.2.0" resolved "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz" @@ -7555,6 +8442,13 @@ d3-shape@3: dependencies: d3-path "^3.1.0" +d3-shape@^1.2.0: + version "1.3.7" + resolved "https://registry.npmjs.org/d3-shape/-/d3-shape-1.3.7.tgz" + integrity sha512-EUkvKjqPFUAZyOlhY5gzCxCeI0Aep04LwIRpsZ/mLFelJiUfnK56jo5JMDSE7yyP2kLSb6LtF+S5chMk7uqPqw== + dependencies: + d3-path "1" + "d3-time-format@2 - 4", d3-time-format@4: version "4.1.0" resolved "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz" @@ -7695,13 +8589,27 @@ dayjs@^1.11.18: resolved "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz" integrity sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw== -debug@^2.6.9: +debug@2.6.9, debug@^2.6.9: version "2.6.9" resolved "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz" integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== dependencies: ms "2.0.0" +debug@4, debug@^4.0.0, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.3.5, debug@^4.3.6, debug@^4.4.0, debug@^4.4.1, debug@^4.4.3, debug@~4.4.1: + version "4.4.3" + resolved "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz" + integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== + dependencies: + ms "^2.1.3" + +debug@4.3.4: + version "4.3.4" + resolved "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz" + integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== + dependencies: + ms "2.1.2" + debug@^3.1.0: version "3.2.7" resolved "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz" @@ -7709,13 +8617,6 @@ debug@^3.1.0: dependencies: ms "^2.1.1" -debug@^4.0.0, debug@^4.1.0, debug@^4.1.1, debug@^4.3.1, debug@^4.3.2, debug@^4.3.4, debug@^4.3.5, debug@^4.3.6, debug@^4.4.0, debug@^4.4.1, debug@^4.4.3, debug@~4.4.1, debug@4: - version "4.4.3" - resolved "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz" - integrity sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA== - dependencies: - ms "^2.1.3" - debug@~4.3.1: version "4.3.7" resolved "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz" @@ -7723,20 +8624,6 @@ debug@~4.3.1: dependencies: ms "^2.1.3" -debug@2.6.9: - version "2.6.9" - resolved "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz" - integrity sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA== - dependencies: - ms "2.0.0" - -debug@4.3.4: - version "4.3.4" - resolved "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz" - integrity sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ== - dependencies: - ms "2.1.2" - decamelize@^1.2.0: version "1.2.0" resolved "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz" @@ -7875,7 +8762,7 @@ denque@^2.1.0: resolved "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz" integrity sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw== -depd@^2.0.0, depd@~2.0.0, depd@2.0.0: +depd@2.0.0, depd@^2.0.0, depd@~2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz" integrity sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw== @@ -7895,7 +8782,7 @@ destroy@1.2.0: resolved "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz" integrity sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg== -detect-libc@^2.0.3, detect-libc@^2.1.2: +detect-libc@^2.0.0, detect-libc@^2.0.3, detect-libc@^2.1.2: version "2.1.2" resolved "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz" integrity sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ== @@ -7922,16 +8809,16 @@ didyoumean@^1.2.2: resolved "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz" integrity sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw== +diff@8.0.3, diff@^8.0.2: + version "8.0.3" + resolved "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz" + integrity sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ== + diff@^4.0.1: version "4.0.4" resolved "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz" integrity sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ== -diff@^8.0.2, diff@8.0.3: - version "8.0.3" - resolved "https://registry.npmjs.org/diff/-/diff-8.0.3.tgz" - integrity sha512-qejHi7bcSD4hQAZE0tNAawRK1ZtafHDmMTMkrrIGgSLl7hTnQHmKCeB45xAcbfTqK2zowkM3j3bHt/4b/ARbYQ== - dijkstrajs@^1.0.1: version "1.0.3" resolved "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz" @@ -7942,19 +8829,6 @@ dlv@^1.1.3: resolved "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz" integrity sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA== -"docs@file:/Users/cdenneen/code/workspace/work/happier-dev/apps/docs": - version "0.0.0" - resolved "file:apps/docs" - dependencies: - fumadocs-core "16.4.11" - fumadocs-mdx "14.2.6" - fumadocs-ui "16.4.11" - lucide-react "^0.563.0" - next "16.1.4" - react "^19.2.3" - react-dom "^19.2.3" - tailwind-merge "^3.4.0" - dom-serializer@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz" @@ -7976,13 +8850,6 @@ domhandler@^5.0.2, domhandler@^5.0.3: dependencies: domelementtype "^2.3.0" -dompurify@^3.2.5: - version "3.3.1" - resolved "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz" - integrity sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q== - optionalDependencies: - "@types/trusted-types" "^2.0.7" - dompurify@3.2.7: version "3.2.7" resolved "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz" @@ -7990,6 +8857,13 @@ dompurify@3.2.7: optionalDependencies: "@types/trusted-types" "^2.0.7" +dompurify@^3.2.5: + version "3.3.1" + resolved "https://registry.npmjs.org/dompurify/-/dompurify-3.3.1.tgz" + integrity sha512-qkdCKzLNtrgPFP1Vo+98FRzJnBRGe4ffyCea9IwHB1fyxPOeNTHpLKYGd4Uk9xvNoH0ZoOjwZxNptyMwqrId1Q== + optionalDependencies: + "@types/trusted-types" "^2.0.7" + domutils@^3.0.1: version "3.2.2" resolved "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz" @@ -8076,7 +8950,9 @@ electron-to-chromium@^1.5.263: integrity sha512-0bblUU5UNdOt5G7XqGiJtpZMONma6WAfq9vsFmtn9x1+joAObr6x1chfqyxFSDCAFwFhCQDrqeAr6MYdpwJ9Hg== elevenlabs@^1.54.0: - version "v1.59.0" + version "1.59.0" + resolved "https://registry.yarnpkg.com/elevenlabs/-/elevenlabs-1.59.0.tgz#8db85064e3b70b65c63aaa25c105e1e3701c0fe4" + integrity sha512-OVKOd+lxNya8h4Rn5fcjv00Asd+DGWfTT6opGrQ16sTI+1HwdLn/kYtjl8tRMhDXbNmksD/9SBRKjb9neiUuVg== dependencies: command-exists "^1.2.9" execa "^5.1.1" @@ -8118,7 +8994,7 @@ encodeurl@~1.0.2: resolved "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz" integrity sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w== -end-of-stream@^1.1.0: +end-of-stream@^1.1.0, end-of-stream@^1.4.1: version "1.4.5" resolved "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.5.tgz" integrity sha512-ooEGc6HP26xXq/N+GCGOT0JKCLDGrq2bQUZrQ7gyrJiZANJ/8YDTxTpQBXGMn+WbIQXNVpyWymm7KYVICQnyOg== @@ -8514,7 +9390,7 @@ eslint-visitor-keys@^4.2.1: resolved "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz" integrity sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ== -"eslint@^6.0.0 || ^7.0.0 || >=8.0.0", "eslint@^8.40 || 9", eslint@^9, eslint@>=7.0.0: +eslint@^9: version "9.39.2" resolved "https://registry.npmjs.org/eslint/-/eslint-9.39.2.tgz" integrity sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw== @@ -8563,12 +9439,7 @@ espree@^10.0.1, espree@^10.4.0: acorn-jsx "^5.3.2" eslint-visitor-keys "^4.2.1" -esprima@^4.0.0: - version "4.0.1" - resolved "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz" - integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== - -esprima@^4.0.1: +esprima@^4.0.0, esprima@^4.0.1: version "4.0.1" resolved "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz" integrity sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A== @@ -8656,14 +9527,7 @@ estree-walker@^2.0.2: resolved "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz" integrity sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w== -estree-walker@^3.0.0: - version "3.0.3" - resolved "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz" - integrity sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g== - dependencies: - "@types/estree" "^1.0.0" - -estree-walker@^3.0.3: +estree-walker@^3.0.0, estree-walker@^3.0.3: version "3.0.3" resolved "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz" integrity sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g== @@ -8685,16 +9549,16 @@ etag@^1.8.1, etag@~1.8.1: resolved "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz" integrity sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg== -event-target-shim@^5.0.0: - version "5.0.1" - resolved "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz" - integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ== - event-target-shim@6.0.2: version "6.0.2" resolved "https://registry.npmjs.org/event-target-shim/-/event-target-shim-6.0.2.tgz" integrity sha512-8q3LsZjRezbFZ2PN+uP+Q7pnHUMmAOziU2vA2OwoFaKIXxlxl38IylhSSgUorWu/rf4er67w0ikBqjBFk/pomA== +event-target-shim@^5.0.0: + version "5.0.1" + resolved "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz" + integrity sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ== + eventemitter3@^4.0.0: version "4.0.7" resolved "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz" @@ -8705,6 +9569,13 @@ eventemitter3@^5.0.1: resolved "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz" integrity sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw== +events-universal@^1.0.0: + version "1.0.1" + resolved "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz" + integrity sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw== + dependencies: + bare-events "^2.7.0" + events@^3.3.0: version "3.3.0" resolved "https://registry.npmjs.org/events/-/events-3.3.0.tgz" @@ -8757,17 +9628,22 @@ execa@^8.0.1: signal-exit "^4.1.0" strip-final-newline "^3.0.0" +expand-template@^2.0.3: + version "2.0.3" + resolved "https://registry.npmjs.org/expand-template/-/expand-template-2.0.3.tgz" + integrity sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg== + expect-type@^1.2.1: version "1.3.0" resolved "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz" integrity sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA== -"expo-application@>= 4.0.0", expo-application@~7.0.7, expo-application@~7.0.8: +expo-application@~7.0.7, expo-application@~7.0.8: version "7.0.8" resolved "https://registry.npmjs.org/expo-application/-/expo-application-7.0.8.tgz" integrity sha512-qFGyxk7VJbrNOQWBbE09XUuGuvkOgFS9QfToaK2FdagM2aQ+x3CvGV2DuVgl/l4ZxPgIf3b/MNh9xHpwSwn74Q== -expo-asset@*, expo-asset@~12.0.12, expo-asset@~12.0.9: +expo-asset@~12.0.12, expo-asset@~12.0.9: version "12.0.12" resolved "https://registry.npmjs.org/expo-asset/-/expo-asset-12.0.12.tgz" integrity sha512-CsXFCQbx2fElSMn0lyTdRIyKlSXOal6ilLJd+yeZ6xaC7I9AICQgscY5nj0QcwgA+KYYCCEQEBndMsmj7drOWQ== @@ -8797,7 +9673,7 @@ expo-clipboard@^8.0.7: resolved "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-8.0.8.tgz" integrity sha512-VKoBkHIpZZDJTB0jRO4/PZskHdMNOEz3P/41tmM6fDuODMpqhvyWK053X0ebspkxiawJX9lX33JXHBCvVsTTOA== -expo-constants@^18.0.13, expo-constants@~18.0.12, expo-constants@~18.0.13, expo-constants@~18.0.9: +expo-constants@~18.0.12, expo-constants@~18.0.13, expo-constants@~18.0.9: version "18.0.13" resolved "https://registry.npmjs.org/expo-constants/-/expo-constants-18.0.13.tgz" integrity sha512-FnZn12E1dRYKDHlAdIyNFhBurKTS3F9CrfrBDJI5m3D7U17KBHMQ6JEfYlSj7LG7t+Ulr+IKaj58L1k5gBwTcQ== @@ -8844,7 +9720,7 @@ expo-dev-menu@7.0.18: dependencies: expo-dev-menu-interface "2.0.0" -"expo-device@>= 4.0.0", expo-device@~8.0.8: +expo-device@~8.0.8: version "8.0.10" resolved "https://registry.npmjs.org/expo-device/-/expo-device-8.0.10.tgz" integrity sha512-jd5BxjaF7382JkDMaC+P04aXXknB2UhWaVx5WiQKA05ugm/8GH5uaz9P9ckWdMKZGQVVEOC8MHaUADoT26KmFA== @@ -8861,12 +9737,12 @@ expo-eas-client@~1.0.8: resolved "https://registry.npmjs.org/expo-eas-client/-/expo-eas-client-1.0.8.tgz" integrity sha512-5or11NJhSeDoHHI6zyvQDW2cz/yFyE+1Cz8NTs5NK8JzC7J0JrkUgptWtxyfB6Xs/21YRNifd3qgbBN3hfKVgA== -"expo-file-system@>= 13.0.0", expo-file-system@~19.0.14, expo-file-system@~19.0.21: +expo-file-system@~19.0.14, expo-file-system@~19.0.21: version "19.0.21" resolved "https://registry.npmjs.org/expo-file-system/-/expo-file-system-19.0.21.tgz" integrity sha512-s3DlrDdiscBHtab/6W1osrjGL+C2bvoInPJD7sOwmxfJ5Woynv2oc+Fz1/xVXaE/V7HE/+xrHC/H45tu6lZzzg== -expo-font@>=14.0.4, expo-font@~14.0.11, expo-font@~14.0.8: +expo-font@~14.0.11, expo-font@~14.0.8: version "14.0.11" resolved "https://registry.npmjs.org/expo-font/-/expo-font-14.0.11.tgz" integrity sha512-ga0q61ny4s/kr4k8JX9hVH69exVSIfcIc19+qZ7gt71Mqtm7xy2c6kwsPTCyhBW2Ro5yXTT8EaZOpuRi35rHbg== @@ -8878,6 +9754,18 @@ expo-haptics@~15.0.7: resolved "https://registry.npmjs.org/expo-haptics/-/expo-haptics-15.0.8.tgz" integrity sha512-lftutojy8Qs8zaDzzjwM3gKHFZ8bOOEZDCkmh2Ddpe95Ra6kt2izeOfOfKuP/QEh0MZ1j9TfqippyHdRd1ZM9g== +expo-image-loader@~5.0.0: + version "5.0.0" + resolved "https://registry.yarnpkg.com/expo-image-loader/-/expo-image-loader-5.0.0.tgz#4f58a21ab26e40d6fccc211b664fd9fe21a5dcb8" + integrity sha512-Eg+5FHtyzv3Jjw9dHwu2pWy4xjf8fu3V0Asyy42kO+t/FbvW/vjUixpTjPtgKQLQh+2/9Nk4JjFDV6FwCnF2ZA== + +expo-image-picker@~16.0.6: + version "16.0.6" + resolved "https://registry.yarnpkg.com/expo-image-picker/-/expo-image-picker-16.0.6.tgz#ab1378711813a8c857ee577a97ce43e31ea84ab5" + integrity sha512-HN4xZirFjsFDIsWFb12AZh19fRzuvZjj2ll17cGr19VNRP06S/VPQU3Tdccn5vwUzQhOBlLu704CnNm278boiQ== + dependencies: + expo-image-loader "~5.0.0" + expo-image@~3.0.8: version "3.0.11" resolved "https://registry.npmjs.org/expo-image/-/expo-image-3.0.11.tgz" @@ -8898,7 +9786,7 @@ expo-linear-gradient@~15.0.7: resolved "https://registry.npmjs.org/expo-linear-gradient/-/expo-linear-gradient-15.0.8.tgz" integrity sha512-V2d8Wjn0VzhPHO+rrSBtcl+Fo+jUUccdlmQ6OoL9/XQB7Qk3d9lYrqKDJyccwDxmQT10JdST3Tmf2K52NLc3kw== -expo-linking@^8.0.11, expo-linking@~8.0.8: +expo-linking@~8.0.8: version "8.0.11" resolved "https://registry.npmjs.org/expo-linking/-/expo-linking-8.0.11.tgz" integrity sha512-+VSaNL5om3kOp/SSKO5qe6cFgfSIWnnQDSbA7XLs3ECkYzXRquk5unxNS3pg7eK5kNUmQ4kgLI7MhTggAEUBLA== @@ -8906,7 +9794,7 @@ expo-linking@^8.0.11, expo-linking@~8.0.8: expo-constants "~18.0.12" invariant "^2.2.4" -"expo-localization@>= 11.0.0", expo-localization@~17.0.7: +expo-localization@~17.0.7: version "17.0.8" resolved "https://registry.npmjs.org/expo-localization/-/expo-localization-17.0.8.tgz" integrity sha512-UrdwklZBDJ+t+ZszMMiE0SXZ2eJxcquCuQcl6EvGHM9K+e6YqKVRQ+w8qE+iIB3H75v2RJy6MHAaLK+Mqeo04g== @@ -8957,7 +9845,7 @@ expo-notifications@~0.32.11: expo-application "~7.0.8" expo-constants "~18.0.13" -expo-router@*, expo-router@6.0.22: +expo-router@6.0.22: version "6.0.22" resolved "https://registry.npmjs.org/expo-router/-/expo-router-6.0.22.tgz" integrity sha512-6eOwobaVZQRsSQv0IoWwVlPbJru1zbreVsuPFIWwk7HApENStU2MggrceHXJqXjGho+FKeXxUop/gqOFDzpOMg== @@ -9005,6 +9893,11 @@ expo-server@^1.0.5: resolved "https://registry.npmjs.org/expo-server/-/expo-server-1.0.5.tgz" integrity sha512-IGR++flYH70rhLyeXF0Phle56/k4cee87WeQ4mamS+MkVAVP+dDlOHf2nN06Z9Y2KhU0Gp1k+y61KkghF7HdhA== +expo-sharing@~14.0.8: + version "14.0.8" + resolved "https://registry.npmjs.org/expo-sharing/-/expo-sharing-14.0.8.tgz" + integrity sha512-A1pPr2iBrxypFDCWVAESk532HK+db7MFXbvO2sCV9ienaFXAk7lIBm6bkqgE6vzRd9O3RGdEGzYx80cYlc089Q== + expo-speech-recognition@^3.1.0: version "3.1.0" resolved "https://registry.npmjs.org/expo-speech-recognition/-/expo-speech-recognition-3.1.0.tgz" @@ -9077,7 +9970,7 @@ expo-web-browser@~15.0.7: resolved "https://registry.npmjs.org/expo-web-browser/-/expo-web-browser-15.0.10.tgz" integrity sha512-fvDhW4bhmXAeWFNFiInmsGCK83PAqAcQaFyp/3pE/jbdKmFKoRCWr46uZGIfN4msLK/OODhaQ/+US7GSJNDHJg== -expo@*, expo@^54, expo@^54.0.0, expo@>=49.0.0: +expo@^54.0.0: version "54.0.32" resolved "https://registry.npmjs.org/expo/-/expo-54.0.32.tgz" integrity sha512-yL9eTxiQ/QKKggVDAWO5CLjUl6IS0lPYgEvC3QM4q4fxd6rs7ks3DnbXSGVU3KNFoY/7cRNYihvd0LKYP+MCXA== @@ -9114,7 +10007,7 @@ express-rate-limit@^7.5.0: resolved "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-7.5.1.tgz" integrity sha512-7iN8iPMDzOMHPUYllBEsQdWVB6fPDMPqwjBaFrgr4Jgr/+okjvzAy+UHlYYL/Vs0OsOrMkwS6PJDkFlJwoxUnw== -express@^5.0.1, "express@>= 4.11": +express@^5.0.1: version "5.2.1" resolved "https://registry.npmjs.org/express/-/express-5.2.1.tgz" integrity sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw== @@ -9196,6 +10089,11 @@ fast-deep-equal@^3.1.1, fast-deep-equal@^3.1.3: resolved "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz" integrity sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q== +fast-fifo@^1.2.0, fast-fifo@^1.3.2: + version "1.3.2" + resolved "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz" + integrity sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ== + fast-glob@^3.2.12, fast-glob@^3.3.2: version "3.3.3" resolved "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz" @@ -9207,12 +10105,7 @@ fast-glob@^3.2.12, fast-glob@^3.3.2: merge2 "^1.3.0" micromatch "^4.0.8" -fast-json-stable-stringify@^2.0.0: - version "2.1.0" - resolved "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz" - integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== - -fast-json-stable-stringify@^2.1.0: +fast-json-stable-stringify@^2.0.0, fast-json-stable-stringify@^2.1.0: version "2.1.0" resolved "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz" integrity sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw== @@ -9270,7 +10163,7 @@ fastify-type-provider-zod@6.1.0: dependencies: "@fastify/error" "^4.2.0" -fastify@^5.2.0, fastify@^5.5.0, fastify@^5.6.2: +fastify@^5.2.0, fastify@^5.6.2: version "5.7.2" resolved "https://registry.npmjs.org/fastify/-/fastify-5.7.2.tgz" integrity sha512-dBJolW+hm6N/yJVf6J5E1BxOBNkuXNl405nrfeR8SpvGWG3aCC2XDHyiFBdow8Win1kj7sjawQc257JlYY6M/A== @@ -9354,18 +10247,6 @@ filter-obj@^1.1.0: resolved "https://registry.npmjs.org/filter-obj/-/filter-obj-1.1.0.tgz" integrity sha512-8rXg1ZnX7xzy2NGDVkBVaAy+lSlPNwad13BtgSlLuxfIslyt5Vg64U7tFcCt4WS1R0hvtnQybT/IyCkGZ3DpXQ== -finalhandler@^2.1.0: - version "2.1.1" - resolved "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz" - integrity sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA== - dependencies: - debug "^4.4.0" - encodeurl "^2.0.0" - escape-html "^1.0.3" - on-finished "^2.4.1" - parseurl "^1.3.3" - statuses "^2.0.1" - finalhandler@1.1.2: version "1.1.2" resolved "https://registry.npmjs.org/finalhandler/-/finalhandler-1.1.2.tgz" @@ -9379,6 +10260,18 @@ finalhandler@1.1.2: statuses "~1.5.0" unpipe "~1.0.0" +finalhandler@^2.1.0: + version "2.1.1" + resolved "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz" + integrity sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA== + dependencies: + debug "^4.4.0" + encodeurl "^2.0.0" + escape-html "^1.0.3" + on-finished "^2.4.1" + parseurl "^1.3.3" + statuses "^2.0.1" + find-babel-config@^2.1.1: version "2.1.2" resolved "https://registry.npmjs.org/find-babel-config/-/find-babel-config-2.1.2.tgz" @@ -9538,6 +10431,11 @@ fresh@~0.5.2: resolved "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz" integrity sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q== +fs-constants@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/fs-constants/-/fs-constants-1.0.0.tgz" + integrity sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow== + fs-extra@^10.0.0: version "10.1.0" resolved "https://registry.npmjs.org/fs-extra/-/fs-extra-10.1.0.tgz" @@ -9552,7 +10450,17 @@ fs.realpath@^1.0.0: resolved "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz" integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== -"fumadocs-core@^15.0.0 || ^16.0.0", fumadocs-core@16.4.11: +fsevents@2.3.2: + version "2.3.2" + resolved "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz" + integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== + +fsevents@^2.3.2, fsevents@~2.3.2, fsevents@~2.3.3: + version "2.3.3" + resolved "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz" + integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== + +fumadocs-core@16.4.11: version "16.4.11" resolved "https://registry.npmjs.org/fumadocs-core/-/fumadocs-core-16.4.11.tgz" integrity sha512-ORjWgYetxDgyHZocuvEghfxt6tuEPWE+Km5KvwNKlXPxcNdBIiSVCED8WEMwiw1n/FZ/ys+W+BOe58ZXxhWg2A== @@ -9782,12 +10690,17 @@ git-url-parse@16.1.0: dependencies: git-up "^8.1.0" +github-from-package@0.0.0: + version "0.0.0" + resolved "https://registry.npmjs.org/github-from-package/-/github-from-package-0.0.0.tgz" + integrity sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw== + github-slugger@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/github-slugger/-/github-slugger-2.0.0.tgz" integrity sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw== -glob-parent@^5.1.2: +glob-parent@^5.1.2, glob-parent@~5.1.2: version "5.1.2" resolved "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz" integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== @@ -9801,14 +10714,7 @@ glob-parent@^6.0.2: dependencies: is-glob "^4.0.3" -glob-parent@~5.1.2: - version "5.1.2" - resolved "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz" - integrity sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow== - dependencies: - is-glob "^4.0.1" - -glob@^10.3.7: +glob@^10.0.0, glob@^10.3.7: version "10.5.0" resolved "https://registry.npmjs.org/glob/-/glob-10.5.0.tgz" integrity sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg== @@ -9821,27 +10727,15 @@ glob@^10.3.7: path-scurry "^1.11.1" glob@^13.0.0: - version "13.0.0" - resolved "https://registry.npmjs.org/glob/-/glob-13.0.0.tgz" - integrity sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA== - dependencies: - minimatch "^10.1.1" - minipass "^7.1.2" - path-scurry "^2.0.0" - -glob@^7.0.0: - version "7.2.3" - resolved "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz" - integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== - dependencies: - fs.realpath "^1.0.0" - inflight "^1.0.4" - inherits "2" - minimatch "^3.1.1" - once "^1.3.0" - path-is-absolute "^1.0.0" + version "13.0.0" + resolved "https://registry.npmjs.org/glob/-/glob-13.0.0.tgz" + integrity sha512-tvZgpqk6fz4BaNZ66ZsRaZnbHvP/jG3uKJvAZOwEVUL4RTA5nJeeLYfyN9/VA8NX/V3IBG+hkeuGpKjvELkVhA== + dependencies: + minimatch "^10.1.1" + minipass "^7.1.2" + path-scurry "^2.0.0" -glob@^7.1.1, glob@^7.1.3, glob@^7.1.4: +glob@^7.0.0, glob@^7.1.1, glob@^7.1.3, glob@^7.1.4: version "7.2.3" resolved "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz" integrity sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q== @@ -9887,15 +10781,7 @@ globals@^14.0.0: resolved "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz" integrity sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ== -globalthis@^1.0.1: - version "1.0.4" - resolved "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz" - integrity sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ== - dependencies: - define-properties "^1.2.1" - gopd "^1.0.1" - -globalthis@^1.0.4: +globalthis@^1.0.1, globalthis@^1.0.4: version "1.0.4" resolved "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz" integrity sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ== @@ -10015,7 +10901,7 @@ hast-util-to-estree@^3.0.0, hast-util-to-estree@^3.1.3: unist-util-position "^5.0.0" zwitch "^2.0.0" -hast-util-to-html@^9.0.5, hast-util-to-html@9.0.5: +hast-util-to-html@9.0.5, hast-util-to-html@^9.0.5: version "9.0.5" resolved "https://registry.npmjs.org/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz" integrity sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw== @@ -10082,7 +10968,7 @@ hermes-estree@0.32.0: resolved "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.32.0.tgz" integrity sha512-KWn3BqnlDOl97Xe1Yviur6NbgIZ+IP+UVSpshlZWkq+EtoHg6/cwiDj/osP9PCEgFE15KBm1O55JRwbMEm5ejQ== -hermes-parser@^0.29.1, hermes-parser@0.29.1: +hermes-parser@0.29.1, hermes-parser@^0.29.1: version "0.29.1" resolved "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.29.1.tgz" integrity sha512-xBHWmUtRC5e/UL0tI7Ivt2riA/YBq9+SiYFU7C1oBa/j2jYGlIF9043oak1F47ihuDIxQ5nbsKueYJDRY02UgA== @@ -10198,24 +11084,17 @@ hyphenate-style-name@^1.0.3: resolved "https://registry.npmjs.org/hyphenate-style-name/-/hyphenate-style-name-1.1.0.tgz" integrity sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw== -iconv-lite@^0.7.0, iconv-lite@~0.7.0: - version "0.7.2" - resolved "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz" - integrity sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw== - dependencies: - safer-buffer ">= 2.1.2 < 3.0.0" - -iconv-lite@0.6: +iconv-lite@0.6, iconv-lite@0.6.3: version "0.6.3" resolved "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz" integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw== dependencies: safer-buffer ">= 2.1.2 < 3.0.0" -iconv-lite@0.6.3: - version "0.6.3" - resolved "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz" - integrity sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw== +iconv-lite@^0.7.0, iconv-lite@~0.7.0: + version "0.7.2" + resolved "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.2.tgz" + integrity sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw== dependencies: safer-buffer ">= 2.1.2 < 3.0.0" @@ -10241,7 +11120,7 @@ image-size@^2.0.2: resolved "https://registry.npmjs.org/image-size/-/image-size-2.0.2.tgz" integrity sha512-IRqXKlaXwgSMAMtpNzZa1ZAe8m+Sa1770Dhk8VkSsP9LS+iHD62Zd8FQKs8fbPiagBE7BzoFX23cxFnwshpV6w== -immer@^11.0.0, immer@>=10.0.0, immer@>=9.0.6: +immer@>=10.0.0, immer@^11.0.0: version "11.1.3" resolved "https://registry.npmjs.org/immer/-/immer-11.1.3.tgz" integrity sha512-6jQTc5z0KJFtr1UgFpIL3N9XSC3saRaI9PwWtzM2pSqkNGtiNkYY2OSwkOGDK2XcTRcLb1pi/aNkKZz0nxVH4Q== @@ -10282,7 +11161,7 @@ inflight@^1.0.4: once "^1.3.0" wrappy "1" -inherits@^2.0.3, inherits@~2.0.3, inherits@~2.0.4, inherits@2: +inherits@2, inherits@^2.0.3, inherits@^2.0.4, inherits@~2.0.3, inherits@~2.0.4: version "2.0.4" resolved "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz" integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== @@ -10355,22 +11234,22 @@ internal-slot@^1.1.0: hasown "^2.0.2" side-channel "^1.1.0" -internmap@^1.0.0: - version "1.0.1" - resolved "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz" - integrity sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw== - "internmap@1 - 2": version "2.0.3" resolved "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz" integrity sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg== +internmap@^1.0.0: + version "1.0.1" + resolved "https://registry.npmjs.org/internmap/-/internmap-1.0.1.tgz" + integrity sha512-lDB5YccMydFBtasVtxnZ3MRBHuaoE8GKsppq+EchKL2U4nK/DmEpPHNH8MZe5HkMtpSiTSOZwfN0tzYjO/lJEw== + interpret@^1.0.0: version "1.4.0" resolved "https://registry.npmjs.org/interpret/-/interpret-1.4.0.tgz" integrity sha512-agE4QfB2Lkp9uICn7BAqoscw4SZP9kTE2hxiFI3jBPmXJfdqiahTbUuKGsMoN2GtqL9AxhYioAcVvgsb1HvRbA== -invariant@^2.2.4, invariant@2.2.4: +invariant@2.2.4, invariant@^2.2.4: version "2.2.4" resolved "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz" integrity sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA== @@ -10397,16 +11276,16 @@ ip-address@^10.0.1: resolved "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz" integrity sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q== -ipaddr.js@^2.0.1, ipaddr.js@^2.1.0: - version "2.3.0" - resolved "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz" - integrity sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg== - ipaddr.js@1.9.1: version "1.9.1" resolved "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz" integrity sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g== +ipaddr.js@^2.0.1, ipaddr.js@^2.1.0: + version "2.3.0" + resolved "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.3.0.tgz" + integrity sha512-Zv/pA+ciVFbCSBBjGfaKUya/CcGmUHzTydLMaTwrUUEM2DIEO3iZvueGxmacvmN50fGpGVKeTXpb2LcYQxeVdg== + is-alphabetical@^2.0.0: version "2.0.1" resolved "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz" @@ -10420,15 +11299,7 @@ is-alphanumerical@^2.0.0: is-alphabetical "^2.0.0" is-decimal "^2.0.0" -is-arguments@^1.0.4: - version "1.2.0" - resolved "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz" - integrity sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA== - dependencies: - call-bound "^1.0.2" - has-tostringtag "^1.0.2" - -is-arguments@^1.1.1: +is-arguments@^1.0.4, is-arguments@^1.1.1: version "1.2.0" resolved "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz" integrity sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA== @@ -10551,18 +11422,7 @@ is-fullwidth-code-point@^5.0.0: dependencies: get-east-asian-width "^1.3.1" -is-generator-function@^1.0.10: - version "1.1.2" - resolved "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz" - integrity sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA== - dependencies: - call-bound "^1.0.4" - generator-function "^2.0.0" - get-proto "^1.0.1" - has-tostringtag "^1.0.2" - safe-regex-test "^1.1.0" - -is-generator-function@^1.0.7: +is-generator-function@^1.0.10, is-generator-function@^1.0.7: version "1.1.2" resolved "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz" integrity sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA== @@ -10699,7 +11559,7 @@ is-ssh@^1.4.0: dependencies: protocols "^2.0.1" -is-stream@^2.0.0: +is-stream@^2.0.0, is-stream@^2.0.1: version "2.0.1" resolved "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz" integrity sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg== @@ -10726,14 +11586,7 @@ is-symbol@^1.0.4, is-symbol@^1.1.1: has-symbols "^1.1.0" safe-regex-test "^1.1.0" -is-typed-array@^1.1.13, is-typed-array@^1.1.14, is-typed-array@^1.1.15: - version "1.1.15" - resolved "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz" - integrity sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ== - dependencies: - which-typed-array "^1.1.16" - -is-typed-array@^1.1.3: +is-typed-array@^1.1.13, is-typed-array@^1.1.14, is-typed-array@^1.1.15, is-typed-array@^1.1.3: version "1.1.15" resolved "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz" integrity sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ== @@ -10784,6 +11637,11 @@ isarray@^2.0.5: resolved "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz" integrity sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw== +isarray@~1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz" + integrity sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ== + isexe@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz" @@ -10942,16 +11800,16 @@ jimp-compact@0.16.1: resolved "https://registry.npmjs.org/jimp-compact/-/jimp-compact-0.16.1.tgz" integrity sha512-dZ6Ra7u1G8c4Letq/B5EzAxj4tLFHL+cGtdpR+PVm4yzPDj+lCk+AbivWt1eOM+ikzkowtyV7qSqX6qr3t71Ww== -jiti@*, jiti@^2.4.2, jiti@^2.6.1, jiti@>=1.21.0: - version "2.6.1" - resolved "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz" - integrity sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ== - jiti@^1.21.7: version "1.21.7" resolved "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz" integrity sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A== +jiti@^2.4.2, jiti@^2.6.1: + version "2.6.1" + resolved "https://registry.npmjs.org/jiti/-/jiti-2.6.1.tgz" + integrity sha512-ekilCSN1jwRvIbgeg/57YFh8qQDNbwDb9xT/qu2DAHbFFZUicIl4ygVaAvzveMhMVr3LnpSKTNnwt8PoOfmKhQ== + jose@^6.0.11, jose@^6.0.12, jose@^6.1.0, jose@^6.1.1: version "6.1.3" resolved "https://registry.npmjs.org/jose/-/jose-6.1.3.tgz" @@ -10992,7 +11850,7 @@ jsc-safe-url@^0.2.2, jsc-safe-url@^0.2.4: resolved "https://registry.npmjs.org/jsc-safe-url/-/jsc-safe-url-0.2.4.tgz" integrity sha512-0wM3YBWtYePOjfyXQH5MWQ8H7sdk5EXSwZvmSLKk2RboVQ2Bu239jycHDz5J/8Blf3K0Qnoy2b6xD+z10MFB+Q== -jsdom@*, jsdom@^24.1.0: +jsdom@^24.1.0: version "24.1.3" resolved "https://registry.npmjs.org/jsdom/-/jsdom-24.1.3.tgz" integrity sha512-MyL55p3Ut3cXbeBEG7Hcv0mVM8pp8PBNWxRqchZnSfAiES1v1mRnMeFfaHWIPULpwsYfvO+ZmMZz5tGCnjzDUQ== @@ -11203,6 +12061,13 @@ layout-base@^2.0.0: resolved "https://registry.npmjs.org/layout-base/-/layout-base-2.0.1.tgz" integrity sha512-dp3s92+uNI1hWIpPGH3jK2kxE2lMjdXdr+DH8ynZHpd6PUlH6x6cbuXnoMmiNumznqaNO31xu9e79F0uuZ0JFg== +lazystream@^1.0.0: + version "1.0.1" + resolved "https://registry.npmjs.org/lazystream/-/lazystream-1.0.1.tgz" + integrity sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw== + dependencies: + readable-stream "^2.0.5" + leven@^3.1.0: version "3.1.0" resolved "https://registry.npmjs.org/leven/-/leven-3.1.0.tgz" @@ -11269,6 +12134,16 @@ lighthouse-logger@^1.0.0: debug "^2.6.9" marky "^1.2.2" +lightningcss-android-arm64@1.30.2: + version "1.30.2" + resolved "https://registry.yarnpkg.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.30.2.tgz#6966b7024d39c94994008b548b71ab360eb3a307" + integrity sha512-BH9sEdOCahSgmkVhBLeU7Hc9DWeZ1Eb6wNS6Da8igvUwAe0sqROHddIlvU06q3WyXVEOYDZ6ykBZQnjTbmo4+A== + +lightningcss-android-arm64@1.31.1: + version "1.31.1" + resolved "https://registry.yarnpkg.com/lightningcss-android-arm64/-/lightningcss-android-arm64-1.31.1.tgz#609ff48332adff452a8157a7c2842fd692a8eac4" + integrity sha512-HXJF3x8w9nQ4jbXRiNppBCqeZPIAfUo8zE/kOEGbW5NZvGc/K7nMxbhIr+YlFlHW5mpbg/YFPdbnCh1wAXCKFg== + lightningcss-darwin-arm64@1.30.2: version "1.30.2" resolved "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.30.2.tgz" @@ -11279,7 +12154,97 @@ lightningcss-darwin-arm64@1.31.1: resolved "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.31.1.tgz" integrity sha512-02uTEqf3vIfNMq3h/z2cJfcOXnQ0GRwQrkmPafhueLb2h7mqEidiCzkE4gBMEH65abHRiQvhdcQ+aP0D0g67sg== -lightningcss@^1.21.0, lightningcss@1.30.2: +lightningcss-darwin-x64@1.30.2: + version "1.30.2" + resolved "https://registry.yarnpkg.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.30.2.tgz#5ce87e9cd7c4f2dcc1b713f5e8ee185c88d9b7cd" + integrity sha512-oBZgKchomuDYxr7ilwLcyms6BCyLn0z8J0+ZZmfpjwg9fRVZIR5/GMXd7r9RH94iDhld3UmSjBM6nXWM2TfZTQ== + +lightningcss-darwin-x64@1.31.1: + version "1.31.1" + resolved "https://registry.yarnpkg.com/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.31.1.tgz#f7482c311273571ec0c2bd8277c1f5f6e90e03a4" + integrity sha512-1ObhyoCY+tGxtsz1lSx5NXCj3nirk0Y0kB/g8B8DT+sSx4G9djitg9ejFnjb3gJNWo7qXH4DIy2SUHvpoFwfTA== + +lightningcss-freebsd-x64@1.30.2: + version "1.30.2" + resolved "https://registry.yarnpkg.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.30.2.tgz#6ae1d5e773c97961df5cff57b851807ef33692a5" + integrity sha512-c2bH6xTrf4BDpK8MoGG4Bd6zAMZDAXS569UxCAGcA7IKbHNMlhGQ89eRmvpIUGfKWNVdbhSbkQaWhEoMGmGslA== + +lightningcss-freebsd-x64@1.31.1: + version "1.31.1" + resolved "https://registry.yarnpkg.com/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.31.1.tgz#91df1bb290f1cb7bb2af832d7d0d8809225e0124" + integrity sha512-1RINmQKAItO6ISxYgPwszQE1BrsVU5aB45ho6O42mu96UiZBxEXsuQ7cJW4zs4CEodPUioj/QrXW1r9pLUM74A== + +lightningcss-linux-arm-gnueabihf@1.30.2: + version "1.30.2" + resolved "https://registry.yarnpkg.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.30.2.tgz#62c489610c0424151a6121fa99d77731536cdaeb" + integrity sha512-eVdpxh4wYcm0PofJIZVuYuLiqBIakQ9uFZmipf6LF/HRj5Bgm0eb3qL/mr1smyXIS1twwOxNWndd8z0E374hiA== + +lightningcss-linux-arm-gnueabihf@1.31.1: + version "1.31.1" + resolved "https://registry.yarnpkg.com/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.31.1.tgz#c3cad5ae8b70045f21600dc95295ab6166acf57e" + integrity sha512-OOCm2//MZJ87CdDK62rZIu+aw9gBv4azMJuA8/KB74wmfS3lnC4yoPHm0uXZ/dvNNHmnZnB8XLAZzObeG0nS1g== + +lightningcss-linux-arm64-gnu@1.30.2: + version "1.30.2" + resolved "https://registry.yarnpkg.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.30.2.tgz#2a3661b56fe95a0cafae90be026fe0590d089298" + integrity sha512-UK65WJAbwIJbiBFXpxrbTNArtfuznvxAJw4Q2ZGlU8kPeDIWEX1dg3rn2veBVUylA2Ezg89ktszWbaQnxD/e3A== + +lightningcss-linux-arm64-gnu@1.31.1: + version "1.31.1" + resolved "https://registry.yarnpkg.com/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.31.1.tgz#a5c4f6a5ac77447093f61b209c0bd7fef1f0a3e3" + integrity sha512-WKyLWztD71rTnou4xAD5kQT+982wvca7E6QoLpoawZ1gP9JM0GJj4Tp5jMUh9B3AitHbRZ2/H3W5xQmdEOUlLg== + +lightningcss-linux-arm64-musl@1.30.2: + version "1.30.2" + resolved "https://registry.yarnpkg.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.30.2.tgz#d7ddd6b26959245e026bc1ad9eb6aa983aa90e6b" + integrity sha512-5Vh9dGeblpTxWHpOx8iauV02popZDsCYMPIgiuw97OJ5uaDsL86cnqSFs5LZkG3ghHoX5isLgWzMs+eD1YzrnA== + +lightningcss-linux-arm64-musl@1.31.1: + version "1.31.1" + resolved "https://registry.yarnpkg.com/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.31.1.tgz#af26ab8f829b727ada0a200938a6c8796ff36900" + integrity sha512-mVZ7Pg2zIbe3XlNbZJdjs86YViQFoJSpc41CbVmKBPiGmC4YrfeOyz65ms2qpAobVd7WQsbW4PdsSJEMymyIMg== + +lightningcss-linux-x64-gnu@1.30.2: + version "1.30.2" + resolved "https://registry.yarnpkg.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.30.2.tgz#5a89814c8e63213a5965c3d166dff83c36152b1a" + integrity sha512-Cfd46gdmj1vQ+lR6VRTTadNHu6ALuw2pKR9lYq4FnhvgBc4zWY1EtZcAc6EffShbb1MFrIPfLDXD6Xprbnni4w== + +lightningcss-linux-x64-gnu@1.31.1: + version "1.31.1" + resolved "https://registry.yarnpkg.com/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.31.1.tgz#a891d44e84b71c0d88959feb9a7522bbf61450ee" + integrity sha512-xGlFWRMl+0KvUhgySdIaReQdB4FNudfUTARn7q0hh/V67PVGCs3ADFjw+6++kG1RNd0zdGRlEKa+T13/tQjPMA== + +lightningcss-linux-x64-musl@1.30.2: + version "1.30.2" + resolved "https://registry.yarnpkg.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.30.2.tgz#808c2e91ce0bf5d0af0e867c6152e5378c049728" + integrity sha512-XJaLUUFXb6/QG2lGIW6aIk6jKdtjtcffUT0NKvIqhSBY3hh9Ch+1LCeH80dR9q9LBjG3ewbDjnumefsLsP6aiA== + +lightningcss-linux-x64-musl@1.31.1: + version "1.31.1" + resolved "https://registry.yarnpkg.com/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.31.1.tgz#8c8b21def851f4d477fa897b80cb3db2b650bc6e" + integrity sha512-eowF8PrKHw9LpoZii5tdZwnBcYDxRw2rRCyvAXLi34iyeYfqCQNA9rmUM0ce62NlPhCvof1+9ivRaTY6pSKDaA== + +lightningcss-win32-arm64-msvc@1.30.2: + version "1.30.2" + resolved "https://registry.yarnpkg.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.30.2.tgz#ab4a8a8a2e6a82a4531e8bbb6bf0ff161ee6625a" + integrity sha512-FZn+vaj7zLv//D/192WFFVA0RgHawIcHqLX9xuWiQt7P0PtdFEVaxgF9rjM/IRYHQXNnk61/H/gb2Ei+kUQ4xQ== + +lightningcss-win32-arm64-msvc@1.31.1: + version "1.31.1" + resolved "https://registry.yarnpkg.com/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.31.1.tgz#79000fb8c57e94a91b8fc643e74d5a54407d7080" + integrity sha512-aJReEbSEQzx1uBlQizAOBSjcmr9dCdL3XuC/6HLXAxmtErsj2ICo5yYggg1qOODQMtnjNQv2UHb9NpOuFtYe4w== + +lightningcss-win32-x64-msvc@1.30.2: + version "1.30.2" + resolved "https://registry.yarnpkg.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.30.2.tgz#f01f382c8e0a27e1c018b0bee316d210eac43b6e" + integrity sha512-5g1yc73p+iAkid5phb4oVFMB45417DkRevRbt/El/gKXJk4jid+vPFF/AXbxn05Aky8PapwzZrdJShv5C0avjw== + +lightningcss-win32-x64-msvc@1.31.1: + version "1.31.1" + resolved "https://registry.yarnpkg.com/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.31.1.tgz#7f025274c81c7d659829731e09c8b6f442209837" + integrity sha512-I9aiFrbd7oYHwlnQDqr1Roz+fTz61oDDJX7n9tYF9FJymH1cIN1DtKw3iYt6b8WZgEjoNwVSncwF4wx/ZedMhw== + +lightningcss@1.30.2: version "1.30.2" resolved "https://registry.npmjs.org/lightningcss/-/lightningcss-1.30.2.tgz" integrity sha512-utfs7Pr5uJyyvDETitgsaqSyjCb2qNRAtuqUeWIAKztsOYdcACf2KtARYXg2pSvhkt+9NfoaNY7fxjl6nuMjIQ== @@ -11327,23 +12292,7 @@ lines-and-columns@^1.1.6: resolved "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz" integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg== -livekit-client@^2.11.4: - version "2.17.0" - resolved "https://registry.npmjs.org/livekit-client/-/livekit-client-2.17.0.tgz" - integrity sha512-BD1QUS44ancVTBdnAher0aO7DV5holFYH2lYradYT/HgXtn6R8xPyvtDAH3UH40jGcesDo9fEopCFwEdOgrIhg== - dependencies: - "@livekit/mutex" "1.1.1" - "@livekit/protocol" "1.42.2" - events "^3.3.0" - jose "^6.1.0" - loglevel "^1.9.2" - sdp-transform "^2.15.0" - ts-debounce "^4.0.0" - tslib "2.8.1" - typed-emitter "^2.1.0" - webrtc-adapter "^9.0.1" - -livekit-client@^2.15.14, livekit-client@^2.15.4, livekit-client@^2.15.8, livekit-client@^2.16.0: +livekit-client@^2.11.4, livekit-client@^2.15.4: version "2.17.0" resolved "https://registry.npmjs.org/livekit-client/-/livekit-client-2.17.0.tgz" integrity sha512-BD1QUS44ancVTBdnAher0aO7DV5holFYH2lYradYT/HgXtn6R8xPyvtDAH3UH40jGcesDo9fEopCFwEdOgrIhg== @@ -11388,16 +12337,16 @@ lockfile@^1.0.4: dependencies: signal-exit "^3.0.2" -lodash-es@^4.17.21: - version "4.17.23" - resolved "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz" - integrity sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg== - lodash-es@4.17.21: version "4.17.21" resolved "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz" integrity sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw== +lodash-es@^4.17.21: + version "4.17.23" + resolved "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.23.tgz" + integrity sha512-kVI48u3PZr38HdYz98UmfPnXl2DXrpdctLrFLCd3kOx1xUkOmpFPx7gCWWM5MPkL/fD8zb+Ph0QzjGFs4+hHWg== + lodash.capitalize@^4.2.1: version "4.2.1" resolved "https://registry.npmjs.org/lodash.capitalize/-/lodash.capitalize-4.2.1.tgz" @@ -11453,7 +12402,7 @@ lodash.isstring@^4.0.1: resolved "https://registry.npmjs.org/lodash.isstring/-/lodash.isstring-4.0.1.tgz" integrity sha512-0wJxfxH1wgO3GrbuP+dTTk7op+6L41QCXbGINEmD+ny/G/eCqGzxyCsh7159S+mgDDcoarnBw6PC1PS5+wUGgw== -lodash.merge@^4.6.2, lodash.merge@4.6.2: +lodash.merge@4.6.2, lodash.merge@^4.6.2: version "4.6.2" resolved "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz" integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ== @@ -11473,7 +12422,7 @@ lodash.uniqby@^4.7.0: resolved "https://registry.npmjs.org/lodash.uniqby/-/lodash.uniqby-4.7.0.tgz" integrity sha512-e/zcLx6CSbmaEgFHCA7BnoQKyCtKMxnuWrJygbwPs/AIn+IMKl66L8/s+wBUn5LRw2pZx3bUHibiV1b6aTWIww== -lodash@^4.15.0, lodash@^4.17.21: +lodash@^4.15.0, lodash@^4.17.15, lodash@^4.17.21: version "4.17.23" resolved "https://registry.npmjs.org/lodash/-/lodash-4.17.23.tgz" integrity sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w== @@ -11493,16 +12442,16 @@ log-symbols@^7.0.1: is-unicode-supported "^2.0.0" yoctocolors "^2.1.1" -loglevel@^1.8.0, loglevel@^1.9.2: - version "1.9.2" - resolved "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz" - integrity sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg== - loglevel@1.9.1: version "1.9.1" resolved "https://registry.npmjs.org/loglevel/-/loglevel-1.9.1.tgz" integrity sha512-hP3I3kCrDIMuRwAwHltphhDM1r8i55H33GgqjXbrisuJhF4kRhW1dNuxsRklp4bXl8DSdLaNLuiL4A/LWRfxvg== +loglevel@^1.8.0, loglevel@^1.9.2: + version "1.9.2" + resolved "https://registry.npmjs.org/loglevel/-/loglevel-1.9.2.tgz" + integrity sha512-HgMmCqIJSAKqo68l0rS2AanEWfkxaZ5wNiEFb5ggm08lDs9Xl2KxBlX3PTcaD2chBM1gXAYf491/M2Rv8Jwayg== + long@^5.0.0, long@^5.2.3: version "5.3.2" resolved "https://registry.npmjs.org/long/-/long-5.3.2.tgz" @@ -11530,11 +12479,6 @@ lowercase-keys@^2.0.0: resolved "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-2.0.0.tgz" integrity sha512-tqNXrS78oMOE73NMxK4EMLQsQowWf8jKooH9g7xPavRT706R6bkQJ6DY2Te7QukaZsulxa30wQ7bk0pm4XiHmA== -lru_map@0.4.1: - version "0.4.1" - resolved "https://registry.npmjs.org/lru_map/-/lru_map-0.4.1.tgz" - integrity sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg== - lru-cache@^10.0.1, lru-cache@^10.2.0, lru-cache@^10.4.3: version "10.4.3" resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz" @@ -11557,7 +12501,12 @@ lru-cache@^7.14.1: resolved "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz" integrity sha512-jumlc0BIUrS3qJGgIkWZsyfAM7NCWiBcCDhnd+3NNM5KbBmLTgHVfWBcg6W+rLUsIpzpERPsvwUP7CckAQSOoA== -lucide-react@*, lucide-react@^0.563.0: +lru_map@0.4.1: + version "0.4.1" + resolved "https://registry.npmjs.org/lru_map/-/lru_map-0.4.1.tgz" + integrity sha512-I+lBvqMMFfqaV8CJCISjI3wbjmwVu/VyOoU7+qtu9d7ioW5klMgsTTiUOUp+DJvfTTzKXoPbyC6YfgkNcyPSOg== + +lucide-react@^0.563.0: version "0.563.0" resolved "https://registry.npmjs.org/lucide-react/-/lucide-react-0.563.0.tgz" integrity sha512-8dXPB2GI4dI8jV4MgUDGBeLdGk8ekfqVZ0BdLcrRzocGgG75ltNEmWS+gE7uokKF/0oSUuczNDT+g9hFJ23FkA== @@ -11608,16 +12557,16 @@ markdown-table@^3.0.0: resolved "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz" integrity sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw== -marked@^16.2.1: - version "16.4.2" - resolved "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz" - integrity sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA== - marked@14.0.0: version "14.0.0" resolved "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz" integrity sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ== +marked@^16.2.1: + version "16.4.2" + resolved "https://registry.npmjs.org/marked/-/marked-16.4.2.tgz" + integrity sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA== + marky@^1.2.2: version "1.3.0" resolved "https://registry.npmjs.org/marky/-/marky-1.3.0.tgz" @@ -11921,7 +12870,7 @@ metro-cache@0.83.3: https-proxy-agent "^7.0.5" metro-core "0.83.3" -metro-config@^0.83.1, metro-config@0.83.3: +metro-config@0.83.3, metro-config@^0.83.1: version "0.83.3" resolved "https://registry.npmjs.org/metro-config/-/metro-config-0.83.3.tgz" integrity sha512-mTel7ipT0yNjKILIan04bkJkuCzUUkm2SeEaTads8VfEecCh+ltXchdq6DovXJqzQAXuR2P9cxZB47Lg4klriA== @@ -11935,7 +12884,7 @@ metro-config@^0.83.1, metro-config@0.83.3: metro-runtime "0.83.3" yaml "^2.6.1" -metro-core@^0.83.1, metro-core@0.83.3: +metro-core@0.83.3, metro-core@^0.83.1: version "0.83.3" resolved "https://registry.npmjs.org/metro-core/-/metro-core-0.83.3.tgz" integrity sha512-M+X59lm7oBmJZamc96usuF1kusd5YimqG/q97g4Ac7slnJ3YiGglW5CsOlicTR5EWf8MQFxxjDoB6ytTqRe8Hw== @@ -11974,7 +12923,7 @@ metro-resolver@0.83.3: dependencies: flow-enums-runtime "^0.0.6" -metro-runtime@^0.83.1, metro-runtime@0.83.3: +metro-runtime@0.83.3, metro-runtime@^0.83.1: version "0.83.3" resolved "https://registry.npmjs.org/metro-runtime/-/metro-runtime-0.83.3.tgz" integrity sha512-JHCJb9ebr9rfJ+LcssFYA2x1qPYuSD/bbePupIGhpMrsla7RCwC/VL3yJ9cSU+nUhU4c9Ixxy8tBta+JbDeZWw== @@ -11982,7 +12931,7 @@ metro-runtime@^0.83.1, metro-runtime@0.83.3: "@babel/runtime" "^7.25.0" flow-enums-runtime "^0.0.6" -metro-source-map@^0.83.1, metro-source-map@0.83.3: +metro-source-map@0.83.3, metro-source-map@^0.83.1: version "0.83.3" resolved "https://registry.npmjs.org/metro-source-map/-/metro-source-map-0.83.3.tgz" integrity sha512-xkC3qwUBh2psVZgVavo8+r2C9Igkk3DibiOXSAht1aYRRcztEZNFtAMtfSB7sdO2iFMx2Mlyu++cBxz/fhdzQg== @@ -12041,7 +12990,7 @@ metro-transform-worker@0.83.3: metro-transform-plugins "0.83.3" nullthrows "^1.1.1" -metro@^0.83.1, metro@0.83.3: +metro@0.83.3, metro@^0.83.1: version "0.83.3" resolved "https://registry.npmjs.org/metro/-/metro-0.83.3.tgz" integrity sha512-+rP+/GieOzkt97hSJ0MrPOuAH/jpaS21ZDvL9DJ35QYRDlQcwzcvUlGUf79AnQxq/2NPiS/AULhhM4TKutIt8Q== @@ -12462,44 +13411,30 @@ micromatch@^4.0.2, micromatch@^4.0.4, micromatch@^4.0.8: braces "^3.0.3" picomatch "^2.3.1" -mime-db@^1.54.0, "mime-db@>= 1.43.0 < 2": - version "1.54.0" - resolved "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz" - integrity sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ== - mime-db@1.52.0: version "1.52.0" resolved "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz" integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== -mime-types@^2.1.12: - version "2.1.35" - resolved "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz" - integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== - dependencies: - mime-db "1.52.0" +"mime-db@>= 1.43.0 < 2", mime-db@^1.54.0: + version "1.54.0" + resolved "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz" + integrity sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ== -mime-types@^2.1.27, mime-types@~2.1.34: - version "2.1.35" - resolved "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz" - integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== +mime-types@3.0.2, mime-types@^3.0.0, mime-types@^3.0.2: + version "3.0.2" + resolved "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz" + integrity sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A== dependencies: - mime-db "1.52.0" + mime-db "^1.54.0" -mime-types@^2.1.35: +mime-types@^2.1.12, mime-types@^2.1.27, mime-types@^2.1.35, mime-types@~2.1.34: version "2.1.35" resolved "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz" integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== dependencies: mime-db "1.52.0" -mime-types@^3.0.0, mime-types@^3.0.2, mime-types@3.0.2: - version "3.0.2" - resolved "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz" - integrity sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A== - dependencies: - mime-db "^1.54.0" - mime@1.6.0: version "1.6.0" resolved "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz" @@ -12556,6 +13491,13 @@ minimatch@^3.0.4, minimatch@^3.1.1, minimatch@^3.1.2: dependencies: brace-expansion "^1.1.7" +minimatch@^5.1.0: + version "5.1.9" + resolved "https://registry.npmjs.org/minimatch/-/minimatch-5.1.9.tgz" + integrity sha512-7o1wEA2RyMP7Iu7GNba9vc0RWWGACJOCZBJX2GJWip0ikV+wcOsgVuY9uE8CPiyQhkGFSlhuSkZPavN7u1c2Fw== + dependencies: + brace-expansion "^2.0.1" + minimatch@^8.0.2: version "8.0.4" resolved "https://registry.npmjs.org/minimatch/-/minimatch-8.0.4.tgz" @@ -12563,14 +13505,7 @@ minimatch@^8.0.2: dependencies: brace-expansion "^2.0.1" -minimatch@^9.0.0: - version "9.0.5" - resolved "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz" - integrity sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow== - dependencies: - brace-expansion "^2.0.1" - -minimatch@^9.0.4: +minimatch@^9.0.0, minimatch@^9.0.4: version "9.0.5" resolved "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz" integrity sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow== @@ -12619,6 +13554,11 @@ minizlib@^3.1.0: dependencies: minipass "^7.1.2" +mkdirp-classic@^0.5.2, mkdirp-classic@^0.5.3: + version "0.5.3" + resolved "https://registry.npmjs.org/mkdirp-classic/-/mkdirp-classic-0.5.3.tgz" + integrity sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A== + mkdirp@^1.0.4: version "1.0.4" resolved "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz" @@ -12654,11 +13594,6 @@ monaco-editor@^0.55.1: dompurify "3.2.7" marked "14.0.0" -ms@^2.1.1, ms@^2.1.3, ms@2.1.3: - version "2.1.3" - resolved "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz" - integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== - ms@2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz" @@ -12669,6 +13604,11 @@ ms@2.1.2: resolved "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz" integrity sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w== +ms@2.1.3, ms@^2.1.1, ms@^2.1.3: + version "2.1.3" + resolved "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz" + integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== + mute-stream@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/mute-stream/-/mute-stream-2.0.0.tgz" @@ -12688,11 +13628,21 @@ nanoid@^3.3.11, nanoid@^3.3.6, nanoid@^3.3.7, nanoid@^3.3.8: resolved "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz" integrity sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w== +napi-build-utils@^2.0.0: + version "2.0.0" + resolved "https://registry.npmjs.org/napi-build-utils/-/napi-build-utils-2.0.0.tgz" + integrity sha512-GEbrYkbfF7MoNaoh2iGG84Mnf/WZfB0GdGEsM8wz7Expx/LlWf5U8t9nvJKXSp3qr5IsEbK04cBGhol/KwOsWA== + natural-compare@^1.4.0: version "1.4.0" resolved "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz" integrity sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw== +negotiator@0.6.3: + version "0.6.3" + resolved "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz" + integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg== + negotiator@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz" @@ -12703,11 +13653,6 @@ negotiator@~0.6.4: resolved "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz" integrity sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w== -negotiator@0.6.3: - version "0.6.3" - resolved "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz" - integrity sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg== - nested-error-stacks@~2.0.1: version "2.0.1" resolved "https://registry.npmjs.org/nested-error-stacks/-/nested-error-stacks-2.0.1.tgz" @@ -12730,7 +13675,7 @@ next-themes@^0.4.6: resolved "https://registry.npmjs.org/next-themes/-/next-themes-0.4.6.tgz" integrity sha512-pZvgD5L0IEvX5/9GWyHMf3m8BKiVQwsCMHfoFosXtXBMnaS0ZnIJ9ST4b4NqLVKDEm8QBxoNNGNaBv2JNF6XNA== -"next@^15.3.0 || ^16.0.0", next@16.1.4, next@16.x.x: +next@16.1.4: version "16.1.4" resolved "https://registry.npmjs.org/next/-/next-16.1.4.tgz" integrity sha512-gKSecROqisnV7Buen5BfjmXAm7Xlpx9o2ueVQRo5DxQcjC8d330dOM1xiGWc2k3Dcnz0In3VybyRPOsudwgiqQ== @@ -12752,13 +13697,18 @@ next-themes@^0.4.6: "@next/swc-win32-x64-msvc" "16.1.4" sharp "^0.34.4" -node-abi@^3.73.0: +node-abi@^3.3.0, node-abi@^3.73.0: version "3.87.0" resolved "https://registry.npmjs.org/node-abi/-/node-abi-3.87.0.tgz" integrity sha512-+CGM1L1CgmtheLcBuleyYOn7NWPVu0s0EJH2C4puxgEZb9h8QpR9G2dBfZJOAUhi7VQxuBPMd0hiISWcTyiYyQ== dependencies: semver "^7.3.5" +node-addon-api@^7.1.0: + version "7.1.1" + resolved "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz" + integrity sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ== + node-fetch-native@^1.6.6: version "1.6.7" resolved "https://registry.npmjs.org/node-fetch-native/-/node-fetch-native-1.6.7.tgz" @@ -12781,6 +13731,13 @@ node-int64@^0.4.0: resolved "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz" integrity sha512-O5lz91xSOeoXP6DulyHfllpq+Eg00MWitZIbtPfoSEvqIHdl5gfcY6hYzDWnj0qD5tz52PI08u9qUvSVeUBeHw== +node-pty@^1.1.0: + version "1.1.0" + resolved "https://registry.npmjs.org/node-pty/-/node-pty-1.1.0.tgz" + integrity sha512-20JqtutY6JPXTUnL0ij1uad7Qe1baT46lyolh2sSENDd4sTzKZ4nmAFkeAARDKwmlLjPx6XKRlwRUxwjOy+lUg== + dependencies: + node-addon-api "^7.1.0" + node-releases@^2.0.27: version "2.0.27" resolved "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz" @@ -12886,24 +13843,12 @@ object-is@^1.1.5: call-bind "^1.0.7" define-properties "^1.2.1" -object-keys@^1.1.1: - version "1.1.1" - resolved "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz" - integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA== - -object.assign@^4.1.4: - version "4.1.7" - resolved "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz" - integrity sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw== - dependencies: - call-bind "^1.0.8" - call-bound "^1.0.3" - define-properties "^1.2.1" - es-object-atoms "^1.0.0" - has-symbols "^1.1.0" - object-keys "^1.1.1" - -object.assign@^4.1.7: +object-keys@^1.1.1: + version "1.1.1" + resolved "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz" + integrity sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA== + +object.assign@^4.1.4, object.assign@^4.1.7: version "4.1.7" resolved "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz" integrity sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw== @@ -13046,7 +13991,7 @@ onnxruntime-web@1.22.0-dev.20250409-89f8206ba4: platform "^1.3.6" protobufjs "^7.2.4" -open@^10.2.0: +open@10.2.0, open@^10.2.0: version "10.2.0" resolved "https://registry.npmjs.org/open/-/open-10.2.0.tgz" integrity sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA== @@ -13073,17 +14018,7 @@ open@^8.0.4: is-docker "^2.1.1" is-wsl "^2.2.0" -open@10.2.0: - version "10.2.0" - resolved "https://registry.npmjs.org/open/-/open-10.2.0.tgz" - integrity sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA== - dependencies: - default-browser "^5.2.1" - define-lazy-prop "^3.0.0" - is-inside-container "^1.0.0" - wsl-utils "^0.1.0" - -openapi-types@^12.1.3, openapi-types@12.1.3: +openapi-types@12.1.3, openapi-types@^12.1.3: version "12.1.3" resolved "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz" integrity sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw== @@ -13108,18 +14043,6 @@ optionator@^0.9.3: type-check "^0.4.0" word-wrap "^1.2.5" -ora@^3.4.0: - version "3.4.0" - resolved "https://registry.npmjs.org/ora/-/ora-3.4.0.tgz" - integrity sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg== - dependencies: - chalk "^2.4.2" - cli-cursor "^2.1.0" - cli-spinners "^2.0.0" - log-symbols "^2.2.0" - strip-ansi "^5.2.0" - wcwidth "^1.0.1" - ora@9.0.0: version "9.0.0" resolved "https://registry.npmjs.org/ora/-/ora-9.0.0.tgz" @@ -13135,6 +14058,18 @@ ora@9.0.0: string-width "^8.1.0" strip-ansi "^7.1.2" +ora@^3.4.0: + version "3.4.0" + resolved "https://registry.npmjs.org/ora/-/ora-3.4.0.tgz" + integrity sha512-eNwHudNbO1folBP3JsZ19v9azXWtQZjICdr3Q0TDPIaeBQ3mXLrh54wM+er0+hSp+dWKf+Z8KM58CYzEyIYxYg== + dependencies: + chalk "^2.4.2" + cli-cursor "^2.1.0" + cli-spinners "^2.0.0" + log-symbols "^2.2.0" + strip-ansi "^5.2.0" + wcwidth "^1.0.1" + os-name@6.1.0: version "6.1.0" resolved "https://registry.npmjs.org/os-name/-/os-name-6.1.0.tgz" @@ -13164,14 +14099,7 @@ p-limit@^2.0.0, p-limit@^2.2.0: dependencies: p-try "^2.0.0" -p-limit@^3.0.2: - version "3.1.0" - resolved "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz" - integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== - dependencies: - yocto-queue "^0.1.0" - -p-limit@^3.1.0: +p-limit@^3.0.2, p-limit@^3.1.0: version "3.1.0" resolved "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz" integrity sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ== @@ -13315,7 +14243,7 @@ patch-package@^8.0.0: tmp "^0.2.4" yaml "^2.2.2" -path-data-parser@^0.1.0, path-data-parser@0.1.0: +path-data-parser@0.1.0, path-data-parser@^0.1.0: version "0.1.0" resolved "https://registry.npmjs.org/path-data-parser/-/path-data-parser-0.1.0.tgz" integrity sha512-NOnmBpt5Y2RWbuv0LMzsayp3lVylAHLPUTut412ZA3l+C4uw4ZVkQbjShYCQ8TCpUMdPapr4YjUqLYD6v68j+w== @@ -13427,31 +14355,33 @@ picocolors@^1.0.0, picocolors@^1.1.1: resolved "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz" integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== -picomatch@^2.0.4, picomatch@^2.2.3, picomatch@^2.3.1: - version "2.3.1" - resolved "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz" - integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== - -picomatch@^2.2.1: +picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.2.3, picomatch@^2.3.1: version "2.3.1" resolved "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz" integrity sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA== -"picomatch@^3 || ^4", picomatch@^4.0.2, picomatch@^4.0.3: - version "4.0.3" - resolved "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz" - integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q== - picomatch@^3.0.1: version "3.0.1" resolved "https://registry.npmjs.org/picomatch/-/picomatch-3.0.1.tgz" integrity sha512-I3EurrIQMlRc9IaAZnqRR044Phh2DXY+55o7uJ0V+hYZAcQYSuFWsc9q5PvyDHUSCe1Qxn/iBz+78s86zWnGag== +picomatch@^4.0.2, picomatch@^4.0.3: + version "4.0.3" + resolved "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz" + integrity sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q== + pify@^2.3.0: version "2.3.0" resolved "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz" integrity sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog== +pino-abstract-transport@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz#de241578406ac7b8a33ce0d77ae6e8a0b3b68a60" + integrity sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw== + dependencies: + split2 "^4.0.0" + pino-abstract-transport@^3.0.0: version "3.0.0" resolved "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz" @@ -13500,12 +14430,24 @@ pino@^10.1.0: sonic-boom "^4.0.1" thread-stream "^4.0.0" -pirates@^4.0.1: - version "4.0.7" - resolved "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz" - integrity sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA== +pino@^9.9.0: + version "9.14.0" + resolved "https://registry.yarnpkg.com/pino/-/pino-9.14.0.tgz#673d9711c2d1e64d18670c1ec05ef7ba14562556" + integrity sha512-8OEwKp5juEvb/MjpIc4hjqfgCNysrS94RIOMXYvpYCdm/jglrKEiAYmiumbmGhCvs+IcInsphYDFwqrjr7398w== + dependencies: + "@pinojs/redact" "^0.4.0" + atomic-sleep "^1.0.0" + on-exit-leak-free "^2.1.0" + pino-abstract-transport "^2.0.0" + pino-std-serializers "^7.0.0" + process-warning "^5.0.0" + quick-format-unescaped "^4.0.3" + real-require "^0.2.0" + safe-stable-stringify "^2.3.1" + sonic-boom "^4.0.1" + thread-stream "^3.0.0" -pirates@^4.0.4: +pirates@^4.0.1, pirates@^4.0.4: version "4.0.7" resolved "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz" integrity sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA== @@ -13602,7 +14544,7 @@ pngjs@^5.0.0: resolved "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz" integrity sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw== -points-on-curve@^0.2.0, points-on-curve@0.2.0: +points-on-curve@0.2.0, points-on-curve@^0.2.0: version "0.2.0" resolved "https://registry.npmjs.org/points-on-curve/-/points-on-curve-0.2.0.tgz" integrity sha512-0mYKnYYe9ZcqMCWhUjItv/oHjvgEsfKvnUTg8sAtnHr3GVy7rGkXCb6d5cSyqrWqL4k81b9CPg3urd+T7aop3A== @@ -13671,7 +14613,16 @@ postcss-value-parser@^4.0.0, postcss-value-parser@^4.2.0: resolved "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz" integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ== -postcss@^8.0.0, postcss@^8.1.0, postcss@^8.2.14, postcss@^8.4.21, postcss@^8.4.41, postcss@^8.4.47, postcss@^8.5.3, postcss@^8.5.6, postcss@>=8.0.9: +postcss@8.4.31: + version "8.4.31" + resolved "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz" + integrity sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ== + dependencies: + nanoid "^3.3.6" + picocolors "^1.0.0" + source-map-js "^1.0.2" + +postcss@^8.4.41, postcss@^8.4.47, postcss@^8.5.3, postcss@^8.5.6: version "8.5.6" resolved "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz" integrity sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg== @@ -13689,15 +14640,6 @@ postcss@~8.4.32: picocolors "^1.1.1" source-map-js "^1.2.1" -postcss@8.4.31: - version "8.4.31" - resolved "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz" - integrity sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ== - dependencies: - nanoid "^3.3.6" - picocolors "^1.0.0" - source-map-js "^1.0.2" - postgres-array@~2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/postgres-array/-/postgres-array-2.0.0.tgz" @@ -13727,6 +14669,24 @@ posthog-react-native@^4.16.2: dependencies: "@posthog/core" "1.14.1" +prebuild-install@^7.1.2: + version "7.1.3" + resolved "https://registry.npmjs.org/prebuild-install/-/prebuild-install-7.1.3.tgz" + integrity sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug== + dependencies: + detect-libc "^2.0.0" + expand-template "^2.0.3" + github-from-package "0.0.0" + minimist "^1.2.3" + mkdirp-classic "^0.5.3" + napi-build-utils "^2.0.0" + node-abi "^3.3.0" + pump "^3.0.0" + rc "^1.2.7" + simple-get "^4.0.0" + tar-fs "^2.0.0" + tunnel-agent "^0.6.0" + prelude-ls@^1.2.1: version "1.2.1" resolved "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz" @@ -13755,7 +14715,7 @@ prisma-json-types-generator@^3.5.1: semver "^7.7.2" tslib "^2.8.1" -prisma@*, prisma@^6.11.1, prisma@^6.14: +prisma@^6.11.1: version "6.19.2" resolved "https://registry.npmjs.org/prisma/-/prisma-6.19.2.tgz" integrity sha512-XTKeKxtQElcq3U9/jHyxSPgiRgeYDKxWTPOf6NkXA0dNj5j40MfEsZkMbyNpwDWCUv7YBFUl7I2VK/6ALbmhEg== @@ -13784,6 +14744,11 @@ proc-log@^4.0.0: resolved "https://registry.npmjs.org/proc-log/-/proc-log-4.2.0.tgz" integrity sha512-g8+OnU/L2v+wyiVK+D5fA34J7EH8jZ8DDlvwhRCMxmMj7UCBvxiO1mGeN+36JXIKF4zevU4kRBd8lVgG9vLelA== +process-nextick-args@~2.0.0: + version "2.0.1" + resolved "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz" + integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== + process-warning@^4.0.0: version "4.0.1" resolved "https://registry.npmjs.org/process-warning/-/process-warning-4.0.1.tgz" @@ -13961,16 +14926,16 @@ pvutils@^1.1.3: resolved "https://registry.npmjs.org/pvutils/-/pvutils-1.1.5.tgz" integrity sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA== -qrcode-terminal@^0.12.0: - version "0.12.0" - resolved "https://registry.npmjs.org/qrcode-terminal/-/qrcode-terminal-0.12.0.tgz" - integrity sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ== - qrcode-terminal@0.11.0: version "0.11.0" resolved "https://registry.npmjs.org/qrcode-terminal/-/qrcode-terminal-0.11.0.tgz" integrity sha512-Uu7ii+FQy4Qf82G4xu7ShHhjhGahEpCWc3x8UavY3CTcWV+ufmmCtwkr7ZKsX42jdL0kr1B5FKUeqJvAn51jzQ== +qrcode-terminal@^0.12.0: + version "0.12.0" + resolved "https://registry.npmjs.org/qrcode-terminal/-/qrcode-terminal-0.12.0.tgz" + integrity sha512-EXtzRZmC+YGmGlDFbXKxQiMZNwCLEO6BANKXG4iCtSIM0yqc/pappSx3RIKr4r0uh5JsBckOXeKrB3Iz7mdQpQ== + qrcode@^1.5.4: version "1.5.4" resolved "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz" @@ -14039,7 +15004,15 @@ raw-body@^3.0.0, raw-body@^3.0.1: iconv-lite "~0.7.0" unpipe "~1.0.0" -rc@~1.2.7: +rc9@^2.1.2: + version "2.1.2" + resolved "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz" + integrity sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg== + dependencies: + defu "^6.1.4" + destr "^2.0.3" + +rc@^1.2.7, rc@~1.2.7: version "1.2.8" resolved "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz" integrity sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw== @@ -14049,15 +15022,7 @@ rc@~1.2.7: minimist "^1.2.0" strip-json-comments "~2.0.1" -rc9@^2.1.2: - version "2.1.2" - resolved "https://registry.npmjs.org/rc9/-/rc9-2.1.2.tgz" - integrity sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg== - dependencies: - defu "^6.1.4" - destr "^2.0.3" - -react-devtools-core@^6.1.2, react-devtools-core@^6.1.5: +react-devtools-core@^6.1.5: version "6.1.5" resolved "https://registry.npmjs.org/react-devtools-core/-/react-devtools-core-6.1.5.tgz" integrity sha512-ePrwPfxAnB+7hgnEr8vpKxL9cmnp7F322t8oqcPshbIQQhDKgFDW4tjhF2wjVbdXF9O/nyuy3sQWd9JGpiLPvA== @@ -14065,14 +15030,14 @@ react-devtools-core@^6.1.2, react-devtools-core@^6.1.5: shell-quote "^1.6.1" ws "^7" -react-dom@*, "react-dom@^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react-dom@^18.0.0 || ^19.0.0", "react-dom@^18.3.1 || ^19.0.0", react-dom@>=18, react-dom@19.1.0: +react-dom@19.1.0: version "19.1.0" resolved "https://registry.npmjs.org/react-dom/-/react-dom-19.1.0.tgz" integrity sha512-Xs1hdnE+DyKgeHJeJznQmYMIBG3TKIHJJT95Q58nHLSrElKlGQqDTR2HQ9fx5CN/Gk6Vh/kupBTDLU11/nDk/g== dependencies: scheduler "^0.26.0" -"react-dom@^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react-dom@^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react-dom@^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react-dom@^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", react-dom@^19.2.0, react-dom@^19.2.3, react-dom@>=16.8.0: +react-dom@^19.2.3: version "19.2.4" resolved "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz" integrity sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ== @@ -14114,7 +15079,7 @@ react-native-audio-api@^0.8.2: resolved "https://registry.npmjs.org/react-native-audio-api/-/react-native-audio-api-0.8.4.tgz" integrity sha512-OvS5KFK1lAcile4z4easHUxS5i/D2UKF2kTRjEv6HVARs/zqUXahFj6NxhS/PzZ0VEmRDZD7d3V6rfCcO7xgEQ== -react-native-device-info@^14.1.1, "react-native-device-info@>= 10.0.0": +react-native-device-info@^14.1.1: version "14.1.1" resolved "https://registry.npmjs.org/react-native-device-info/-/react-native-device-info-14.1.1.tgz" integrity sha512-lXFpe6DJmzbQXNLWxlMHP2xuTU5gwrKAvI8dCAZuERhW9eOXSubOQIesk9lIBnsi9pI19GMrcpJEvs4ARPRYmw== @@ -14127,7 +15092,7 @@ react-native-drawer-layout@^4.2.1: color "^4.2.3" use-latest-callback "^0.2.4" -react-native-gesture-handler@*, "react-native-gesture-handler@>= 2.0.0", react-native-gesture-handler@>=2.16.1, react-native-gesture-handler@~2.28.0: +react-native-gesture-handler@~2.28.0: version "2.28.0" resolved "https://registry.npmjs.org/react-native-gesture-handler/-/react-native-gesture-handler-2.28.0.tgz" integrity sha512-0msfJ1vRxXKVgTgvL+1ZOoYw3/0z1R+Ked0+udoJhyplC2jbVKIJ8Z1bzWdpQRCV3QcQ87Op0zJVE5DhKK2A0A== @@ -14136,7 +15101,7 @@ react-native-gesture-handler@*, "react-native-gesture-handler@>= 2.0.0", react-n hoist-non-react-statics "^3.3.0" invariant "^2.2.4" -react-native-is-edge-to-edge@^1.1.6, react-native-is-edge-to-edge@^1.2.1, react-native-is-edge-to-edge@1.2.1: +react-native-is-edge-to-edge@1.2.1, react-native-is-edge-to-edge@^1.1.6, react-native-is-edge-to-edge@^1.2.1: version "1.2.1" resolved "https://registry.npmjs.org/react-native-is-edge-to-edge/-/react-native-is-edge-to-edge-1.2.1.tgz" integrity sha512-FLbPWl/MyYQWz+KwqOZsSyj2JmLKglHatd3xLZWskXOpRaio4LfEDEz8E/A6uD8QoTHW6Aobw1jbEwK7KMgR7Q== @@ -14148,7 +15113,7 @@ react-native-keyboard-controller@1.18.5: dependencies: react-native-is-edge-to-edge "^1.2.1" -react-native-localize@^3.5.1, "react-native-localize@>= 3.0.0": +react-native-localize@^3.5.1: version "3.6.1" resolved "https://registry.npmjs.org/react-native-localize/-/react-native-localize-3.6.1.tgz" integrity sha512-0kVFUh8TWeh+8p13yVIDjbcdxR2ZOfPBFNcY/VBh/3/g8KfvXywY/XJkCuCVrX5dCLumMi3aXmy+MmJNy4IaYQ== @@ -14158,7 +15123,7 @@ react-native-mmkv@^3.3.3: resolved "https://registry.npmjs.org/react-native-mmkv/-/react-native-mmkv-3.3.3.tgz" integrity sha512-GMsfOmNzx0p5+CtrCFRVtpOOMYNJXuksBVARSQrCFaZwjUyHJdQzcN900GGaFFNTxw2fs8s5Xje//RDKj9+PZA== -react-native-nitro-modules@*, react-native-nitro-modules@0.33.2: +react-native-nitro-modules@0.33.2: version "0.33.2" resolved "https://registry.npmjs.org/react-native-nitro-modules/-/react-native-nitro-modules-0.33.2.tgz" integrity sha512-ZlfOe6abODeHv/eZf8PxeSkrxIUhEKha6jaAAA9oXy7I6VPr7Ff4dUsAq3cyF3kX0L6qt2Dh9nzD2NdSsDwGpA== @@ -14170,7 +15135,7 @@ react-native-purchases-ui@^9.4.2: dependencies: "@revenuecat/purchases-typescript-internal" "17.29.0" -react-native-purchases@^9.4.2, react-native-purchases@9.7.2: +react-native-purchases@^9.4.2: version "9.7.2" resolved "https://registry.npmjs.org/react-native-purchases/-/react-native-purchases-9.7.2.tgz" integrity sha512-vOHDhPlK/XDS+o0VZzKoXoRPdAN6/E/m2bnnp3PKWxxVX4aFcytlh3tY/KiWpHlQPCraWcZay+oF+E4zGgxkZw== @@ -14183,7 +15148,7 @@ react-native-quick-base64@^2.2.1: resolved "https://registry.npmjs.org/react-native-quick-base64/-/react-native-quick-base64-2.2.2.tgz" integrity sha512-WLHSifHLoamr2kF00Gov0W9ud6CfPshe1rmqWTquVIi9c62qxOaJCFVDrXFZhEBU8B8PvGLVuOlVKH78yhY0Fg== -react-native-reanimated@*, react-native-reanimated@^4.2.1, "react-native-reanimated@>= 2.0.0", react-native-reanimated@>=3.0.0, react-native-reanimated@>=3.16.0, react-native-reanimated@>=3.19.1: +react-native-reanimated@^4.2.1: version "4.2.1" resolved "https://registry.npmjs.org/react-native-reanimated/-/react-native-reanimated-4.2.1.tgz" integrity sha512-/NcHnZMyOvsD/wYXug/YqSKw90P9edN0kEPL5lP4PFf1aQ4F1V7MKe/E0tvfkXKIajy3Qocp5EiEnlcrK/+BZg== @@ -14191,7 +15156,7 @@ react-native-reanimated@*, react-native-reanimated@^4.2.1, "react-native-reanima react-native-is-edge-to-edge "1.2.1" semver "7.7.3" -react-native-safe-area-context@*, "react-native-safe-area-context@>= 4.0.0", "react-native-safe-area-context@>= 5.4.0", react-native-safe-area-context@~5.6.0: +react-native-safe-area-context@~5.6.0: version "5.6.2" resolved "https://registry.npmjs.org/react-native-safe-area-context/-/react-native-safe-area-context-5.6.2.tgz" integrity sha512-4XGqMNj5qjUTYywJqpdWZ9IG8jgkS3h06sfVjfw5yZQZfWnRFXczi0GnYyFyCc2EBps/qFmoCH8fez//WumdVg== @@ -14203,7 +15168,7 @@ react-native-screen-transitions@^1.2.0: dependencies: immer ">=10.0.0" -react-native-screens@*, "react-native-screens@>= 4.0.0", react-native-screens@~4.16.0: +react-native-screens@~4.16.0: version "4.16.0" resolved "https://registry.npmjs.org/react-native-screens/-/react-native-screens-4.16.0.tgz" integrity sha512-yIAyh7F/9uWkOzCi1/2FqvNvK6Wb9Y1+Kzn16SuGfN9YFJDTbwlzGRvePCNTOX0recpLQF3kc2FmvMUhyTCH1Q== @@ -14212,7 +15177,7 @@ react-native-screens@*, "react-native-screens@>= 4.0.0", react-native-screens@~4 react-native-is-edge-to-edge "^1.2.1" warn-once "^0.1.0" -"react-native-svg@>= 15.0.0", react-native-svg@15.12.1: +react-native-svg@15.12.1: version "15.12.1" resolved "https://registry.npmjs.org/react-native-svg/-/react-native-svg-15.12.1.tgz" integrity sha512-vCuZJDf8a5aNC2dlMovEv4Z0jjEUET53lm/iILFnFewa15b4atjVxU6Wirm6O9y6dEsdjDZVD7Q3QM4T1wlI8g== @@ -14243,7 +15208,7 @@ react-native-vision-camera@^4.7.3: resolved "https://registry.npmjs.org/react-native-vision-camera/-/react-native-vision-camera-4.7.3.tgz" integrity sha512-g1/neOyjSqn1kaAa2FxI/qp5KzNvPcF0bnQw6NntfbxH6tm0+8WFZszlgb5OV+iYlB6lFUztCbDtyz5IpL47OA== -react-native-web@*, react-native-web@^0.21.0: +react-native-web@^0.21.0: version "0.21.2" resolved "https://registry.npmjs.org/react-native-web/-/react-native-web-0.21.2.tgz" integrity sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg== @@ -14257,7 +15222,7 @@ react-native-web@*, react-native-web@^0.21.0: postcss-value-parser "^4.2.0" styleq "^0.1.3" -react-native-webview@*, react-native-webview@13.15.0: +react-native-webview@13.15.0: version "13.15.0" resolved "https://registry.npmjs.org/react-native-webview/-/react-native-webview-13.15.0.tgz" integrity sha512-Vzjgy8mmxa/JO6l5KZrsTC7YemSdq+qB01diA0FqjUTaWGAGwuykpJ73MDj3+mzBSlaDxAEugHzTtkUQkQEQeQ== @@ -14265,7 +15230,7 @@ react-native-webview@*, react-native-webview@13.15.0: escape-string-regexp "^4.0.0" invariant "2.2.4" -react-native-worklets@^0.7.1, react-native-worklets@>=0.7.0: +react-native-worklets@^0.7.1: version "0.7.2" resolved "https://registry.npmjs.org/react-native-worklets/-/react-native-worklets-0.7.2.tgz" integrity sha512-DuLu1kMV/Uyl9pQHp3hehAlThoLw7Yk2FwRTpzASOmI+cd4845FWn3m2bk9MnjUw8FBRIyhwLqYm2AJaXDXsog== @@ -14282,46 +15247,7 @@ react-native-worklets@^0.7.1, react-native-worklets@>=0.7.0: convert-source-map "2.0.0" semver "7.7.3" -react-native@*, "react-native@^0.0.0-0 || >=0.65 <1.0": - version "0.81.4" - resolved "file:apps/ui/node_modules/react-native" - dependencies: - "@jest/create-cache-key-function" "^29.7.0" - "@react-native/assets-registry" "0.81.4" - "@react-native/codegen" "0.81.4" - "@react-native/community-cli-plugin" "0.81.4" - "@react-native/gradle-plugin" "0.81.4" - "@react-native/js-polyfills" "0.81.4" - "@react-native/normalize-colors" "0.81.4" - "@react-native/virtualized-lists" "0.81.4" - abort-controller "^3.0.0" - anser "^1.4.9" - ansi-regex "^5.0.0" - babel-jest "^29.7.0" - babel-plugin-syntax-hermes-parser "0.29.1" - base64-js "^1.5.1" - commander "^12.0.0" - flow-enums-runtime "^0.0.6" - glob "^7.1.1" - invariant "^2.2.4" - jest-environment-node "^29.7.0" - memoize-one "^5.0.0" - metro-runtime "^0.83.1" - metro-source-map "^0.83.1" - nullthrows "^1.1.1" - pretty-format "^29.7.0" - promise "^8.3.0" - react-devtools-core "^6.1.5" - react-refresh "^0.14.0" - regenerator-runtime "^0.13.2" - scheduler "0.26.0" - semver "^7.1.3" - stacktrace-parser "^0.1.10" - whatwg-fetch "^3.0.0" - ws "^6.2.3" - yargs "^17.6.2" - -"react-native@>= 0.73.0", react-native@>=0.57, react-native@>=0.60.0, react-native@>=0.65.0, react-native@>=0.70.0, react-native@>=0.76.0, react-native@>=0.78, react-native@0.81.4: +react-native@0.81.4: version "0.81.4" resolved "https://registry.npmjs.org/react-native/-/react-native-0.81.4.tgz" integrity sha512-bt5bz3A/+Cv46KcjV0VQa+fo7MKxs17RCcpzjftINlen4ZDUl0I6Ut+brQ2FToa5oD0IB0xvQHfmsg2EDqsZdQ== @@ -14361,13 +15287,6 @@ react-native@*, "react-native@^0.0.0-0 || >=0.65 <1.0": ws "^6.2.3" yargs "^17.6.2" -react-reconciler@^0.33.0: - version "0.33.0" - resolved "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.33.0.tgz" - integrity sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA== - dependencies: - scheduler "^0.27.0" - react-reconciler@0.31.0: version "0.31.0" resolved "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.31.0.tgz" @@ -14375,7 +15294,14 @@ react-reconciler@0.31.0: dependencies: scheduler "^0.25.0" -react-refresh@^0.14.0, react-refresh@^0.14.2, "react-refresh@>=0.14.0 <1.0.0": +react-reconciler@^0.33.0: + version "0.33.0" + resolved "https://registry.npmjs.org/react-reconciler/-/react-reconciler-0.33.0.tgz" + integrity sha512-KetWRytFv1epdpJc3J4G75I4WrplZE5jOL7Yq0p34+OVOKF4Se7WrdIdVC45XsSSmUTlht2FM/fM1FZb1mfQeA== + dependencies: + scheduler "^0.27.0" + +react-refresh@^0.14.0, react-refresh@^0.14.2: version "0.14.2" resolved "https://registry.npmjs.org/react-refresh/-/react-refresh-0.14.2.tgz" integrity sha512-jCvmsr+1IUSMUyzOkRcvnVbX3ZYC6g9TDrDbFuFmRDq7PD4yaGbLKNQL6k2jnArV8hjYxh7hVhAZB6s9HDGpZA== @@ -14424,29 +15350,22 @@ react-textarea-autosize@^8.5.9: use-composed-ref "^1.3.0" use-latest "^1.2.1" -react@*, "react@^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", "react@^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc", "react@^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react@^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc", react@^19.1.0, "react@>= 18.2.0", react@>=16.8: - version "19.1.0" - resolved "file:apps/ui/node_modules/react" - -"react@^16.14.0 || 17.x || 18.x || 19.x", "react@^16.8.0 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react@^18.0.0 || ^19.0.0", "react@^18.3.1 || ^19.0.0", react@^19.0.0, react@^19.1.0, "react@>= 16.6.3", react@>=16, react@>=16.8.0, react@>=17.0.0, react@>=18, react@>=18.0.0, react@>=19.0, react@19.1.0: +react@19.1.0: version "19.1.0" resolved "https://registry.npmjs.org/react/-/react-19.1.0.tgz" integrity sha512-FS+XFBNvn3GTAWq26joslQgWNoFu08F4kl0J4CgdNKADkdSGXQyTCnKteIAJy96Br6YbpEU1LSzV5dYtjMkMDg== -"react@^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0", "react@^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc", "react@^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0", react@^19.2.0, react@^19.2.3, react@^19.2.4, "react@>= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0", react@>=16.8.0, react@>=19.0.0: - version "19.2.4" - resolved "https://registry.npmjs.org/react/-/react-19.2.4.tgz" - integrity sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ== - -"react@^16.9.0 || ^17.0.0 || ^18 || ^19", react@^18.3.1: +react@^18.3.1: version "18.3.1" resolved "https://registry.npmjs.org/react/-/react-18.3.1.tgz" integrity sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ== dependencies: loose-envify "^1.1.0" -react@^19.2.4: +react@^19.2.0, react@^19.2.3: version "19.2.4" + resolved "https://registry.npmjs.org/react/-/react-19.2.4.tgz" + integrity sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ== read-cache@^1.0.0: version "1.0.0" @@ -14455,7 +15374,7 @@ read-cache@^1.0.0: dependencies: pify "^2.3.0" -readable-stream@^3.4.0, readable-stream@3: +readable-stream@3, readable-stream@^3.1.1, readable-stream@^3.4.0: version "3.6.2" resolved "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz" integrity sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA== @@ -14464,7 +15383,20 @@ readable-stream@^3.4.0, readable-stream@3: string_decoder "^1.1.1" util-deprecate "^1.0.1" -readable-stream@^4.5.2: +readable-stream@^2.0.5: + version "2.3.8" + resolved "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz" + integrity sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA== + dependencies: + core-util-is "~1.0.0" + inherits "~2.0.3" + isarray "~1.0.0" + process-nextick-args "~2.0.0" + safe-buffer "~5.1.1" + string_decoder "~1.1.1" + util-deprecate "~1.0.1" + +readable-stream@^4.0.0, readable-stream@^4.5.2: version "4.7.0" resolved "https://registry.npmjs.org/readable-stream/-/readable-stream-4.7.0.tgz" integrity sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg== @@ -14475,6 +15407,13 @@ readable-stream@^4.5.2: process "^0.11.10" string_decoder "^1.3.0" +readdir-glob@^1.1.2: + version "1.1.3" + resolved "https://registry.npmjs.org/readdir-glob/-/readdir-glob-1.1.3.tgz" + integrity sha512-v05I2k7xN8zXvPD9N+z/uhXPaj0sUFCe2rcWZIpBsqxfP7xXFQ0tipAd/wjj1YxWyWtUS5IDJpOG82JKt2EAVA== + dependencies: + minimatch "^5.1.0" + readdirp@^4.0.1: version "4.1.2" resolved "https://registry.npmjs.org/readdirp/-/readdirp-4.1.2.tgz" @@ -14581,7 +15520,7 @@ redux-thunk@^3.1.0: resolved "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz" integrity sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw== -redux@^5.0.0, redux@^5.0.1: +redux@^5.0.1: version "5.0.1" resolved "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz" integrity sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w== @@ -14911,16 +15850,16 @@ ret@~0.5.0: resolved "https://registry.npmjs.org/ret/-/ret-0.5.0.tgz" integrity sha512-I1XxrZSQ+oErkRR4jYbAyEEu2I0avBvvMM5JN+6EBprOGRCs63ENqZ3vjavq8fBw2+62G5LF5XelKwuJpcvcxw== -retry@^0.12.0: - version "0.12.0" - resolved "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz" - integrity sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow== - retry@0.13.1: version "0.13.1" resolved "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz" integrity sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg== +retry@^0.12.0: + version "0.12.0" + resolved "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz" + integrity sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow== + reusify@^1.0.4: version "1.1.0" resolved "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz" @@ -14952,63 +15891,29 @@ rn-encryption@^2.5.0: dependencies: web-secure-encryption "^1.0.2" -roarr@^2.15.3: - version "2.15.4" - resolved "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz" - integrity sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A== - dependencies: - boolean "^3.0.1" - detect-node "^2.0.4" - globalthis "^1.0.1" - json-stringify-safe "^5.0.1" - semver-compare "^1.0.0" - sprintf-js "^1.1.2" - -robust-predicates@^3.0.2: - version "3.0.2" - resolved "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz" - integrity sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg== - -rollup-pluginutils@^2.8.2: - version "2.8.2" - resolved "https://registry.npmjs.org/rollup-pluginutils/-/rollup-pluginutils-2.8.2.tgz" - integrity sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ== - dependencies: - estree-walker "^0.6.1" - -rollup@^1.20.0||^2.0.0||^3.0.0||^4.0.0, rollup@^2.14.0||^3.0.0||^4.0.0, rollup@^2.68.0||^3.0.0||^4.0.0, rollup@^2.78.0||^3.0.0||^4.0.0, rollup@^4.43.0, rollup@^4.53.1, rollup@>=4.0.0: - version "4.57.0" - resolved "https://registry.npmjs.org/rollup/-/rollup-4.57.0.tgz" - integrity sha512-e5lPJi/aui4TO1LpAXIRLySmwXSE8k3b9zoGfd42p67wzxog4WHjiZF3M2uheQih4DGyc25QEV4yRBbpueNiUA== - dependencies: - "@types/estree" "1.0.8" - optionalDependencies: - "@rollup/rollup-android-arm-eabi" "4.57.0" - "@rollup/rollup-android-arm64" "4.57.0" - "@rollup/rollup-darwin-arm64" "4.57.0" - "@rollup/rollup-darwin-x64" "4.57.0" - "@rollup/rollup-freebsd-arm64" "4.57.0" - "@rollup/rollup-freebsd-x64" "4.57.0" - "@rollup/rollup-linux-arm-gnueabihf" "4.57.0" - "@rollup/rollup-linux-arm-musleabihf" "4.57.0" - "@rollup/rollup-linux-arm64-gnu" "4.57.0" - "@rollup/rollup-linux-arm64-musl" "4.57.0" - "@rollup/rollup-linux-loong64-gnu" "4.57.0" - "@rollup/rollup-linux-loong64-musl" "4.57.0" - "@rollup/rollup-linux-ppc64-gnu" "4.57.0" - "@rollup/rollup-linux-ppc64-musl" "4.57.0" - "@rollup/rollup-linux-riscv64-gnu" "4.57.0" - "@rollup/rollup-linux-riscv64-musl" "4.57.0" - "@rollup/rollup-linux-s390x-gnu" "4.57.0" - "@rollup/rollup-linux-x64-gnu" "4.57.0" - "@rollup/rollup-linux-x64-musl" "4.57.0" - "@rollup/rollup-openbsd-x64" "4.57.0" - "@rollup/rollup-openharmony-arm64" "4.57.0" - "@rollup/rollup-win32-arm64-msvc" "4.57.0" - "@rollup/rollup-win32-ia32-msvc" "4.57.0" - "@rollup/rollup-win32-x64-gnu" "4.57.0" - "@rollup/rollup-win32-x64-msvc" "4.57.0" - fsevents "~2.3.2" +roarr@^2.15.3: + version "2.15.4" + resolved "https://registry.npmjs.org/roarr/-/roarr-2.15.4.tgz" + integrity sha512-CHhPh+UNHD2GTXNYhPWLnU8ONHdI+5DI+4EYIAOaiD63rHeYlZvyh8P+in5999TTSFgUYuKUAjzRI4mdh/p+2A== + dependencies: + boolean "^3.0.1" + detect-node "^2.0.4" + globalthis "^1.0.1" + json-stringify-safe "^5.0.1" + semver-compare "^1.0.0" + sprintf-js "^1.1.2" + +robust-predicates@^3.0.2: + version "3.0.2" + resolved "https://registry.npmjs.org/robust-predicates/-/robust-predicates-3.0.2.tgz" + integrity sha512-IXgzBWvWQwE6PrDI05OvmXUIruQTcoMDzRsOd5CDvHCVLcLHMTSYvOK5Cm46kWqlV3yAbuSpBZdJ5oP5OUoStg== + +rollup-pluginutils@^2.8.2: + version "2.8.2" + resolved "https://registry.npmjs.org/rollup-pluginutils/-/rollup-pluginutils-2.8.2.tgz" + integrity sha512-EEp9NhnUkwY8aif6bxgovPHMoMoNr2FulJziTndpt5H9RdwC47GSGuII9XxpSdzVGM0GWrNPHV6ie1LTNJPaLQ== + dependencies: + estree-walker "^0.6.1" rollup@^4.34.9: version "4.57.1" @@ -15044,6 +15949,40 @@ rollup@^4.34.9: "@rollup/rollup-win32-x64-msvc" "4.57.1" fsevents "~2.3.2" +rollup@^4.43.0, rollup@^4.53.1: + version "4.57.0" + resolved "https://registry.npmjs.org/rollup/-/rollup-4.57.0.tgz" + integrity sha512-e5lPJi/aui4TO1LpAXIRLySmwXSE8k3b9zoGfd42p67wzxog4WHjiZF3M2uheQih4DGyc25QEV4yRBbpueNiUA== + dependencies: + "@types/estree" "1.0.8" + optionalDependencies: + "@rollup/rollup-android-arm-eabi" "4.57.0" + "@rollup/rollup-android-arm64" "4.57.0" + "@rollup/rollup-darwin-arm64" "4.57.0" + "@rollup/rollup-darwin-x64" "4.57.0" + "@rollup/rollup-freebsd-arm64" "4.57.0" + "@rollup/rollup-freebsd-x64" "4.57.0" + "@rollup/rollup-linux-arm-gnueabihf" "4.57.0" + "@rollup/rollup-linux-arm-musleabihf" "4.57.0" + "@rollup/rollup-linux-arm64-gnu" "4.57.0" + "@rollup/rollup-linux-arm64-musl" "4.57.0" + "@rollup/rollup-linux-loong64-gnu" "4.57.0" + "@rollup/rollup-linux-loong64-musl" "4.57.0" + "@rollup/rollup-linux-ppc64-gnu" "4.57.0" + "@rollup/rollup-linux-ppc64-musl" "4.57.0" + "@rollup/rollup-linux-riscv64-gnu" "4.57.0" + "@rollup/rollup-linux-riscv64-musl" "4.57.0" + "@rollup/rollup-linux-s390x-gnu" "4.57.0" + "@rollup/rollup-linux-x64-gnu" "4.57.0" + "@rollup/rollup-linux-x64-musl" "4.57.0" + "@rollup/rollup-openbsd-x64" "4.57.0" + "@rollup/rollup-openharmony-arm64" "4.57.0" + "@rollup/rollup-win32-arm64-msvc" "4.57.0" + "@rollup/rollup-win32-ia32-msvc" "4.57.0" + "@rollup/rollup-win32-x64-gnu" "4.57.0" + "@rollup/rollup-win32-x64-msvc" "4.57.0" + fsevents "~2.3.2" + roughjs@^4.6.6: version "4.6.6" resolved "https://registry.npmjs.org/roughjs/-/roughjs-4.6.6.tgz" @@ -15102,7 +16041,7 @@ rw@1: resolved "https://registry.npmjs.org/rw/-/rw-1.3.3.tgz" integrity sha512-PdhdWy89SiZogBLaw42zdeqtRJ//zFd2PgQavcICDUgJT5oW10QCRKbJ6bg4r0/UY2M6BWd5tkxuGFRvCkgfHQ== -rxjs@*, rxjs@^7.8.2, rxjs@7.8.2: +rxjs@7.8.2, rxjs@^7.5.2, rxjs@^7.8.2: version "7.8.2" resolved "https://registry.npmjs.org/rxjs/-/rxjs-7.8.2.tgz" integrity sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA== @@ -15120,11 +16059,16 @@ safe-array-concat@^1.1.3: has-symbols "^1.1.0" isarray "^2.0.5" -safe-buffer@^5.0.1, safe-buffer@~5.2.0, safe-buffer@5.2.1: +safe-buffer@5.2.1, safe-buffer@^5.0.1, safe-buffer@~5.2.0: version "5.2.1" resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz" integrity sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ== +safe-buffer@~5.1.0, safe-buffer@~5.1.1: + version "5.1.2" + resolved "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz" + integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== + safe-push-apply@^1.0.0: version "1.0.0" resolved "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz" @@ -15171,16 +16115,16 @@ saxes@^6.0.0: dependencies: xmlchars "^2.2.0" +scheduler@0.26.0, scheduler@^0.26.0: + version "0.26.0" + resolved "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz" + integrity sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA== + scheduler@^0.25.0: version "0.25.0" resolved "https://registry.npmjs.org/scheduler/-/scheduler-0.25.0.tgz" integrity sha512-xFVuu11jh+xcO7JOAGJNOXld8/TcEHK/4CituBUeUb5hqxJLj9YuemAEuvm9gQ/+pgXYfbQuqAkiYu+u7YEsNA== -scheduler@^0.26.0, scheduler@0.26.0: - version "0.26.0" - resolved "https://registry.npmjs.org/scheduler/-/scheduler-0.26.0.tgz" - integrity sha512-NlHwttCI/l5gCPR3D1nNXtWABUmBwvZpEQiD4IXSbIDq8BzLIK/7Ir5gTFSGZDUu37K5cMNp0hFtzO38sC7gWA== - scheduler@^0.27.0: version "0.27.0" resolved "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz" @@ -15213,26 +16157,16 @@ semver-compare@^1.0.0: resolved "https://registry.npmjs.org/semver-compare/-/semver-compare-1.0.0.tgz" integrity sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow== -semver@^6.0.0: - version "6.3.1" - resolved "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz" - integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== - -semver@^6.3.0: - version "6.3.1" - resolved "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz" - integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== +semver@7.7.3, semver@^7.1.3, semver@^7.3.5, semver@^7.5.3, semver@^7.5.4, semver@^7.6.0, semver@^7.7.2, semver@^7.7.3: + version "7.7.3" + resolved "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz" + integrity sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q== -semver@^6.3.1: +semver@^6.0.0, semver@^6.3.0, semver@^6.3.1: version "6.3.1" resolved "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz" integrity sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA== -semver@^7.1.3, semver@^7.3.5, semver@^7.5.3, semver@^7.5.4, semver@^7.6.0, semver@^7.7.2, semver@^7.7.3, semver@7.7.3: - version "7.7.3" - resolved "https://registry.npmjs.org/semver/-/semver-7.7.3.tgz" - integrity sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q== - semver@^7.3.2: version "7.7.4" resolved "https://registry.npmjs.org/semver/-/semver-7.7.4.tgz" @@ -15437,7 +16371,7 @@ shelljs@^0.8.5: interpret "^1.0.0" rechoir "^0.6.2" -shiki@^3.0.0, shiki@^3.21.0, shiki@3.22.0: +shiki@3.22.0, shiki@^3.0.0, shiki@^3.21.0: version "3.22.0" resolved "https://registry.npmjs.org/shiki/-/shiki-3.22.0.tgz" integrity sha512-LBnhsoYEe0Eou4e1VgJACes+O6S6QC0w71fCSp5Oya79inkwkm15gQ1UF6VtQ8j/taMDh79hAB49WUk8ALQW3g== @@ -15509,15 +16443,24 @@ signal-exit@^3.0.2, signal-exit@^3.0.3, signal-exit@^3.0.7: resolved "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz" integrity sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ== -signal-exit@^4.0.1: +signal-exit@^4.0.1, signal-exit@^4.1.0: version "4.1.0" resolved "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz" integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw== -signal-exit@^4.1.0: - version "4.1.0" - resolved "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz" - integrity sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw== +simple-concat@^1.0.0: + version "1.0.1" + resolved "https://registry.npmjs.org/simple-concat/-/simple-concat-1.0.1.tgz" + integrity sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q== + +simple-get@^4.0.0: + version "4.0.1" + resolved "https://registry.npmjs.org/simple-get/-/simple-get-4.0.1.tgz" + integrity sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA== + dependencies: + decompress-response "^6.0.0" + once "^1.3.1" + simple-concat "^1.0.0" simple-plist@^1.1.0: version "1.3.1" @@ -15568,7 +16511,7 @@ smart-buffer@^4.2.0: resolved "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz" integrity sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg== -socket.io-adapter@^2.5.4, socket.io-adapter@^2.5.5, socket.io-adapter@~2.5.2: +socket.io-adapter@^2.5.5, socket.io-adapter@~2.5.2: version "2.5.6" resolved "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.6.tgz" integrity sha512-DkkO/dz7MGln0dHn5bmN3pPy+JmywNICWrJqVWiVOyvXjWQFIv9c2h24JrQLLFJ2aQVQf/Cvl1vblnd4r2apLQ== @@ -15644,25 +16587,12 @@ source-map-support@~0.5.20, source-map-support@~0.5.21: buffer-from "^1.0.0" source-map "^0.6.0" -source-map-support@~0.5.21: - version "0.5.21" - resolved "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz" - integrity sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w== - dependencies: - buffer-from "^1.0.0" - source-map "^0.6.0" - source-map@^0.5.6: version "0.5.7" resolved "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz" integrity sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ== -source-map@^0.6.0: - version "0.6.1" - resolved "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz" - integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== - -source-map@^0.6.1: +source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.1: version "0.6.1" resolved "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz" integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== @@ -15672,11 +16602,6 @@ source-map@^0.7.0: resolved "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz" integrity sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ== -source-map@~0.6.1: - version "0.6.1" - resolved "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz" - integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== - space-separated-tokens@^2.0.0: version "2.0.2" resolved "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz" @@ -15702,14 +16627,7 @@ sprintf-js@~1.0.2: resolved "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz" integrity sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g== -stack-utils@^2.0.3: - version "2.0.6" - resolved "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz" - integrity sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ== - dependencies: - escape-string-regexp "^2.0.0" - -stack-utils@^2.0.6: +stack-utils@^2.0.3, stack-utils@^2.0.6: version "2.0.6" resolved "https://registry.npmjs.org/stack-utils/-/stack-utils-2.0.6.tgz" integrity sha512-XlkWvfIm6RmsWtNJx+uqtKLS8eqFbxUg0ZzLXqY0caEy9l7hruX8IpiDnjsLavoBgqCCR71TqWO8MaXYheJ3RQ== @@ -15783,18 +16701,20 @@ stream-json@^1.8.0: dependencies: stream-chain "^2.2.5" +streamx@^2.12.5, streamx@^2.15.0, streamx@^2.21.0: + version "2.23.0" + resolved "https://registry.npmjs.org/streamx/-/streamx-2.23.0.tgz" + integrity sha512-kn+e44esVfn2Fa/O0CPFcex27fjIL6MkVae0Mm6q+E6f0hWv578YCERbv+4m02cjxvDsPKLnmxral/rR6lBMAg== + dependencies: + events-universal "^1.0.0" + fast-fifo "^1.3.2" + text-decoder "^1.1.0" + strict-uri-encode@^2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz" integrity sha512-QwiXZgpRcKkhTj2Scnn++4PKtWsH0kpzZ62L2R6c/LUVYv7hVnZqcg2+sMuT6R7Jusu1vviK/MFsu6kNJfWlEQ== -string_decoder@^1.1.1, string_decoder@^1.3.0: - version "1.3.0" - resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz" - integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== - dependencies: - safe-buffer "~5.2.0" - "string-width-cjs@npm:string-width@^4.2.0": version "4.2.3" resolved "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz" @@ -15831,15 +16751,7 @@ string-width@^7.0.0: get-east-asian-width "^1.0.0" strip-ansi "^7.1.0" -string-width@^8.0.0: - version "8.1.0" - resolved "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz" - integrity sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg== - dependencies: - get-east-asian-width "^1.3.0" - strip-ansi "^7.1.0" - -string-width@^8.1.0: +string-width@^8.0.0, string-width@^8.1.0: version "8.1.0" resolved "https://registry.npmjs.org/string-width/-/string-width-8.1.0.tgz" integrity sha512-Kxl3KJGb/gxkaUMOjRsQ8IrXiGW75O4E3RPjFIINOVH8AMl2SQ/yWdTzWwF3FevIX9LcMAjJW+GRwAlAbTSXdg== @@ -15879,6 +16791,20 @@ string.prototype.trimstart@^1.0.8: define-properties "^1.2.1" es-object-atoms "^1.0.0" +string_decoder@^1.1.1, string_decoder@^1.3.0: + version "1.3.0" + resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-1.3.0.tgz" + integrity sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA== + dependencies: + safe-buffer "~5.2.0" + +string_decoder@~1.1.1: + version "1.1.1" + resolved "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz" + integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg== + dependencies: + safe-buffer "~5.1.0" + stringify-entities@^4.0.0: version "4.0.4" resolved "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz" @@ -15908,14 +16834,7 @@ strip-ansi@^6.0.0, strip-ansi@^6.0.1: dependencies: ansi-regex "^5.0.1" -strip-ansi@^7.0.1: - version "7.1.2" - resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz" - integrity sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA== - dependencies: - ansi-regex "^6.0.1" - -strip-ansi@^7.1.0, strip-ansi@^7.1.2: +strip-ansi@^7.0.1, strip-ansi@^7.1.0, strip-ansi@^7.1.2: version "7.1.2" resolved "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.1.2.tgz" integrity sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA== @@ -16057,6 +16976,11 @@ tailwind-merge@^3.4.0: resolved "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz" integrity sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g== +tailwindcss@4.1.18, tailwindcss@^4.1.18: + version "4.1.18" + resolved "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz" + integrity sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw== + tailwindcss@^3.4.17: version "3.4.19" resolved "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz" @@ -16085,16 +17009,42 @@ tailwindcss@^3.4.17: resolve "^1.22.8" sucrase "^3.35.0" -tailwindcss@^4.0.0, tailwindcss@^4.1.18, tailwindcss@4.1.18: - version "4.1.18" - resolved "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.18.tgz" - integrity sha512-4+Z+0yiYyEtUVCScyfHCxOYP06L5Ne+JiHhY2IjR2KWMIWhJOYZKLSGZaP5HkZ8+bY0cxfzwDE5uOmzFXyIwxw== - tapable@^2.2.0: version "2.3.0" resolved "https://registry.npmjs.org/tapable/-/tapable-2.3.0.tgz" integrity sha512-g9ljZiwki/LfxmQADO3dEY1CbpmXT5Hm2fJ+QaGKwSXUylMybePR7/67YW7jOrrvjEgL1Fmz5kzyAjWVWLlucg== +tar-fs@^2.0.0: + version "2.1.4" + resolved "https://registry.npmjs.org/tar-fs/-/tar-fs-2.1.4.tgz" + integrity sha512-mDAjwmZdh7LTT6pNleZ05Yt65HC3E+NiQzl672vQG38jIrehtJk/J3mNwIg+vShQPcLF/LV7CMnDW6vjj6sfYQ== + dependencies: + chownr "^1.1.1" + mkdirp-classic "^0.5.2" + pump "^3.0.0" + tar-stream "^2.1.4" + +tar-stream@^2.1.4: + version "2.2.0" + resolved "https://registry.npmjs.org/tar-stream/-/tar-stream-2.2.0.tgz" + integrity sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ== + dependencies: + bl "^4.0.3" + end-of-stream "^1.4.1" + fs-constants "^1.0.0" + inherits "^2.0.3" + readable-stream "^3.1.1" + +tar-stream@^3.0.0: + version "3.1.8" + resolved "https://registry.npmjs.org/tar-stream/-/tar-stream-3.1.8.tgz" + integrity sha512-U6QpVRyCGHva435KoNWy9PRoi2IFYCgtEhq9nmrPPpbRacPs9IH4aJ3gbrFC8dPcXvdSZ4XXfXT5Fshbp2MtlQ== + dependencies: + b4a "^1.6.4" + bare-fs "^4.5.5" + fast-fifo "^1.2.0" + streamx "^2.15.0" + tar@^7.0.1, tar@^7.5.2, tar@^7.5.4: version "7.5.7" resolved "https://registry.npmjs.org/tar/-/tar-7.5.7.tgz" @@ -16113,6 +17063,13 @@ tdigest@^0.1.1: dependencies: bintrees "1.0.2" +teex@^1.0.1: + version "1.0.1" + resolved "https://registry.npmjs.org/teex/-/teex-1.0.1.tgz" + integrity sha512-eYE6iEI62Ni1H8oIa7KlDU6uQBtqr4Eajni3wX7rpfXD8ysFx8z0+dri+KWEPWpBsxXfxu58x/0jvTVT1ekOSg== + dependencies: + streamx "^2.12.5" + temp-dir@~2.0.0: version "2.0.0" resolved "https://registry.npmjs.org/temp-dir/-/temp-dir-2.0.0.tgz" @@ -16126,7 +17083,7 @@ terminal-link@^2.1.1: ansi-escapes "^4.2.1" supports-hyperlinks "^2.0.0" -terser@^5.15.0, terser@^5.16.0: +terser@^5.15.0: version "5.46.0" resolved "https://registry.npmjs.org/terser/-/terser-5.46.0.tgz" integrity sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg== @@ -16145,6 +17102,13 @@ test-exclude@^6.0.0: glob "^7.1.4" minimatch "^3.0.4" +text-decoder@^1.1.0: + version "1.2.7" + resolved "https://registry.npmjs.org/text-decoder/-/text-decoder-1.2.7.tgz" + integrity sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ== + dependencies: + b4a "^1.6.4" + thenify-all@^1.0.0: version "1.6.0" resolved "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz" @@ -16159,6 +17123,13 @@ thenify-all@^1.0.0: dependencies: any-promise "^1.0.0" +thread-stream@^3.0.0: + version "3.1.0" + resolved "https://registry.yarnpkg.com/thread-stream/-/thread-stream-3.1.0.tgz#4b2ef252a7c215064507d4ef70c05a5e2d34c4f1" + integrity sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A== + dependencies: + real-require "^0.2.0" + thread-stream@^4.0.0: version "4.0.0" resolved "https://registry.npmjs.org/thread-stream/-/thread-stream-4.0.0.tgz" @@ -16193,7 +17164,7 @@ tinyexec@^1.0.1, tinyexec@^1.0.2: resolved "https://registry.npmjs.org/tinyexec/-/tinyexec-1.0.2.tgz" integrity sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg== -tinyglobby@^0.2.11, tinyglobby@^0.2.13, tinyglobby@^0.2.14, tinyglobby@^0.2.15, tinyglobby@0.2.15: +tinyglobby@0.2.15, tinyglobby@^0.2.11, tinyglobby@^0.2.13, tinyglobby@^0.2.14, tinyglobby@^0.2.15: version "0.2.15" resolved "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz" integrity sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ== @@ -16314,7 +17285,7 @@ tsconfck@^3.0.3: resolved "https://registry.npmjs.org/tsconfck/-/tsconfck-3.1.6.tgz" integrity sha512-ks6Vjr/jEw0P1gmOVwutM3B7fWxoWBL2KRDb1JfqGVawBmO5UsvmWOQFGHBPl5yxYz4eERr19E6L7NMv+Fej4w== -tslib@*, tslib@^2.0.0, tslib@^2.0.1, tslib@^2.1.0, tslib@^2.6.2, tslib@^2.8.0, tslib@^2.8.1, tslib@2.8.1: +tslib@2.8.1, tslib@^2.0.0, tslib@^2.0.1, tslib@^2.1.0, tslib@^2.4.0, tslib@^2.8.0, tslib@^2.8.1: version "2.8.1" resolved "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz" integrity sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w== @@ -16324,7 +17295,7 @@ tslib@^1.9.3: resolved "https://registry.npmjs.org/tslib/-/tslib-1.14.1.tgz" integrity sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg== -tsx@^4.19.2, tsx@^4.20.4, tsx@^4.20.6, tsx@^4.8.1: +tsx@^4.19.2, tsx@^4.20.4, tsx@^4.20.6: version "4.21.0" resolved "https://registry.npmjs.org/tsx/-/tsx-4.21.0.tgz" integrity sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw== @@ -16341,6 +17312,13 @@ tsyringe@^4.10.0: dependencies: tslib "^1.9.3" +tunnel-agent@^0.6.0: + version "0.6.0" + resolved "https://registry.npmjs.org/tunnel-agent/-/tunnel-agent-0.6.0.tgz" + integrity sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w== + dependencies: + safe-buffer "^5.0.1" + tweetnacl@^1.0.3: version "1.0.3" resolved "https://registry.npmjs.org/tweetnacl/-/tweetnacl-1.0.3.tgz" @@ -16442,9 +17420,9 @@ typed-emitter@^2.1.0: resolved "https://registry.npmjs.org/typed-emitter/-/typed-emitter-2.1.0.tgz" integrity sha512-g/KzbYKbH5C2vPkaXGu8DJlHrGKHLsM25Zg9WuC9pMGfuvT+X25tZQWo5fK1BjBm8+UrVE9LDCvaY0CQk+fXDA== optionalDependencies: - rxjs "*" + rxjs "^7.5.2" -"typescript@^4.1 || ^5.0", typescript@^5.0.0, typescript@^5.9.2, typescript@^5.9.3, typescript@>=2.7, typescript@>=3.7.0, typescript@>=5.1.0, typescript@5.9.3: +typescript@5.9.3, typescript@^5.9.2, typescript@^5.9.3: version "5.9.3" resolved "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz" integrity sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw== @@ -16484,7 +17462,7 @@ undici-types@~7.16.0: resolved "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz" integrity sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw== -undici@^6.18.2, undici@^6.22.0, undici@6.23.0: +undici@6.23.0, undici@^6.18.2, undici@^6.22.0: version "6.23.0" resolved "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz" integrity sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g== @@ -16680,7 +17658,7 @@ use-sidecar@^1.1.3: detect-node-es "^1.1.0" tslib "^2.0.0" -use-sync-external-store@^1.5.0, use-sync-external-store@>=1.2.0: +use-sync-external-store@^1.5.0: version "1.6.0" resolved "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz" integrity sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w== @@ -16692,7 +17670,7 @@ usehooks-ts@3.1.1: dependencies: lodash.debounce "^4.0.8" -util-deprecate@^1.0.1, util-deprecate@^1.0.2: +util-deprecate@^1.0.1, util-deprecate@^1.0.2, util-deprecate@~1.0.1: version "1.0.2" resolved "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz" integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== @@ -16796,7 +17774,7 @@ vite-tsconfig-paths@^5.1.4: globrex "^0.1.2" tsconfck "^3.0.3" -vite@*, "vite@^5.0.0 || ^6.0.0 || ^7.0.0-0", "vite@6.x.x || 7.x.x": +"vite@^5.0.0 || ^6.0.0 || ^7.0.0-0": version "7.3.1" resolved "https://registry.npmjs.org/vite/-/vite-7.3.1.tgz" integrity sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA== @@ -16824,36 +17802,7 @@ vite@^6.0.7: optionalDependencies: fsevents "~2.3.3" -vitest@^3.2.0: - version "3.2.4" - resolved "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz" - integrity sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A== - dependencies: - "@types/chai" "^5.2.2" - "@vitest/expect" "3.2.4" - "@vitest/mocker" "3.2.4" - "@vitest/pretty-format" "^3.2.4" - "@vitest/runner" "3.2.4" - "@vitest/snapshot" "3.2.4" - "@vitest/spy" "3.2.4" - "@vitest/utils" "3.2.4" - chai "^5.2.0" - debug "^4.4.1" - expect-type "^1.2.1" - magic-string "^0.30.17" - pathe "^2.0.3" - picomatch "^4.0.2" - std-env "^3.9.0" - tinybench "^2.9.0" - tinyexec "^0.3.2" - tinyglobby "^0.2.14" - tinypool "^1.1.1" - tinyrainbow "^2.0.0" - vite "^5.0.0 || ^6.0.0 || ^7.0.0-0" - vite-node "3.2.4" - why-is-node-running "^2.3.0" - -vitest@^3.2.4: +vitest@^3.2.0, vitest@^3.2.4: version "3.2.4" resolved "https://registry.npmjs.org/vitest/-/vitest-3.2.4.tgz" integrity sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A== @@ -16941,7 +17890,7 @@ walker@^1.0.7, walker@^1.0.8: dependencies: makeerror "1.0.12" -warn-once@^0.1.0, warn-once@^0.1.1, warn-once@0.1.1: +warn-once@0.1.1, warn-once@^0.1.0, warn-once@^0.1.1: version "0.1.1" resolved "https://registry.npmjs.org/warn-once/-/warn-once-0.1.1.tgz" integrity sha512-VkQZJbO8zVImzYFteBXvBOZEl1qL175WH8VmZcxF2fZAoudNhNDvHi+doCaAEdU2l2vtcIwa2zn0QK5+I1HQ3Q== @@ -17217,22 +18166,12 @@ ws@^6.2.3: dependencies: async-limiter "~1.0.0" -ws@^7: - version "7.5.10" - resolved "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz" - integrity sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ== - -ws@^7.5.10: +ws@^7, ws@^7.5.10: version "7.5.10" resolved "https://registry.npmjs.org/ws/-/ws-7.5.10.tgz" integrity sha512-+dbF1tHwZpXcbOJdVOkzLDxZP1ailvSxM6ZweXTegylPny803bFhA+vqBYw4s31NSAk4S2Qz+AKXK9a4wkdjcQ== -ws@^8.12.1: - version "8.19.0" - resolved "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz" - integrity sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg== - -ws@^8.18.0: +ws@^8.12.1, ws@^8.18.0: version "8.19.0" resolved "https://registry.npmjs.org/ws/-/ws-8.19.0.tgz" integrity sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg== @@ -17262,14 +18201,6 @@ xml-name-validator@^5.0.0: resolved "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz" integrity sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg== -"xml2js@^0.5.0 || ^0.6.2": - version "0.6.2" - resolved "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz" - integrity sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA== - dependencies: - sax ">=0.6.0" - xmlbuilder "~11.0.0" - xml2js@0.6.0: version "0.6.0" resolved "https://registry.npmjs.org/xml2js/-/xml2js-0.6.0.tgz" @@ -17278,6 +18209,14 @@ xml2js@0.6.0: sax ">=0.6.0" xmlbuilder "~11.0.0" +"xml2js@^0.5.0 || ^0.6.2": + version "0.6.2" + resolved "https://registry.npmjs.org/xml2js/-/xml2js-0.6.2.tgz" + integrity sha512-T4rieHaC1EXcES0Kxxj4JWgaUQHDk+qwHcYOCFHfiwKz7tOVPLq7Hjq9dM1WCMhylqMEfP7hMcOIChvotiZegA== + dependencies: + sax ">=0.6.0" + xmlbuilder "~11.0.0" + xmlbuilder@^15.1.1: version "15.1.1" resolved "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-15.1.1.tgz" @@ -17333,6 +18272,11 @@ yaml@^2.2.2, yaml@^2.4.2, yaml@^2.6.1, yaml@^2.8.1: resolved "https://registry.npmjs.org/yaml/-/yaml-2.8.2.tgz" integrity sha512-mplynKqc1C2hTVYxd0PU2xQAc22TI1vShAYGksCCfxbn/dFwnHTNi1bvYsBTkhdUNtGIf5xNOg938rrSSYvS9A== +yargs-parser@21.1.1, yargs-parser@^21.1.1: + version "21.1.1" + resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz" + integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw== + yargs-parser@^18.1.2: version "18.1.3" resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz" @@ -17341,11 +18285,6 @@ yargs-parser@^18.1.2: camelcase "^5.0.0" decamelize "^1.2.0" -yargs-parser@^21.1.1, yargs-parser@21.1.1: - version "21.1.1" - resolved "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz" - integrity sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw== - yargs@^15.3.1: version "15.4.1" resolved "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz" @@ -17409,36 +18348,30 @@ yoga-layout@~3.2.1: resolved "https://registry.npmjs.org/yoga-layout/-/yoga-layout-3.2.1.tgz" integrity sha512-0LPOt3AxKqMdFBZA3HBAt/t/8vIKq7VaQYbuA8WxCgung+p9TVyKRYdpvCb80HcdTN2NkbIKbhNwKUfm3tQywQ== -zod-to-json-schema@^3.24.3: - version "3.25.1" - resolved "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz" - integrity sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA== - -zod-to-json-schema@^3.25.0: - version "3.25.1" - resolved "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz" - integrity sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA== +zip-stream@^6.0.1: + version "6.0.1" + resolved "https://registry.npmjs.org/zip-stream/-/zip-stream-6.0.1.tgz" + integrity sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA== + dependencies: + archiver-utils "^5.0.0" + compress-commons "^6.0.2" + readable-stream "^4.0.0" -zod-to-json-schema@3.25.1: +zod-to-json-schema@3.25.1, zod-to-json-schema@^3.24.3, zod-to-json-schema@^3.25.0: version "3.25.1" resolved "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz" integrity sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA== -"zod@^3.25 || ^4", "zod@^3.25 || ^4.0", "zod@^3.25.0 || ^4.0.0", "zod@^3.25.76 || ^4.1.8", zod@^4.0.0, zod@>=4.1.5, zod@4.3.6: - version "4.3.6" - resolved "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz" - integrity sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg== - -zod@^4.3.5, zod@4.x.x: - version "4.3.6" - resolved "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz" - integrity sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg== - zod@3.25.76: version "3.25.76" resolved "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz" integrity sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ== +zod@4.3.6, "zod@^3.25 || ^4.0", zod@^4.3.5: + version "4.3.6" + resolved "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz" + integrity sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg== + zustand@^5.0.6: version "5.0.10" resolved "https://registry.npmjs.org/zustand/-/zustand-5.0.10.tgz" From 4870b459c3d333e3c9b09d39456893bf54b66c86 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Fri, 27 Mar 2026 15:45:18 +0100 Subject: [PATCH 57/81] fix(telegram): avoid exposing webhook secret path --- .../providers/telegram/telegramWebhookRelay.test.ts | 8 ++++---- .../channels/providers/telegram/telegramWebhookRelay.ts | 2 -- 2 files changed, 4 insertions(+), 6 deletions(-) diff --git a/apps/cli/src/channels/providers/telegram/telegramWebhookRelay.test.ts b/apps/cli/src/channels/providers/telegram/telegramWebhookRelay.test.ts index 2d040eea0d..a7f8222ab1 100644 --- a/apps/cli/src/channels/providers/telegram/telegramWebhookRelay.test.ts +++ b/apps/cli/src/channels/providers/telegram/telegramWebhookRelay.test.ts @@ -30,7 +30,7 @@ describe('startTelegramWebhookRelay', () => { }); try { - const response = await axios.post(`http://127.0.0.1:${relay.port}${relay.path}`, { + const response = await axios.post(`http://127.0.0.1:${relay.port}/telegram/webhook/secret-123`, { update_id: 42, message: { text: 'hello' }, }, { @@ -65,14 +65,14 @@ describe('startTelegramWebhookRelay', () => { }); try { - const missingHeaderResponse = await axios.post(`http://127.0.0.1:${relay.port}${relay.path}`, { + const missingHeaderResponse = await axios.post(`http://127.0.0.1:${relay.port}/telegram/webhook/secret-abc`, { update_id: 101, message: { text: 'hello' }, }, { validateStatus: () => true, }); - const invalidHeaderResponse = await axios.post(`http://127.0.0.1:${relay.port}${relay.path}`, { + const invalidHeaderResponse = await axios.post(`http://127.0.0.1:${relay.port}/telegram/webhook/secret-abc`, { update_id: 102, message: { text: 'hello again' }, }, { @@ -106,7 +106,7 @@ describe('startTelegramWebhookRelay', () => { }); const response = await axios.post( - `http://127.0.0.1:${relay.port}${relay.path}`, + `http://127.0.0.1:${relay.port}/telegram/webhook/secret-fail`, { update_id: 202, message: { text: 'hello' }, diff --git a/apps/cli/src/channels/providers/telegram/telegramWebhookRelay.ts b/apps/cli/src/channels/providers/telegram/telegramWebhookRelay.ts index d34a0564ac..cddf50cae7 100644 --- a/apps/cli/src/channels/providers/telegram/telegramWebhookRelay.ts +++ b/apps/cli/src/channels/providers/telegram/telegramWebhookRelay.ts @@ -20,7 +20,6 @@ function secureCompareToken(providedToken: string, expectedToken: string): boole export type TelegramWebhookRelayHandle = Readonly<{ port: number; - path: string; stop: () => Promise; }>; @@ -90,7 +89,6 @@ export async function startTelegramWebhookRelay(params: Readonly<{ return { port: boundPort, - path, stop: async () => { await app.close(); }, From e15abd76c29e691c84440663952ce37c3e94ed90 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Fri, 27 Mar 2026 15:46:13 +0100 Subject: [PATCH 58/81] docs(channel-bridges): align UAT checklist with v1 config --- docs/channel-bridge-uat.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/channel-bridge-uat.md b/docs/channel-bridge-uat.md index 8b1221c709..410b420fe3 100644 --- a/docs/channel-bridge-uat.md +++ b/docs/channel-bridge-uat.md @@ -40,7 +40,7 @@ happier bridge list Expected: - `Telegram (scoped settings.json)` shows `configured: yes` -- `Telegram (effective runtime...)` shows configured token/topic policy +- `Telegram (effective runtime: env > settings.json)` shows configured token/topic policy ## 4) Restart daemon and verify bridge worker startup From cb6fe0f27b87e7fb60f239dadba1fb155cfe9dbd Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Fri, 27 Mar 2026 16:27:01 +0100 Subject: [PATCH 59/81] fix(channel-bridges): dedupe adapter pull failure warnings --- .../channels/core/channelBridgeWorker.test.ts | 50 +++++++++++++++++++ .../src/channels/core/channelBridgeWorker.ts | 15 +++++- 2 files changed, 64 insertions(+), 1 deletion(-) diff --git a/apps/cli/src/channels/core/channelBridgeWorker.test.ts b/apps/cli/src/channels/core/channelBridgeWorker.test.ts index 5f7afcbad2..aeccf8d0df 100644 --- a/apps/cli/src/channels/core/channelBridgeWorker.test.ts +++ b/apps/cli/src/channels/core/channelBridgeWorker.test.ts @@ -1657,6 +1657,56 @@ describe('executeChannelBridgeTick', () => { expect(warnings.some((row) => row.message.includes('Failed to pull inbound messages for adapter telegram'))).toBe(true); }); + it('deduplicates adapter pull failure warnings until the adapter recovers when warnedAdapterPullFailures is provided', async () => { + const store = createInMemoryChannelBindingStore(); + const failing = createAdapterHarness('telegram'); + + const { deps, warnings } = createDepsHarness(); + const inboundDeduper = createChannelBridgeInboundDeduper(); + const warnedAdapterPullFailures = new Set(); + + const warningCount = () => + warnings.filter((row) => row.message.includes('Failed to pull inbound messages for adapter telegram')).length; + + failing.failPullOnce(new Error('telegram pull failed')); + await executeChannelBridgeTick({ + store, + adapters: [failing.adapter], + deps, + inboundDeduper, + warnedAdapterPullFailures, + }); + expect(warningCount()).toBe(1); + + failing.failPullOnce(new Error('telegram pull failed again')); + await executeChannelBridgeTick({ + store, + adapters: [failing.adapter], + deps, + inboundDeduper, + warnedAdapterPullFailures, + }); + expect(warningCount()).toBe(1); + + await executeChannelBridgeTick({ + store, + adapters: [failing.adapter], + deps, + inboundDeduper, + warnedAdapterPullFailures, + }); + + failing.failPullOnce(new Error('telegram pull failed after recovery')); + await executeChannelBridgeTick({ + store, + adapters: [failing.adapter], + deps, + inboundDeduper, + warnedAdapterPullFailures, + }); + expect(warningCount()).toBe(2); + }); + it('warns and ignores duplicate adapter provider ids', async () => { const store = createInMemoryChannelBindingStore(); const first = createAdapterHarness('telegram'); diff --git a/apps/cli/src/channels/core/channelBridgeWorker.ts b/apps/cli/src/channels/core/channelBridgeWorker.ts index 8c1bc6e235..8b9caab5cd 100644 --- a/apps/cli/src/channels/core/channelBridgeWorker.ts +++ b/apps/cli/src/channels/core/channelBridgeWorker.ts @@ -888,6 +888,11 @@ async function handleCommand(params: Readonly<{ * warnings are emitted per binding on each call. * - Pass a stable `Set` across calls to dedupe warning spam in * long-running loops (as `startChannelBridgeWorker` does). + * + * Adapter pull failure warning deduplication: + * - `warnedAdapterPullFailures` is optional. When provided, pull failures for + * a given adapter providerId are warned only on the transition to failing. + * - Once an adapter successfully pulls again, future failures will warn again. */ export async function executeChannelBridgeTick(params: Readonly<{ store: ChannelBindingStore; @@ -895,6 +900,7 @@ export async function executeChannelBridgeTick(params: Readonly<{ deps: ChannelBridgeDeps; inboundDeduper: ChannelBridgeInboundDeduper; warnedMissingAdapterBindings?: Set; + warnedAdapterPullFailures?: Set; }>): Promise { const activeAdapters: ChannelBridgeAdapter[] = []; const adapterByProvider = new Map(); @@ -917,8 +923,13 @@ export async function executeChannelBridgeTick(params: Readonly<{ EXTERNAL_IO_TIMEOUT_MS, `pullInboundMessages(${adapter.providerId})`, ); + params.warnedAdapterPullFailures?.delete(adapter.providerId); } catch (error) { - params.deps.onWarning?.(`Failed to pull inbound messages for adapter ${adapter.providerId}`, error); + const warnedPullFailures = params.warnedAdapterPullFailures; + if (!warnedPullFailures || !warnedPullFailures.has(adapter.providerId)) { + params.deps.onWarning?.(`Failed to pull inbound messages for adapter ${adapter.providerId}`, error); + warnedPullFailures?.add(adapter.providerId); + } continue; } @@ -1287,6 +1298,7 @@ export function startChannelBridgeWorker(params: Readonly<{ const inboundDeduper = createChannelBridgeInboundDeduper(); const warnedMissingAdapterBindings = new Set(); + const warnedAdapterPullFailures = new Set(); let inFlightTick: Promise | null = null; const runTick = async (): Promise => { @@ -1296,6 +1308,7 @@ export function startChannelBridgeWorker(params: Readonly<{ deps: params.deps, inboundDeduper, warnedMissingAdapterBindings, + warnedAdapterPullFailures, }); inFlightTick = tickRun; try { From 4c68dc157e8213baad777be4d6204989477e3bf2 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Fri, 27 Mar 2026 16:43:23 +0100 Subject: [PATCH 60/81] fix(channels): retry inbound forwards before acking --- .../channels/core/channelBridgeWorker.test.ts | 147 ++++++++++++++++- .../src/channels/core/channelBridgeWorker.ts | 149 ++++++++++++++++-- 2 files changed, 280 insertions(+), 16 deletions(-) diff --git a/apps/cli/src/channels/core/channelBridgeWorker.test.ts b/apps/cli/src/channels/core/channelBridgeWorker.test.ts index aeccf8d0df..ddb30c2a9e 100644 --- a/apps/cli/src/channels/core/channelBridgeWorker.test.ts +++ b/apps/cli/src/channels/core/channelBridgeWorker.test.ts @@ -5,6 +5,7 @@ import { executeChannelBridgeTick, startChannelBridgeWorker, createChannelBridgeInboundDeduper, + createChannelBridgeInboundForwardFailureTracker, ChannelBridgePermanentDeliveryError, type ChannelBridgeAdapter, type ChannelBindingStore, @@ -368,6 +369,140 @@ describe('executeChannelBridgeTick', () => { ]); }); + it('does not acknowledge inbound messages when forwarding to the session fails (so the adapter can retry)', async () => { + const store = createInMemoryChannelBindingStore(); + await store.upsertBinding({ + providerId: 'telegram', + conversationId: '-1001', + threadId: null, + sessionId: 'sess-abc123', + lastForwardedSeq: 0, + ownerSenderId: 'user-1', + inboundMode: 'ownerOnly', + allowMissingSenderId: false, + }); + + const inbound: ChannelBridgeInboundMessage = { + providerId: 'telegram', + conversationId: '-1001', + threadId: null, + senderId: 'user-1', + text: 'Hello from Telegram', + messageId: 'm-forward-failure', + }; + + const pending: ChannelBridgeInboundMessage[] = [inbound]; + const ackCalls: Array = []; + const sent: SentConversationMessage[] = []; + + const adapter: ChannelBridgeAdapter = { + providerId: 'telegram', + pullInboundMessages: async () => pending.slice(), + ackInboundMessages: async (messages) => { + ackCalls.push(messages); + for (const message of messages) { + const index = pending.findIndex((row) => row.messageId === message.messageId); + if (index >= 0) { + pending.splice(index, 1); + } + } + }, + sendMessage: async (params) => { + sent.push({ conversationId: params.conversationId, threadId: params.threadId, text: params.text }); + }, + }; + + const sendToSessionSpy = async () => { + throw new Error('session forward failed'); + }; + const { deps } = createDepsHarness({ + sendUserMessageToSession: sendToSessionSpy, + }); + + await executeChannelBridgeTick({ + store, + adapters: [adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(ackCalls).toEqual([]); + expect(pending).toHaveLength(1); + expect(sent.length).toBeGreaterThan(0); + }); + + it('gives up and acknowledges after repeated session-forward failures to avoid stalling adapter offsets', async () => { + const store = createInMemoryChannelBindingStore(); + await store.upsertBinding({ + providerId: 'telegram', + conversationId: '-1001', + threadId: null, + sessionId: 'sess-abc123', + lastForwardedSeq: 0, + ownerSenderId: 'user-1', + inboundMode: 'ownerOnly', + allowMissingSenderId: false, + }); + + const inbound: ChannelBridgeInboundMessage = { + providerId: 'telegram', + conversationId: '-1001', + threadId: null, + senderId: 'user-1', + text: 'Hello from Telegram', + messageId: 'm-forward-failure-give-up', + }; + + const pending: ChannelBridgeInboundMessage[] = [inbound]; + const ackCalls: Array = []; + + const adapter: ChannelBridgeAdapter = { + providerId: 'telegram', + pullInboundMessages: async () => pending.slice(), + ackInboundMessages: async (messages) => { + ackCalls.push(messages); + for (const message of messages) { + const index = pending.findIndex((row) => row.messageId === message.messageId); + if (index >= 0) { + pending.splice(index, 1); + } + } + }, + sendMessage: async () => {}, + }; + + const { deps } = createDepsHarness({ + sendUserMessageToSession: async () => { + throw new Error('session forward failed'); + }, + }); + + const deduper = createChannelBridgeInboundDeduper(); + const failureTracker = createChannelBridgeInboundForwardFailureTracker({ maxAttempts: 2, maxAgeMs: 60_000 }); + + await executeChannelBridgeTick({ + store, + adapters: [adapter], + deps, + inboundDeduper: deduper, + inboundForwardFailureTracker: failureTracker, + }); + + expect(ackCalls).toEqual([]); + expect(pending).toHaveLength(1); + + await executeChannelBridgeTick({ + store, + adapters: [adapter], + deps, + inboundDeduper: deduper, + inboundForwardFailureTracker: failureTracker, + }); + + expect(ackCalls).toHaveLength(1); + expect(pending).toHaveLength(0); + }); + it('denies /attach when sender identity is missing (safe-by-default)', async () => { const store = createInMemoryChannelBindingStore(); const harness = createAdapterHarness(); @@ -1788,7 +1923,7 @@ describe('executeChannelBridgeTick', () => { expect(harness.sent.some((row) => row.text.includes('Failed to send message to session sess-fail.'))).toBe(true); }); - it('acks session-forward failures even when failure reply delivery throws', async () => { + it('retries session-forward failures and deduplicates forwarding warnings and failure replies', async () => { const store = createInMemoryChannelBindingStore(); const harness = createAdapterHarness(); @@ -1833,9 +1968,17 @@ describe('executeChannelBridgeTick', () => { inboundDeduper: deduper, }); + harness.pushInbound(failedEvent); + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: deduper, + }); + expect(warnings.filter((row) => row.message.includes('Failed to forward channel message into session'))).toHaveLength(1); expect(warnings.filter((row) => row.message.includes('Failed to send session-forward-failure reply'))).toHaveLength(1); - expect(harness.sent).toHaveLength(0); + expect(harness.sent).toHaveLength(1); }); it('persists cursor after successful sends when a later outbound row fails', async () => { diff --git a/apps/cli/src/channels/core/channelBridgeWorker.ts b/apps/cli/src/channels/core/channelBridgeWorker.ts index 8b9caab5cd..6fbed69309 100644 --- a/apps/cli/src/channels/core/channelBridgeWorker.ts +++ b/apps/cli/src/channels/core/channelBridgeWorker.ts @@ -246,6 +246,83 @@ type ChannelBridgeInboundDeduper = Readonly<{ markSeen: (message: ChannelBridgeInboundMessage) => void; }>; +type ChannelBridgeInboundForwardFailureTracker = Readonly<{ + recordFailure: (message: ChannelBridgeInboundMessage) => Readonly<{ giveUp: boolean }>; + clear: (message: ChannelBridgeInboundMessage) => void; +}>; + +export function createChannelBridgeInboundForwardFailureTracker(params: Readonly<{ + now?: () => number; + maxAttempts?: number; + maxAgeMs?: number; +}> = {}): ChannelBridgeInboundForwardFailureTracker { + const now = params.now ?? (() => Date.now()); + const maxAttempts = + typeof params.maxAttempts === 'number' && Number.isFinite(params.maxAttempts) + ? Math.max(1, Math.trunc(params.maxAttempts)) + : 10; + const maxAgeMs = + typeof params.maxAgeMs === 'number' && Number.isFinite(params.maxAgeMs) + ? Math.max(1_000, Math.trunc(params.maxAgeMs)) + : 5 * 60_000; + + type FailureEntry = Readonly<{ + attempts: number; + firstFailedAtMs: number; + lastFailedAtMs: number; + }>; + + const failures = new Map(); + const maxEntries = 50_000; + + const keyFor = (message: ChannelBridgeInboundMessage): string | null => { + const normalizedMessageId = String(message.messageId ?? '').trim(); + if (!normalizedMessageId) return null; + return JSON.stringify([message.providerId, message.conversationId, message.threadId, normalizedMessageId]); + }; + + const pruneIfNeeded = () => { + if (failures.size <= maxEntries) return; + while (failures.size > maxEntries) { + const [oldest] = failures.keys(); + if (oldest === undefined) break; + failures.delete(oldest); + } + }; + + return { + recordFailure: (message) => { + const key = keyFor(message); + if (!key) { + return { giveUp: true }; + } + pruneIfNeeded(); + const currentNow = now(); + const previous = failures.get(key); + const next: FailureEntry = previous + ? { + attempts: previous.attempts + 1, + firstFailedAtMs: previous.firstFailedAtMs, + lastFailedAtMs: currentNow, + } + : { + attempts: 1, + firstFailedAtMs: currentNow, + lastFailedAtMs: currentNow, + }; + failures.set(key, next); + const tooManyAttempts = next.attempts >= maxAttempts; + const tooOld = currentNow - next.firstFailedAtMs >= maxAgeMs; + return { giveUp: tooManyAttempts || tooOld }; + }, + clear: (message) => { + const key = keyFor(message); + if (!key) return; + failures.delete(key); + }, + }; +} + /** * Create an inbound deduper for channel messages. * @@ -899,6 +976,7 @@ export async function executeChannelBridgeTick(params: Readonly<{ adapters: readonly ChannelBridgeAdapter[]; deps: ChannelBridgeDeps; inboundDeduper: ChannelBridgeInboundDeduper; + inboundForwardFailureTracker?: ChannelBridgeInboundForwardFailureTracker; warnedMissingAdapterBindings?: Set; warnedAdapterPullFailures?: Set; }>): Promise { @@ -914,6 +992,7 @@ export async function executeChannelBridgeTick(params: Readonly<{ } const deduper = params.inboundDeduper; + const inboundForwardFailureTracker = params.inboundForwardFailureTracker; for (const adapter of activeAdapters) { let inbound: ChannelBridgeInboundMessage[]; @@ -1083,25 +1162,52 @@ export async function executeChannelBridgeTick(params: Readonly<{ EXTERNAL_IO_TIMEOUT_MS, `sendUserMessageToSession(${binding.sessionId})`, ); + inboundForwardFailureTracker?.clear(event); + processedSuccessfully = true; } catch (error) { - params.deps.onWarning?.( - `Failed to forward channel message into session ${binding.sessionId} (provider=${adapter.providerId} conversation=${event.conversationId} thread=${event.threadId ?? 'null'} messageId=${event.messageId}); message will not be retried because the user is notified in-channel`, - error, - ); - try { - await replyToConversation( - adapter, - ref, - `Failed to send message to session ${binding.sessionId}.`, - ); - } catch (replyError) { + const giveUp = inboundForwardFailureTracker?.recordFailure(event).giveUp ?? false; + const failureWarnKey: ChannelBridgeInboundMessage = { + ...event, + messageId: `forward-failure-warn:${String(event.messageId ?? '').trim()}`, + }; + + if (!deduper.hasSeen(failureWarnKey)) { params.deps.onWarning?.( - `Failed to send session-forward-failure reply for provider=${adapter.providerId} conversation=${event.conversationId} thread=${event.threadId ?? 'null'}`, - replyError, + `Failed to forward channel message into session ${binding.sessionId} (provider=${adapter.providerId} conversation=${event.conversationId} thread=${event.threadId ?? 'null'} messageId=${event.messageId}); message will be retried until it is acknowledged by the adapter`, + error, ); + deduper.markSeen(failureWarnKey); + } + + const failureReplyKey: ChannelBridgeInboundMessage = { + ...event, + messageId: `forward-failure:${String(event.messageId ?? '').trim()}`, + }; + + if (!deduper.hasSeen(failureReplyKey)) { + try { + await replyToConversation( + adapter, + ref, + giveUp + ? `Failed to send message to session ${binding.sessionId}. Giving up after repeated failures; please try again.` + : `Failed to send message to session ${binding.sessionId}. Retrying…`, + ); + deduper.markSeen(failureReplyKey); + } catch (replyError) { + params.deps.onWarning?.( + `Failed to send session-forward-failure reply for provider=${adapter.providerId} conversation=${event.conversationId} thread=${event.threadId ?? 'null'}`, + replyError, + ); + } + } + + if (giveUp) { + processedSuccessfully = true; + } else { + continue; } } - processedSuccessfully = true; } catch (error) { params.deps.onWarning?.(`Failed to process inbound message for adapter ${adapter.providerId}`, error); } @@ -1297,6 +1403,20 @@ export function startChannelBridgeWorker(params: Readonly<{ : 2_500; const inboundDeduper = createChannelBridgeInboundDeduper(); + const inboundForwardFailureTracker = createChannelBridgeInboundForwardFailureTracker({ + maxAttempts: (() => { + const raw = (process.env.HAPPIER_CHANNEL_BRIDGE_INBOUND_FORWARD_MAX_ATTEMPTS ?? '').trim(); + if (!raw || !/^[0-9]+$/.test(raw)) return undefined; + const parsed = Number.parseInt(raw, 10); + return Number.isSafeInteger(parsed) ? parsed : undefined; + })(), + maxAgeMs: (() => { + const raw = (process.env.HAPPIER_CHANNEL_BRIDGE_INBOUND_FORWARD_MAX_AGE_MS ?? '').trim(); + if (!raw || !/^[0-9]+$/.test(raw)) return undefined; + const parsed = Number.parseInt(raw, 10); + return Number.isSafeInteger(parsed) ? parsed : undefined; + })(), + }); const warnedMissingAdapterBindings = new Set(); const warnedAdapterPullFailures = new Set(); let inFlightTick: Promise | null = null; @@ -1307,6 +1427,7 @@ export function startChannelBridgeWorker(params: Readonly<{ adapters: params.adapters, deps: params.deps, inboundDeduper, + inboundForwardFailureTracker, warnedMissingAdapterBindings, warnedAdapterPullFailures, }); From b3993393d5aa4fb2014284d6954f30586efeb2d7 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Fri, 27 Mar 2026 17:11:51 +0100 Subject: [PATCH 61/81] fix(channel-bridges): harden webhook tokens and env overrides --- .../src/channels/channelBridgeConfig.test.ts | 19 +++++++ apps/cli/src/channels/channelBridgeConfig.ts | 6 +-- .../telegram/telegramAdapter.test.ts | 38 ++++++++++++++ .../providers/telegram/telegramAdapter.ts | 2 +- .../telegram/telegramWebhookRelay.test.ts | 49 +++++++++++++++++++ .../telegram/telegramWebhookRelay.ts | 16 +++++- 6 files changed, 123 insertions(+), 7 deletions(-) diff --git a/apps/cli/src/channels/channelBridgeConfig.test.ts b/apps/cli/src/channels/channelBridgeConfig.test.ts index 3a569be0c4..428edbe138 100644 --- a/apps/cli/src/channels/channelBridgeConfig.test.ts +++ b/apps/cli/src/channels/channelBridgeConfig.test.ts @@ -111,6 +111,25 @@ describe('resolveChannelBridgeRuntimeConfig', () => { expect(config.telegram.webhookPort).toBe(9_001); }); + it('does not override settings bot token when env bot token is empty/whitespace', () => { + const config = resolveChannelBridgeRuntimeConfig({ + env: { + HAPPIER_TELEGRAM_BOT_TOKEN: ' ', + }, + settings: { + channelBridge: { + providers: { + telegram: { + botToken: 'settings-token', + }, + }, + }, + }, + }); + + expect(config.telegram.botToken).toBe('settings-token'); + }); + it('applies a valid env webhook port override', () => { const config = resolveChannelBridgeRuntimeConfig({ env: { diff --git a/apps/cli/src/channels/channelBridgeConfig.ts b/apps/cli/src/channels/channelBridgeConfig.ts index 5daee3f1d8..11d7ddf9a7 100644 --- a/apps/cli/src/channels/channelBridgeConfig.ts +++ b/apps/cli/src/channels/channelBridgeConfig.ts @@ -166,10 +166,8 @@ export function resolveChannelBridgeRuntimeConfig(params: Readonly<{ telegramGlobal?.botToken, ], readTrimmedString) ?? ''; - const botToken = - typeof env.HAPPIER_TELEGRAM_BOT_TOKEN === 'string' - ? env.HAPPIER_TELEGRAM_BOT_TOKEN.trim() - : settingsBotToken; + const envBotToken = readTrimmedString(env.HAPPIER_TELEGRAM_BOT_TOKEN); + const botToken = envBotToken ?? settingsBotToken; const settingsAllowedChatIds = firstParsed( diff --git a/apps/cli/src/channels/providers/telegram/telegramAdapter.test.ts b/apps/cli/src/channels/providers/telegram/telegramAdapter.test.ts index 68fa5be454..67157c0861 100644 --- a/apps/cli/src/channels/providers/telegram/telegramAdapter.test.ts +++ b/apps/cli/src/channels/providers/telegram/telegramAdapter.test.ts @@ -488,4 +488,42 @@ describe('createTelegramChannelAdapter', () => { postSpy.mockRestore(); } }); + + it('bounds getUpdates HTTP timeout under the worker IO timeout to avoid overlapping polls', async () => { + const postSpy = vi.spyOn(axios, 'post').mockImplementation(async (url, data, config) => { + if (typeof url === 'string' && url.includes('/getUpdates')) { + return { + status: 200, + data: { ok: true, result: [] }, + __capturedConfig: config, + __capturedBody: data, + } as never; + } + if (typeof url === 'string' && url.includes('/getMe')) { + return { + status: 200, + data: { ok: true, result: { id: 9, username: 'happier_bot' } }, + __capturedConfig: config, + __capturedBody: data, + } as never; + } + throw new Error(`Unexpected Telegram API call: ${String(url)}`); + }); + + try { + const adapter = createTelegramChannelAdapter({ + botToken: 'test-token', + }); + + await expect(adapter.pullInboundMessages()).resolves.toEqual([]); + + const getUpdatesCall = postSpy.mock.calls.find((call) => String(call[0]).includes('/getUpdates')); + expect(getUpdatesCall).toBeTruthy(); + const getUpdatesConfig = getUpdatesCall?.[2] as { timeout?: number } | undefined; + expect(typeof getUpdatesConfig?.timeout).toBe('number'); + expect((getUpdatesConfig?.timeout ?? 0)).toBeLessThanOrEqual(30_000); + } finally { + postSpy.mockRestore(); + } + }); }); diff --git a/apps/cli/src/channels/providers/telegram/telegramAdapter.ts b/apps/cli/src/channels/providers/telegram/telegramAdapter.ts index 967fc487ea..8d3c011799 100644 --- a/apps/cli/src/channels/providers/telegram/telegramAdapter.ts +++ b/apps/cli/src/channels/providers/telegram/telegramAdapter.ts @@ -14,7 +14,7 @@ type TelegramApiClient = Readonly<{ }>; const TELEGRAM_GET_UPDATES_LONG_POLL_TIMEOUT_SECONDS = 25; -const TELEGRAM_GET_UPDATES_HTTP_TIMEOUT_MS = (TELEGRAM_GET_UPDATES_LONG_POLL_TIMEOUT_SECONDS + 10) * 1_000; +const TELEGRAM_GET_UPDATES_HTTP_TIMEOUT_MS = (TELEGRAM_GET_UPDATES_LONG_POLL_TIMEOUT_SECONDS + 4) * 1_000; const TELEGRAM_MAX_SEND_MESSAGE_TEXT_LENGTH = 4_096; function telegramApiUrl(botToken: string, method: string): string { diff --git a/apps/cli/src/channels/providers/telegram/telegramWebhookRelay.test.ts b/apps/cli/src/channels/providers/telegram/telegramWebhookRelay.test.ts index a7f8222ab1..097ef01c5a 100644 --- a/apps/cli/src/channels/providers/telegram/telegramWebhookRelay.test.ts +++ b/apps/cli/src/channels/providers/telegram/telegramWebhookRelay.test.ts @@ -143,6 +143,55 @@ describe('startTelegramWebhookRelay', () => { })).rejects.toThrow('Webhook header secret token is required'); }); + it('rejects webhook secret tokens that exceed the maximum length', async () => { + await expect(startTelegramWebhookRelay({ + port: 0, + host: '127.0.0.1', + secretPathToken: 'x'.repeat(300), + secretHeaderToken: 'secret-123', + onUpdate: () => undefined, + })).rejects.toThrow('Webhook secret token is too long'); + + await expect(startTelegramWebhookRelay({ + port: 0, + host: '127.0.0.1', + secretPathToken: 'secret-123', + secretHeaderToken: 'x'.repeat(300), + onUpdate: () => undefined, + })).rejects.toThrow('Webhook header secret token is too long'); + }); + + it('rejects requests with oversized header secret tokens without allocating large buffers', async () => { + const received: unknown[] = []; + + const relay = await startTelegramWebhookRelay({ + port: 0, + host: '127.0.0.1', + secretPathToken: 'secret-abc', + secretHeaderToken: 'header-token-abc', + onUpdate: (update) => { + received.push(update); + }, + }); + + try { + const oversizedHeaderResponse = await axios.post(`http://127.0.0.1:${relay.port}/telegram/webhook/secret-abc`, { + update_id: 103, + message: { text: 'hello' }, + }, { + headers: { + 'X-Telegram-Bot-Api-Secret-Token': 'x'.repeat(400), + }, + validateStatus: () => true, + }); + + expect(oversizedHeaderResponse.status).toBe(431); + expect(received).toEqual([]); + } finally { + await relay.stop(); + } + }); + it('rejects non-loopback webhook hosts', async () => { await expect(startTelegramWebhookRelay({ port: 0, diff --git a/apps/cli/src/channels/providers/telegram/telegramWebhookRelay.ts b/apps/cli/src/channels/providers/telegram/telegramWebhookRelay.ts index cddf50cae7..f7eb1bdbb5 100644 --- a/apps/cli/src/channels/providers/telegram/telegramWebhookRelay.ts +++ b/apps/cli/src/channels/providers/telegram/telegramWebhookRelay.ts @@ -5,6 +5,8 @@ import fastify from 'fastify'; import { isLoopbackHostname } from '@/server/serverUrlClassification'; import { logger } from '@/ui/logger'; +const MAX_WEBHOOK_SECRET_TOKEN_LENGTH = 256; + function secureCompareToken(providedToken: string, expectedToken: string): boolean { const providedBytes = Buffer.from(providedToken, 'utf8'); const expectedBytes = Buffer.from(expectedToken, 'utf8'); @@ -34,6 +36,9 @@ export async function startTelegramWebhookRelay(params: Readonly<{ if (!secretPathToken) { throw new Error('Webhook secret token is required'); } + if (secretPathToken.length > MAX_WEBHOOK_SECRET_TOKEN_LENGTH) { + throw new Error('Webhook secret token is too long'); + } if (!/^[A-Za-z0-9_-]+$/.test(secretPathToken)) { throw new Error('Webhook secret token must match [A-Za-z0-9_-]'); } @@ -42,6 +47,9 @@ export async function startTelegramWebhookRelay(params: Readonly<{ if (!secretHeaderToken) { throw new Error('Webhook header secret token is required'); } + if (secretHeaderToken.length > MAX_WEBHOOK_SECRET_TOKEN_LENGTH) { + throw new Error('Webhook header secret token is too long'); + } const host = String(params.host ?? '127.0.0.1').trim() || '127.0.0.1'; if (!isLoopbackHostname(host)) { @@ -66,7 +74,11 @@ export async function startTelegramWebhookRelay(params: Readonly<{ : Array.isArray(providedHeader) && providedHeader.length > 0 ? providedHeader[0] ?? '' : ''; - if (!secureCompareToken(providedToken.trim(), secretHeaderToken)) { + const trimmedProvidedToken = providedToken.trim(); + if (trimmedProvidedToken.length > MAX_WEBHOOK_SECRET_TOKEN_LENGTH) { + return reply.status(431).send({ ok: false, error: 'Request Header Fields Too Large' }); + } + if (!secureCompareToken(trimmedProvidedToken, secretHeaderToken)) { return reply.status(401).send({ ok: false, error: 'Unauthorized' }); } @@ -76,7 +88,7 @@ export async function startTelegramWebhookRelay(params: Readonly<{ } catch (error) { logger.warn('[TELEGRAM_WEBHOOK_RELAY] Failed to process inbound webhook update', { path: redactedPath, - tokenLength: providedToken.trim().length, + tokenLength: trimmedProvidedToken.length, error: error instanceof Error ? error.message : String(error), }); return reply.status(500).send({ ok: false, error: 'Internal Server Error' }); From 9df4e955960c9f3a5be24262120578cf55436ae1 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Fri, 27 Mar 2026 17:38:17 +0100 Subject: [PATCH 62/81] fix(ui): resolve channel bridges decisions in runtime scope --- .../ChannelBridgesSettingsView.test.tsx | 11 ++++++++++- .../channelBridges/ChannelBridgesSettingsView.tsx | 4 ++-- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/apps/ui/sources/components/settings/channelBridges/ChannelBridgesSettingsView.test.tsx b/apps/ui/sources/components/settings/channelBridges/ChannelBridgesSettingsView.test.tsx index 1feabe9ae6..d21aa13c27 100644 --- a/apps/ui/sources/components/settings/channelBridges/ChannelBridgesSettingsView.test.tsx +++ b/apps/ui/sources/components/settings/channelBridges/ChannelBridgesSettingsView.test.tsx @@ -10,7 +10,7 @@ import { installSettingsViewCommonModuleMocks } from '../settingsViewTestHelpers const useFeatureDecisionMock = vi.hoisted(() => vi.fn()); vi.mock('@/hooks/server/useFeatureDecision', () => ({ - useFeatureDecision: (featureId: FeatureId) => useFeatureDecisionMock(featureId), + useFeatureDecision: (featureId: FeatureId, scope?: unknown) => useFeatureDecisionMock(featureId, scope), })); installSettingsViewCommonModuleMocks({ @@ -45,6 +45,15 @@ afterEach(() => { }); describe('ChannelBridgesSettingsView', () => { + it('requests runtime-scoped feature decisions', async () => { + useFeatureDecisionMock.mockReturnValue(null); + const { ChannelBridgesSettingsView } = await import('./ChannelBridgesSettingsView'); + await renderScreen(React.createElement(ChannelBridgesSettingsView)); + + expect(useFeatureDecisionMock).toHaveBeenCalledWith('channelBridges', { scopeKind: 'runtime' }); + expect(useFeatureDecisionMock).toHaveBeenCalledWith('channelBridges.telegram', { scopeKind: 'runtime' }); + }); + it('shows a loading state before decisions resolve', async () => { useFeatureDecisionMock.mockReturnValue(null); const { ChannelBridgesSettingsView } = await import('./ChannelBridgesSettingsView'); diff --git a/apps/ui/sources/components/settings/channelBridges/ChannelBridgesSettingsView.tsx b/apps/ui/sources/components/settings/channelBridges/ChannelBridgesSettingsView.tsx index 69f913b7f0..46dccb811e 100644 --- a/apps/ui/sources/components/settings/channelBridges/ChannelBridgesSettingsView.tsx +++ b/apps/ui/sources/components/settings/channelBridges/ChannelBridgesSettingsView.tsx @@ -18,8 +18,8 @@ import { t } from '@/text'; export const ChannelBridgesSettingsView = React.memo(function ChannelBridgesSettingsView() { const { theme } = useUnistyles(); const router = useRouter(); - const channelBridgesDecision = useFeatureDecision('channelBridges'); - const telegramDecision = useFeatureDecision('channelBridges.telegram'); + const channelBridgesDecision = useFeatureDecision('channelBridges', { scopeKind: 'runtime' }); + const telegramDecision = useFeatureDecision('channelBridges.telegram', { scopeKind: 'runtime' }); const loading = channelBridgesDecision === null; const needsLocalEnablement = channelBridgesDecision?.blockedBy === 'local_policy'; From 22b3e156b59f854a25abf5bb7351dcea91096a0f Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Fri, 27 Mar 2026 17:43:27 +0100 Subject: [PATCH 63/81] fix(ui): make feature diagnostics scope-aware --- .../ChannelBridgesSettingsView.tsx | 4 +++- .../features/FeatureDiagnosticsPanel.test.tsx | 16 ++++++++++++++-- .../features/FeatureDiagnosticsPanel.tsx | 9 +++++---- 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/apps/ui/sources/components/settings/channelBridges/ChannelBridgesSettingsView.tsx b/apps/ui/sources/components/settings/channelBridges/ChannelBridgesSettingsView.tsx index 46dccb811e..4f7440b740 100644 --- a/apps/ui/sources/components/settings/channelBridges/ChannelBridgesSettingsView.tsx +++ b/apps/ui/sources/components/settings/channelBridges/ChannelBridgesSettingsView.tsx @@ -81,7 +81,9 @@ export const ChannelBridgesSettingsView = React.memo(function ChannelBridgesSett ) : null} - {!loading && supported ? : null} + {!loading && supported ? ( + + ) : null} ); diff --git a/apps/ui/sources/components/settings/features/FeatureDiagnosticsPanel.test.tsx b/apps/ui/sources/components/settings/features/FeatureDiagnosticsPanel.test.tsx index f4b2075953..3f1bac1ea5 100644 --- a/apps/ui/sources/components/settings/features/FeatureDiagnosticsPanel.test.tsx +++ b/apps/ui/sources/components/settings/features/FeatureDiagnosticsPanel.test.tsx @@ -7,10 +7,10 @@ import { renderScreen } from '@/dev/testkit'; (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true; -const useFeatureDecisionMock = vi.fn<(featureId: FeatureId) => FeatureDecision | null>(); +const useFeatureDecisionMock = vi.fn<(featureId: FeatureId, scope?: unknown) => FeatureDecision | null>(); vi.mock('@/hooks/server/useFeatureDecision', () => ({ - useFeatureDecision: (featureId: FeatureId) => useFeatureDecisionMock(featureId), + useFeatureDecision: (featureId: FeatureId, scope?: unknown) => useFeatureDecisionMock(featureId, scope), })); vi.mock('@/components/ui/lists/ItemGroup', () => ({ @@ -59,4 +59,16 @@ describe('FeatureDiagnosticsPanel', () => { expect(items).toHaveLength(featureIds.length); expect(items.map((item) => item.props.title)).toEqual(featureIds); }); + + it('forwards scope to useFeatureDecision', async () => { + const { FeatureDiagnosticsPanel } = await import('./FeatureDiagnosticsPanel'); + + const featureIds: FeatureId[] = ['voice', 'automations']; + useFeatureDecisionMock.mockReturnValue(null); + + await renderScreen(React.createElement(FeatureDiagnosticsPanel, { featureIds, scope: { scopeKind: 'runtime' } })); + + expect(useFeatureDecisionMock).toHaveBeenCalledWith('voice', { scopeKind: 'runtime' }); + expect(useFeatureDecisionMock).toHaveBeenCalledWith('automations', { scopeKind: 'runtime' }); + }); }); diff --git a/apps/ui/sources/components/settings/features/FeatureDiagnosticsPanel.tsx b/apps/ui/sources/components/settings/features/FeatureDiagnosticsPanel.tsx index 9117ce9296..3da089fc39 100644 --- a/apps/ui/sources/components/settings/features/FeatureDiagnosticsPanel.tsx +++ b/apps/ui/sources/components/settings/features/FeatureDiagnosticsPanel.tsx @@ -3,6 +3,7 @@ import type { FeatureId } from '@happier-dev/protocol'; import { Item } from '@/components/ui/lists/Item'; import { ItemGroup } from '@/components/ui/lists/ItemGroup'; +import type { FeatureDecisionScopeParams } from '@/hooks/server/useFeatureDecision'; import { useFeatureDecision } from '@/hooks/server/useFeatureDecision'; import { t } from '@/text'; @@ -17,8 +18,8 @@ function formatDecisionSubtitle(decision: ReturnType) }); } -const FeatureDiagnosticsRow = React.memo(function FeatureDiagnosticsRow(props: { featureId: FeatureId }) { - const decision = useFeatureDecision(props.featureId); +const FeatureDiagnosticsRow = React.memo(function FeatureDiagnosticsRow(props: { featureId: FeatureId; scope?: FeatureDecisionScopeParams }) { + const decision = useFeatureDecision(props.featureId, props.scope); return ( {props.featureIds.map((featureId) => ( - + ))} ); From 4b6d8eb0545ac729e9552f1b115f83df3b1ec15e Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Fri, 27 Mar 2026 18:45:50 +0100 Subject: [PATCH 64/81] fix(channel-bridges): persist telegram polling cursor and harden worker pulls --- .../channels/core/channelBridgeWorker.test.ts | 94 ++++++++++----- .../src/channels/core/channelBridgeWorker.ts | 24 +++- .../telegramChannelBridgeProvider.ts | 26 ++--- .../telegram/telegramAdapter.test.ts | 48 ++++++++ .../providers/telegram/telegramAdapter.ts | 51 ++++++++- .../telegramPollingCursorStore.test.ts | 34 ++++++ .../telegram/telegramPollingCursorStore.ts | 107 ++++++++++++++++++ 7 files changed, 336 insertions(+), 48 deletions(-) create mode 100644 apps/cli/src/channels/providers/telegram/telegramPollingCursorStore.test.ts create mode 100644 apps/cli/src/channels/providers/telegram/telegramPollingCursorStore.ts diff --git a/apps/cli/src/channels/core/channelBridgeWorker.test.ts b/apps/cli/src/channels/core/channelBridgeWorker.test.ts index ddb30c2a9e..92155b0ab5 100644 --- a/apps/cli/src/channels/core/channelBridgeWorker.test.ts +++ b/apps/cli/src/channels/core/channelBridgeWorker.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { createInMemoryChannelBindingStore, @@ -2842,39 +2842,79 @@ describe('startChannelBridgeWorker', () => { } }); - it('deduplicates inbound messages across runtime ticks', async () => { + it('does not start overlapping adapter pulls across ticks when the previous pull times out', async () => { + vi.useFakeTimers(); const store = createInMemoryChannelBindingStore(); - const harness = createAdapterHarness(); - - await store.upsertBinding({ - ...DEFAULT_BINDING_POLICY, - providerId: 'telegram', - conversationId: 'dedupe-room', - threadId: null, - sessionId: 'sess-dedupe', - lastForwardedSeq: 0, - }); - - const { deps, sentToSession } = createDepsHarness(); + const { deps } = createDepsHarness(); + const deferred = createDeferredPromise(); + let pullCalls = 0; - harness.pushInbound({ + const adapter: ChannelBridgeAdapter = { providerId: 'telegram', - conversationId: 'dedupe-room', - threadId: null, - text: 'duplicate payload', - messageId: 'dedupe-id-1', - }); + pullInboundMessages: async () => { + pullCalls += 1; + return deferred.promise; + }, + sendMessage: async () => {}, + }; const worker = startChannelBridgeWorker({ store, - adapters: [harness.adapter], + adapters: [adapter], deps, - tickMs: 60_000, + tickMs: 250, }); try { - await waitFor(() => harness.pendingInboundCount() === 0 && sentToSession.length === 1); - expect(sentToSession).toHaveLength(1); + // Allow the startup tick to time out without resolving the underlying adapter pulls. + await vi.advanceTimersByTimeAsync(30_000); + await Promise.resolve(); + expect(pullCalls).toBe(1); + + worker.trigger(); + await vi.advanceTimersByTimeAsync(30_000); + await Promise.resolve(); + expect(pullCalls).toBe(1); + } finally { + deferred.resolve([]); + await worker.stop(); + vi.useRealTimers(); + } + }); + + it('deduplicates inbound messages across runtime ticks', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + + await store.upsertBinding({ + ...DEFAULT_BINDING_POLICY, + providerId: 'telegram', + conversationId: 'dedupe-room', + threadId: null, + sessionId: 'sess-dedupe', + lastForwardedSeq: 0, + }); + + const { deps, sentToSession } = createDepsHarness(); + + harness.pushInbound({ + providerId: 'telegram', + conversationId: 'dedupe-room', + threadId: null, + text: 'duplicate payload', + messageId: 'dedupe-id-1', + }); + + const worker = startChannelBridgeWorker({ + store, + adapters: [harness.adapter], + deps, + tickMs: 60_000, + }); + + try { + await waitFor(() => harness.pendingInboundCount() === 0 && sentToSession.length === 1); + expect(sentToSession).toHaveLength(1); harness.pushInbound({ providerId: 'telegram', @@ -2887,9 +2927,9 @@ describe('startChannelBridgeWorker', () => { await waitFor(() => harness.pendingInboundCount() === 0 && sentToSession.length === 1); expect(sentToSession).toHaveLength(1); - } finally { - await worker.stop(); - } + } finally { + await worker.stop(); + } }); it('stops idempotently and waits for in-flight tick before stopping adapters', async () => { diff --git a/apps/cli/src/channels/core/channelBridgeWorker.ts b/apps/cli/src/channels/core/channelBridgeWorker.ts index 6fbed69309..d66ba66be0 100644 --- a/apps/cli/src/channels/core/channelBridgeWorker.ts +++ b/apps/cli/src/channels/core/channelBridgeWorker.ts @@ -1419,12 +1419,34 @@ export function startChannelBridgeWorker(params: Readonly<{ }); const warnedMissingAdapterBindings = new Set(); const warnedAdapterPullFailures = new Set(); + const adapterPullSingleFlightByAdapter = new WeakMap>(); + const adaptersForTick: ChannelBridgeAdapter[] = params.adapters.map((adapter) => ({ + ...adapter, + pullInboundMessages: () => { + const existing = adapterPullSingleFlightByAdapter.get(adapter); + if (existing) { + return existing; + } + + let inflight: Promise; + inflight = Promise.resolve() + .then(() => adapter.pullInboundMessages()) + .finally(() => { + if (adapterPullSingleFlightByAdapter.get(adapter) === inflight) { + adapterPullSingleFlightByAdapter.delete(adapter); + } + }); + + adapterPullSingleFlightByAdapter.set(adapter, inflight); + return inflight; + }, + })); let inFlightTick: Promise | null = null; const runTick = async (): Promise => { const tickRun = executeChannelBridgeTick({ store: params.store, - adapters: params.adapters, + adapters: adaptersForTick, deps: params.deps, inboundDeduper, inboundForwardFailureTracker, diff --git a/apps/cli/src/channels/providers/_registry/telegramChannelBridgeProvider.ts b/apps/cli/src/channels/providers/_registry/telegramChannelBridgeProvider.ts index 94fa1f8f39..d248e4c5d9 100644 --- a/apps/cli/src/channels/providers/_registry/telegramChannelBridgeProvider.ts +++ b/apps/cli/src/channels/providers/_registry/telegramChannelBridgeProvider.ts @@ -1,5 +1,6 @@ import { serializeAxiosErrorForLog } from '@/api/client/serializeAxiosErrorForLog'; import { createTelegramChannelAdapter } from '@/channels/providers/telegram/telegramAdapter'; +import { createTelegramPollingCursorStore } from '@/channels/providers/telegram/telegramPollingCursorStore'; import { startTelegramWebhookRelay, type TelegramWebhookRelayHandle, @@ -37,12 +38,13 @@ export const telegramChannelBridgeProvider: ChannelBridgeProviderDefinition< const webhookEnabled = config.webhookEnabled; const webhookSecret = config.webhookSecret; if (webhookEnabled && webhookSecret.length === 0) { - logger.warn( - '[channelBridge] Telegram webhook.enabled=true but webhook.secret is missing; falling back to polling mode', + throw new Error( + 'Telegram webhook mode is enabled but webhook secret is missing. Set HAPPIER_TELEGRAM_WEBHOOK_SECRET (or persist secrets.webhookSecret in settings) and restart the daemon.', ); } - const webhookModeRequested = webhookEnabled && webhookSecret.length > 0; + const webhookModeRequested = webhookEnabled; + const pollingCursorStore = createTelegramPollingCursorStore({ botToken }); let relayHandle: TelegramWebhookRelayHandle | null = null; let adapter = createTelegramChannelAdapter({ @@ -51,6 +53,7 @@ export const telegramChannelBridgeProvider: ChannelBridgeProviderDefinition< allowAllSharedChats, requireTopics, webhookMode: webhookModeRequested, + pollingCursorStore, }); if (webhookModeRequested) { @@ -71,20 +74,12 @@ export const telegramChannelBridgeProvider: ChannelBridgeProviderDefinition< `[channelBridge] Telegram webhook relay listening on http://${host}:${relayHandle.port} (path redacted)`, ); } catch (error) { - logger.warn( - '[channelBridge] Failed to start Telegram webhook relay; bridge will continue without webhook relay', - serializeAxiosErrorForLog(error), - ); + logger.warn('[channelBridge] Failed to start Telegram webhook relay', serializeAxiosErrorForLog(error)); await stopRelayBestEffort(relayHandle); relayHandle = null; - adapter = createTelegramChannelAdapter({ - botToken, - allowedChatIds, - allowAllSharedChats, - requireTopics, - webhookMode: false, - }); - logger.warn('[channelBridge] Falling back to Telegram polling mode because webhook relay failed to start'); + throw new Error( + 'Telegram webhook mode is enabled but the local webhook relay failed to start. Fix webhookHost/webhookPort and restart the daemon.', + ); } } @@ -100,4 +95,3 @@ export const telegramChannelBridgeProvider: ChannelBridgeProviderDefinition< return runtime; }, }; - diff --git a/apps/cli/src/channels/providers/telegram/telegramAdapter.test.ts b/apps/cli/src/channels/providers/telegram/telegramAdapter.test.ts index 67157c0861..2c39537934 100644 --- a/apps/cli/src/channels/providers/telegram/telegramAdapter.test.ts +++ b/apps/cli/src/channels/providers/telegram/telegramAdapter.test.ts @@ -465,6 +465,54 @@ describe('createTelegramChannelAdapter', () => { expect(getUpdates).toHaveBeenNthCalledWith(3, { offset: 12, limit: 100 }); }); + it('loads and persists polling offset when a cursor store is provided', async () => { + const getUpdates = vi + .fn() + .mockResolvedValueOnce([ + { + update_id: 50, + message: { + message_id: 111, + text: 'hello', + chat: { id: 1234, type: 'private' }, + from: { id: 42, is_bot: false, first_name: 'Ada' }, + }, + }, + ]) + .mockResolvedValueOnce([]); + + const api = { + getMe: vi.fn(async () => ({ id: 9, username: 'happier_bot' })), + getUpdates, + sendMessage: vi.fn(async () => undefined), + }; + + const cursorStore = { + load: vi.fn(async () => 42), + save: vi.fn(async () => undefined), + }; + + const adapter = createTelegramChannelAdapter({ + botToken: 'test-token', + api, + webhookMode: false, + pollingCursorStore: cursorStore, + }); + + const parsed = await adapter.pullInboundMessages(); + expect(getUpdates).toHaveBeenNthCalledWith(1, { offset: 42, limit: 100 }); + expect(parsed).toHaveLength(1); + expect(cursorStore.load).toHaveBeenCalledTimes(1); + expect(cursorStore.save).not.toHaveBeenCalled(); + + await adapter.ackInboundMessages?.(parsed); + expect(cursorStore.save).toHaveBeenCalledTimes(1); + expect(cursorStore.save).toHaveBeenCalledWith(51); + + await adapter.pullInboundMessages(); + expect(getUpdates).toHaveBeenNthCalledWith(2, { offset: 51, limit: 100 }); + }); + it('includes Telegram API error descriptions when available', async () => { const postSpy = vi.spyOn(axios, 'post').mockResolvedValue({ status: 400, diff --git a/apps/cli/src/channels/providers/telegram/telegramAdapter.ts b/apps/cli/src/channels/providers/telegram/telegramAdapter.ts index 8d3c011799..718de97eb0 100644 --- a/apps/cli/src/channels/providers/telegram/telegramAdapter.ts +++ b/apps/cli/src/channels/providers/telegram/telegramAdapter.ts @@ -235,6 +235,10 @@ export function createTelegramChannelAdapter(params: Readonly<{ allowedChatIds?: ReadonlySet | null; allowAllSharedChats?: boolean; requireTopics?: boolean; + pollingCursorStore?: Readonly<{ + load: () => Promise; + save: (offset: number) => Promise; + }>; }>): ChannelBridgeAdapter & Readonly<{ enqueueWebhookUpdate: (update: unknown) => void }> { const api = params.api ?? createDefaultTelegramApiClient(params.botToken); const webhookMode = params.webhookMode === true; @@ -268,18 +272,53 @@ export function createTelegramChannelAdapter(params: Readonly<{ /** * Telegram polling cursor (`getUpdates` offset). * - * This cursor is intentionally process-local today and is not persisted across - * daemon restarts. Polling mode therefore provides at-least-once delivery: - * after restart, Telegram may replay unacknowledged updates from the retention - * window, and downstream dedupe is expected to absorb duplicates. + * If a `pollingCursorStore` is provided, the adapter loads and persists this cursor + * (best-effort) so polling can resume after daemon restarts without replaying the + * retention window. If not provided, the cursor is process-local and polling is + * at-least-once across restarts. */ let updateOffset: number | null = null; + const pollingCursorStore = params.pollingCursorStore ?? null; + let pollingCursorLoaded = false; + let lastPersistedPollingOffset: number | null = null; + let pollingCursorSaveQueue = Promise.resolve(); const queuedWebhookUpdates: QueuedWebhookUpdate[] = []; let nextQueuedWebhookId = 1; let droppedWebhookUpdates = 0; const pendingWebhookAcksByMessageKey = new Map(); let pendingPollingBatchAck: PendingPollingBatchAck | null = null; + async function ensurePollingCursorLoaded(): Promise { + if (pollingCursorLoaded) return; + pollingCursorLoaded = true; + if (!pollingCursorStore) return; + + try { + const loaded = await pollingCursorStore.load(); + if (typeof loaded === 'number' && Number.isFinite(loaded)) { + const candidate = Math.max(0, Math.trunc(loaded)); + updateOffset = updateOffset === null ? candidate : Math.max(updateOffset, candidate); + lastPersistedPollingOffset = candidate; + } + } catch (error) { + logger.warn('[channelBridge] Failed to load Telegram polling cursor; continuing with empty cursor', error); + } + } + + async function persistPollingOffset(offset: number): Promise { + if (!pollingCursorStore) return; + if (lastPersistedPollingOffset !== null && offset <= lastPersistedPollingOffset) return; + lastPersistedPollingOffset = offset; + + pollingCursorSaveQueue = pollingCursorSaveQueue + .then(() => pollingCursorStore.save(offset)) + .catch((error) => { + logger.warn('[channelBridge] Failed to persist Telegram polling cursor; continuing without persistence', error); + }); + + await pollingCursorSaveQueue; + } + function dropPendingWebhookAckIds(ids: ReadonlySet): void { if (ids.size === 0) return; @@ -390,6 +429,7 @@ export function createTelegramChannelAdapter(params: Readonly<{ return pendingPollingBatchAck.messages.map((message) => ({ ...message })); } + await ensurePollingCursorLoaded(); const updates = await api.getUpdates({ offset: updateOffset, limit: updateLimit, @@ -401,6 +441,7 @@ export function createTelegramChannelAdapter(params: Readonly<{ if (maxUpdateId !== null) { const nextOffset = maxUpdateId + 1; updateOffset = updateOffset === null ? nextOffset : Math.max(updateOffset, nextOffset); + await persistPollingOffset(updateOffset); } return []; } @@ -439,6 +480,7 @@ export function createTelegramChannelAdapter(params: Readonly<{ if (pendingBatch.maxUpdateId !== null) { const nextOffset = pendingBatch.maxUpdateId + 1; updateOffset = updateOffset === null ? nextOffset : Math.max(updateOffset, nextOffset); + await persistPollingOffset(updateOffset); } pendingPollingBatchAck = null; @@ -477,6 +519,7 @@ export function createTelegramChannelAdapter(params: Readonly<{ if (maxAckedUpdateId !== null) { const nextOffset = maxAckedUpdateId + 1; updateOffset = updateOffset === null ? nextOffset : Math.max(updateOffset, nextOffset); + await persistPollingOffset(updateOffset); } }, sendMessage: async (message) => { diff --git a/apps/cli/src/channels/providers/telegram/telegramPollingCursorStore.test.ts b/apps/cli/src/channels/providers/telegram/telegramPollingCursorStore.test.ts new file mode 100644 index 0000000000..371be49721 --- /dev/null +++ b/apps/cli/src/channels/providers/telegram/telegramPollingCursorStore.test.ts @@ -0,0 +1,34 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createEnvKeyScope } from '@/testkit/env/envScope'; +import { withTempDir } from '@/testkit/fs/tempDir'; + +describe('createTelegramPollingCursorStore', () => { + const envKeys = ['HAPPIER_HOME_DIR'] as const; + let envScope = createEnvKeyScope(envKeys); + + afterEach(() => { + envScope.restore(); + envScope = createEnvKeyScope(envKeys); + vi.resetModules(); + }); + + it('persists and reloads a polling cursor for the same bot token', async () => { + await withTempDir('happier-telegram-polling-cursor-', async (homeDir) => { + envScope.patch({ HAPPIER_HOME_DIR: homeDir }); + vi.resetModules(); + + const { createTelegramPollingCursorStore } = await import('./telegramPollingCursorStore'); + + const storeA = createTelegramPollingCursorStore({ botToken: 'bot-token-a' }); + await expect(storeA.load()).resolves.toBeNull(); + + await expect(storeA.save(123)).resolves.toBeUndefined(); + await expect(storeA.load()).resolves.toBe(123); + + const storeB = createTelegramPollingCursorStore({ botToken: 'bot-token-a' }); + await expect(storeB.load()).resolves.toBe(123); + }); + }); +}); + diff --git a/apps/cli/src/channels/providers/telegram/telegramPollingCursorStore.ts b/apps/cli/src/channels/providers/telegram/telegramPollingCursorStore.ts new file mode 100644 index 0000000000..daa8628977 --- /dev/null +++ b/apps/cli/src/channels/providers/telegram/telegramPollingCursorStore.ts @@ -0,0 +1,107 @@ +import { createHash } from 'node:crypto'; +import { chmod, mkdir, readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { configuration } from '@/configuration'; +import { writeJsonAtomic } from '@/utils/fs/writeJsonAtomic'; +import { logger } from '@/ui/logger'; + +type StoredTelegramPollingCursorDocV1 = Readonly<{ + schemaVersion: 1; + updateOffset: number; +}>; + +const STORE_SCHEMA_VERSION = 1; +const BOT_TOKEN_HASH_LENGTH = 32; + +function asRecord(value: unknown): Record | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + return value as Record; +} + +function toNonNegativeInt(value: unknown): number | null { + if (typeof value !== 'number' || !Number.isFinite(value)) return null; + const truncated = Math.trunc(value); + return truncated >= 0 ? truncated : null; +} + +function parseStoredDoc(value: unknown): StoredTelegramPollingCursorDocV1 | null { + const record = asRecord(value); + if (!record) return null; + if (record.schemaVersion !== STORE_SCHEMA_VERSION) return null; + const updateOffset = toNonNegativeInt(record.updateOffset); + if (updateOffset === null) return null; + return { schemaVersion: 1, updateOffset }; +} + +function hashBotToken(botToken: string): string { + const normalized = botToken.trim(); + if (!normalized) { + throw new Error('Telegram bot token is required'); + } + return createHash('sha256').update(normalized).digest('hex').slice(0, BOT_TOKEN_HASH_LENGTH); +} + +async function bestEffortChmod0700(path: string): Promise { + if (process.platform === 'win32') return; + await chmod(path, 0o700).catch(() => {}); +} + +function resolveCursorFile(botToken: string): string { + const tokenHash = hashBotToken(botToken); + return join(configuration.activeServerDir, 'channel-bridges', 'v1', 'telegram', 'polling-cursors', `${tokenHash}.json`); +} + +export type TelegramPollingCursorStore = Readonly<{ + load: () => Promise; + save: (offset: number) => Promise; +}>; + +export function createTelegramPollingCursorStore(params: Readonly<{ botToken: string }>): TelegramPollingCursorStore { + const cursorFile = resolveCursorFile(params.botToken); + let queue = Promise.resolve(); + + const enqueue = (work: () => Promise): Promise => { + const run = queue.then(work, work); + queue = run.then(() => undefined, () => undefined); + return run; + }; + + return { + load: () => enqueue(async () => { + try { + const raw = await readFile(cursorFile, { encoding: 'utf-8' }).catch((error: unknown) => { + const err = error as NodeJS.ErrnoException; + if (err?.code === 'ENOENT') return null; + throw error; + }); + const parsed = raw ? parseStoredDoc(JSON.parse(raw)) : null; + return parsed?.updateOffset ?? null; + } catch (error) { + logger.warn('[channelBridge] Failed to read Telegram polling cursor; treating cursor as missing', error); + return null; + } + }), + save: (offset) => enqueue(async () => { + const candidate = toNonNegativeInt(offset); + if (candidate === null) { + throw new Error('Invalid Telegram polling cursor offset'); + } + + const telegramDir = join(configuration.activeServerDir, 'channel-bridges', 'v1', 'telegram'); + const cursorsDir = join(telegramDir, 'polling-cursors'); + await mkdir(cursorsDir, { recursive: true, mode: 0o700 }); + await bestEffortChmod0700(configuration.activeServerDir); + + const doc: StoredTelegramPollingCursorDocV1 = { + schemaVersion: 1, + updateOffset: candidate, + }; + await writeJsonAtomic(cursorFile, doc); + await bestEffortChmod0700(join(configuration.activeServerDir, 'channel-bridges')); + await bestEffortChmod0700(join(configuration.activeServerDir, 'channel-bridges', 'v1')); + await bestEffortChmod0700(telegramDir); + await bestEffortChmod0700(cursorsDir); + }), + }; +} From a99fe028d004952ef35f16cd896afc09ff5d95a9 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Fri, 27 Mar 2026 18:51:06 +0100 Subject: [PATCH 65/81] fix(channel-bridges): skip provider on runtime startup failure --- .../channels/runtime/startChannelBridgeRuntime.ts | 13 ++++++++++++- .../src/channels/startChannelBridgeWorker.test.ts | 9 ++++++--- 2 files changed, 18 insertions(+), 4 deletions(-) diff --git a/apps/cli/src/channels/runtime/startChannelBridgeRuntime.ts b/apps/cli/src/channels/runtime/startChannelBridgeRuntime.ts index 7af357b947..a1f1f8f08e 100644 --- a/apps/cli/src/channels/runtime/startChannelBridgeRuntime.ts +++ b/apps/cli/src/channels/runtime/startChannelBridgeRuntime.ts @@ -7,6 +7,8 @@ import type { import { startChannelBridgeWorker } from '@/channels/core/channelBridgeWorker'; import type { ChannelBridgeRuntimeConfig } from '@/channels/channelBridgeConfig'; import { listChannelBridgeProviderIds, resolveChannelBridgeProviderDefinition } from '@/channels/providers/_registry/channelBridgeProviderRegistry'; +import { serializeAxiosErrorForLog } from '@/api/client/serializeAxiosErrorForLog'; +import { logger } from '@/ui/logger'; export type ChannelBridgeRuntimeHandle = ChannelBridgeWorkerHandle; @@ -19,7 +21,16 @@ async function startProviderRuntimes(params: Readonly<{ for (const providerId of listChannelBridgeProviderIds()) { const provider = resolveChannelBridgeProviderDefinition(providerId); const providerConfig = provider.readConfig(params.runtimeConfig); - const runtime = await provider.createRuntime({ config: providerConfig }); + let runtime: Awaited> | null; + try { + runtime = await provider.createRuntime({ config: providerConfig }); + } catch (error) { + logger.warn( + `[channelBridge] Failed to start provider runtime (provider=${providerId}); skipping provider`, + serializeAxiosErrorForLog(error), + ); + continue; + } if (!runtime) continue; adapters.push(...runtime.adapters); stopFns.push(runtime.stop); diff --git a/apps/cli/src/channels/startChannelBridgeWorker.test.ts b/apps/cli/src/channels/startChannelBridgeWorker.test.ts index 6ea0dd2b22..d42847819c 100644 --- a/apps/cli/src/channels/startChannelBridgeWorker.test.ts +++ b/apps/cli/src/channels/startChannelBridgeWorker.test.ts @@ -877,12 +877,15 @@ describe('startChannelBridgeFromEnv', () => { } as NodeJS.ProcessEnv, }); - expect(handle).not.toBeNull(); + expect(handle).toBeNull(); expect(warnSpy).toHaveBeenCalledWith( - '[channelBridge] Telegram webhook.enabled=true but webhook.secret is missing; falling back to polling mode', + '[channelBridge] Failed to start provider runtime (provider=telegram); skipping provider', + expect.objectContaining({ + message: expect.stringMatching(/webhook.*secret/i), + }), ); expect(startRelay).not.toHaveBeenCalled(); - expect(createTelegramChannelAdapter).toHaveBeenCalledWith(expect.objectContaining({ webhookMode: false })); + expect(createTelegramChannelAdapter).not.toHaveBeenCalled(); await handle?.stop(); }); From 2571a53ece02f33f8ba6b81faf73af71a2eeeeec Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Fri, 27 Mar 2026 19:02:25 +0100 Subject: [PATCH 66/81] fix(channel-bridges): harden telegram relay and cursor scoping --- .../telegramChannelBridgeProvider.ts | 14 ++- .../src/channels/providers/_registry/types.ts | 7 +- .../telegramPollingCursorStore.test.ts | 31 +++++-- .../telegram/telegramPollingCursorStore.ts | 42 +++++++-- .../telegram/telegramWebhookRelay.test.ts | 56 ++++------- .../telegram/telegramWebhookRelay.ts | 92 ++++++++++--------- .../runtime/startChannelBridgeRuntime.ts | 7 +- .../src/channels/startChannelBridgeWorker.ts | 4 + docs/telegram-channel-bridge.md | 16 ++-- 9 files changed, 152 insertions(+), 117 deletions(-) diff --git a/apps/cli/src/channels/providers/_registry/telegramChannelBridgeProvider.ts b/apps/cli/src/channels/providers/_registry/telegramChannelBridgeProvider.ts index d248e4c5d9..c8460b3ad0 100644 --- a/apps/cli/src/channels/providers/_registry/telegramChannelBridgeProvider.ts +++ b/apps/cli/src/channels/providers/_registry/telegramChannelBridgeProvider.ts @@ -26,7 +26,7 @@ export const telegramChannelBridgeProvider: ChannelBridgeProviderDefinition< > = { providerId: 'telegram', readConfig: (root) => root.telegram, - createRuntime: async ({ config }) => { + createRuntime: async ({ config, context }) => { const botToken = config.botToken; if (!botToken) return null; @@ -44,7 +44,9 @@ export const telegramChannelBridgeProvider: ChannelBridgeProviderDefinition< } const webhookModeRequested = webhookEnabled; - const pollingCursorStore = createTelegramPollingCursorStore({ botToken }); + const pollingCursorStore = context.accountId + ? createTelegramPollingCursorStore({ accountId: context.accountId, botToken }) + : null; let relayHandle: TelegramWebhookRelayHandle | null = null; let adapter = createTelegramChannelAdapter({ @@ -53,7 +55,7 @@ export const telegramChannelBridgeProvider: ChannelBridgeProviderDefinition< allowAllSharedChats, requireTopics, webhookMode: webhookModeRequested, - pollingCursorStore, + pollingCursorStore: pollingCursorStore ?? undefined, }); if (webhookModeRequested) { @@ -63,11 +65,7 @@ export const telegramChannelBridgeProvider: ChannelBridgeProviderDefinition< relayHandle = await startTelegramWebhookRelay({ port, host, - // We intentionally use one shared secret today because bridge config currently - // exposes a single webhook secret field. The relay API keeps both knobs - // separate so we can split path/header secrets in a future config version. - secretPathToken: webhookSecret, - secretHeaderToken: webhookSecret, + secretToken: webhookSecret, onUpdate: adapter.enqueueWebhookUpdate, }); logger.debug( diff --git a/apps/cli/src/channels/providers/_registry/types.ts b/apps/cli/src/channels/providers/_registry/types.ts index 6a5a142ea4..56c9eefe25 100644 --- a/apps/cli/src/channels/providers/_registry/types.ts +++ b/apps/cli/src/channels/providers/_registry/types.ts @@ -5,6 +5,11 @@ export type ChannelBridgeProviderRuntime = Readonly<{ stop: () => Promise; }>; +export type ChannelBridgeProviderRuntimeContext = Readonly<{ + serverId: string | null; + accountId: string | null; +}>; + export type ChannelBridgeProviderDefinition< TProviderId extends string, TRootConfig, @@ -12,5 +17,5 @@ export type ChannelBridgeProviderDefinition< > = Readonly<{ providerId: TProviderId; readConfig: (root: TRootConfig) => TProviderConfig; - createRuntime(params: Readonly<{ config: TProviderConfig }>): Promise; + createRuntime(params: Readonly<{ config: TProviderConfig; context: ChannelBridgeProviderRuntimeContext }>): Promise; }>; diff --git a/apps/cli/src/channels/providers/telegram/telegramPollingCursorStore.test.ts b/apps/cli/src/channels/providers/telegram/telegramPollingCursorStore.test.ts index 371be49721..03ec476dbd 100644 --- a/apps/cli/src/channels/providers/telegram/telegramPollingCursorStore.test.ts +++ b/apps/cli/src/channels/providers/telegram/telegramPollingCursorStore.test.ts @@ -13,22 +13,37 @@ describe('createTelegramPollingCursorStore', () => { vi.resetModules(); }); - it('persists and reloads a polling cursor for the same bot token', async () => { - await withTempDir('happier-telegram-polling-cursor-', async (homeDir) => { + it('persists polling cursors under the active server directory scoped by account and bot token', async () => { + await withTempDir('happier-channel-bridge-telegram-cursors-', async (homeDir) => { envScope.patch({ HAPPIER_HOME_DIR: homeDir }); vi.resetModules(); const { createTelegramPollingCursorStore } = await import('./telegramPollingCursorStore'); - const storeA = createTelegramPollingCursorStore({ botToken: 'bot-token-a' }); - await expect(storeA.load()).resolves.toBeNull(); - - await expect(storeA.save(123)).resolves.toBeUndefined(); + const storeA = createTelegramPollingCursorStore({ + accountId: 'acct-1', + botToken: 'token-1', + }); + await storeA.save(123); await expect(storeA.load()).resolves.toBe(123); - const storeB = createTelegramPollingCursorStore({ botToken: 'bot-token-a' }); + const storeB = createTelegramPollingCursorStore({ + accountId: 'acct-1', + botToken: 'token-1', + }); await expect(storeB.load()).resolves.toBe(123); + + const otherAccountStore = createTelegramPollingCursorStore({ + accountId: 'acct-2', + botToken: 'token-1', + }); + await expect(otherAccountStore.load()).resolves.toBe(null); + + const otherTokenStore = createTelegramPollingCursorStore({ + accountId: 'acct-1', + botToken: 'token-2', + }); + await expect(otherTokenStore.load()).resolves.toBe(null); }); }); }); - diff --git a/apps/cli/src/channels/providers/telegram/telegramPollingCursorStore.ts b/apps/cli/src/channels/providers/telegram/telegramPollingCursorStore.ts index daa8628977..1444d02a19 100644 --- a/apps/cli/src/channels/providers/telegram/telegramPollingCursorStore.ts +++ b/apps/cli/src/channels/providers/telegram/telegramPollingCursorStore.ts @@ -13,12 +13,30 @@ type StoredTelegramPollingCursorDocV1 = Readonly<{ const STORE_SCHEMA_VERSION = 1; const BOT_TOKEN_HASH_LENGTH = 32; +const ACCOUNT_ID_SAFE_RE = /^[A-Za-z0-9._-]{1,128}$/; function asRecord(value: unknown): Record | null { if (!value || typeof value !== 'object' || Array.isArray(value)) return null; return value as Record; } +function assertFilesystemSafeAccountId(raw: string): string { + const value = raw.trim(); + if (!value) { + throw new Error('Invalid accountId: empty'); + } + if (value === '.' || value === '..') { + throw new Error(`Invalid accountId: ${value}`); + } + if (value.includes('/') || value.includes('\\')) { + throw new Error(`Invalid accountId: ${value}`); + } + if (!ACCOUNT_ID_SAFE_RE.test(value)) { + throw new Error(`Invalid accountId: ${value}`); + } + return value; +} + function toNonNegativeInt(value: unknown): number | null { if (typeof value !== 'number' || !Number.isFinite(value)) return null; const truncated = Math.trunc(value); @@ -47,9 +65,19 @@ async function bestEffortChmod0700(path: string): Promise { await chmod(path, 0o700).catch(() => {}); } -function resolveCursorFile(botToken: string): string { +function resolveCursorFile(accountId: string, botToken: string): string { const tokenHash = hashBotToken(botToken); - return join(configuration.activeServerDir, 'channel-bridges', 'v1', 'telegram', 'polling-cursors', `${tokenHash}.json`); + return join( + configuration.activeServerDir, + 'channel-bridges', + 'v1', + 'account', + accountId, + 'providers', + 'telegram', + 'polling-cursors', + `${tokenHash}.json`, + ); } export type TelegramPollingCursorStore = Readonly<{ @@ -57,8 +85,9 @@ export type TelegramPollingCursorStore = Readonly<{ save: (offset: number) => Promise; }>; -export function createTelegramPollingCursorStore(params: Readonly<{ botToken: string }>): TelegramPollingCursorStore { - const cursorFile = resolveCursorFile(params.botToken); +export function createTelegramPollingCursorStore(params: Readonly<{ accountId: string; botToken: string }>): TelegramPollingCursorStore { + const accountId = assertFilesystemSafeAccountId(params.accountId); + const cursorFile = resolveCursorFile(accountId, params.botToken); let queue = Promise.resolve(); const enqueue = (work: () => Promise): Promise => { @@ -88,7 +117,8 @@ export function createTelegramPollingCursorStore(params: Readonly<{ botToken: st throw new Error('Invalid Telegram polling cursor offset'); } - const telegramDir = join(configuration.activeServerDir, 'channel-bridges', 'v1', 'telegram'); + const accountDir = join(configuration.activeServerDir, 'channel-bridges', 'v1', 'account', accountId); + const telegramDir = join(accountDir, 'providers', 'telegram'); const cursorsDir = join(telegramDir, 'polling-cursors'); await mkdir(cursorsDir, { recursive: true, mode: 0o700 }); await bestEffortChmod0700(configuration.activeServerDir); @@ -100,7 +130,7 @@ export function createTelegramPollingCursorStore(params: Readonly<{ botToken: st await writeJsonAtomic(cursorFile, doc); await bestEffortChmod0700(join(configuration.activeServerDir, 'channel-bridges')); await bestEffortChmod0700(join(configuration.activeServerDir, 'channel-bridges', 'v1')); - await bestEffortChmod0700(telegramDir); + await bestEffortChmod0700(accountDir); await bestEffortChmod0700(cursorsDir); }), }; diff --git a/apps/cli/src/channels/providers/telegram/telegramWebhookRelay.test.ts b/apps/cli/src/channels/providers/telegram/telegramWebhookRelay.test.ts index 097ef01c5a..b5d72f0110 100644 --- a/apps/cli/src/channels/providers/telegram/telegramWebhookRelay.test.ts +++ b/apps/cli/src/channels/providers/telegram/telegramWebhookRelay.test.ts @@ -5,32 +5,31 @@ import { logger } from '@/ui/logger'; import { startTelegramWebhookRelay } from './telegramWebhookRelay'; describe('startTelegramWebhookRelay', () => { - it('rejects webhook secret path tokens outside Telegram-safe charset', async () => { + it('rejects webhook secret tokens outside Telegram-safe charset', async () => { await expect(startTelegramWebhookRelay({ port: 0, host: '127.0.0.1', - secretPathToken: 'bad$token', + secretToken: 'bad$token', onUpdate: () => { throw new Error('should not be called'); }, })).rejects.toThrow('Webhook secret token must match [A-Za-z0-9_-]'); }); - it('accepts webhook updates on the configured secret path', async () => { + it('accepts webhook updates on the fixed webhook path', async () => { const received: unknown[] = []; const relay = await startTelegramWebhookRelay({ port: 0, host: '127.0.0.1', - secretPathToken: 'secret-123', - secretHeaderToken: 'secret-123', + secretToken: 'secret-123', onUpdate: (update) => { received.push(update); }, }); try { - const response = await axios.post(`http://127.0.0.1:${relay.port}/telegram/webhook/secret-123`, { + const response = await axios.post(`http://127.0.0.1:${relay.port}/telegram/webhook`, { update_id: 42, message: { text: 'hello' }, }, { @@ -57,22 +56,21 @@ describe('startTelegramWebhookRelay', () => { const relay = await startTelegramWebhookRelay({ port: 0, host: '127.0.0.1', - secretPathToken: 'secret-abc', - secretHeaderToken: 'header-token-abc', + secretToken: 'header-token-abc', onUpdate: (update) => { received.push(update); }, }); try { - const missingHeaderResponse = await axios.post(`http://127.0.0.1:${relay.port}/telegram/webhook/secret-abc`, { + const missingHeaderResponse = await axios.post(`http://127.0.0.1:${relay.port}/telegram/webhook`, { update_id: 101, message: { text: 'hello' }, }, { validateStatus: () => true, }); - const invalidHeaderResponse = await axios.post(`http://127.0.0.1:${relay.port}/telegram/webhook/secret-abc`, { + const invalidHeaderResponse = await axios.post(`http://127.0.0.1:${relay.port}/telegram/webhook`, { update_id: 102, message: { text: 'hello again' }, }, { @@ -98,15 +96,14 @@ describe('startTelegramWebhookRelay', () => { relay = await startTelegramWebhookRelay({ port: 0, host: '127.0.0.1', - secretPathToken: 'secret-fail', - secretHeaderToken: 'secret-fail', + secretToken: 'secret-fail', onUpdate: async () => { throw new Error('failed to process update'); }, }); const response = await axios.post( - `http://127.0.0.1:${relay.port}/telegram/webhook/secret-fail`, + `http://127.0.0.1:${relay.port}/telegram/webhook`, { update_id: 202, message: { text: 'hello' }, @@ -124,7 +121,7 @@ describe('startTelegramWebhookRelay', () => { expect(warnSpy).toHaveBeenCalledWith( '[TELEGRAM_WEBHOOK_RELAY] Failed to process inbound webhook update', expect.objectContaining({ - path: '/telegram/webhook/*', + path: '/telegram/webhook', tokenLength: 'secret-fail'.length, }), ); @@ -134,31 +131,13 @@ describe('startTelegramWebhookRelay', () => { } }); - it('requires an explicit webhook header secret token', async () => { - await expect(startTelegramWebhookRelay({ - port: 0, - host: '127.0.0.1', - secretPathToken: 'secret-123', - onUpdate: () => undefined, - })).rejects.toThrow('Webhook header secret token is required'); - }); - it('rejects webhook secret tokens that exceed the maximum length', async () => { await expect(startTelegramWebhookRelay({ port: 0, host: '127.0.0.1', - secretPathToken: 'x'.repeat(300), - secretHeaderToken: 'secret-123', + secretToken: 'x'.repeat(300), onUpdate: () => undefined, })).rejects.toThrow('Webhook secret token is too long'); - - await expect(startTelegramWebhookRelay({ - port: 0, - host: '127.0.0.1', - secretPathToken: 'secret-123', - secretHeaderToken: 'x'.repeat(300), - onUpdate: () => undefined, - })).rejects.toThrow('Webhook header secret token is too long'); }); it('rejects requests with oversized header secret tokens without allocating large buffers', async () => { @@ -167,15 +146,14 @@ describe('startTelegramWebhookRelay', () => { const relay = await startTelegramWebhookRelay({ port: 0, host: '127.0.0.1', - secretPathToken: 'secret-abc', - secretHeaderToken: 'header-token-abc', + secretToken: 'header-token-abc', onUpdate: (update) => { received.push(update); }, }); try { - const oversizedHeaderResponse = await axios.post(`http://127.0.0.1:${relay.port}/telegram/webhook/secret-abc`, { + const oversizedHeaderResponse = await axios.post(`http://127.0.0.1:${relay.port}/telegram/webhook`, { update_id: 103, message: { text: 'hello' }, }, { @@ -196,8 +174,7 @@ describe('startTelegramWebhookRelay', () => { await expect(startTelegramWebhookRelay({ port: 0, host: '0.0.0.0', - secretPathToken: 'secret-123', - secretHeaderToken: 'secret-123', + secretToken: 'secret-123', onUpdate: () => undefined, })).rejects.toThrow('Webhook host must be loopback-only'); }); @@ -206,8 +183,7 @@ describe('startTelegramWebhookRelay', () => { await expect(startTelegramWebhookRelay({ port: Number.NaN, host: '127.0.0.1', - secretPathToken: 'secret-123', - secretHeaderToken: 'secret-123', + secretToken: 'secret-123', onUpdate: () => undefined, })).rejects.toThrow('Webhook port must be a finite number'); }); diff --git a/apps/cli/src/channels/providers/telegram/telegramWebhookRelay.ts b/apps/cli/src/channels/providers/telegram/telegramWebhookRelay.ts index f7eb1bdbb5..b09da4764e 100644 --- a/apps/cli/src/channels/providers/telegram/telegramWebhookRelay.ts +++ b/apps/cli/src/channels/providers/telegram/telegramWebhookRelay.ts @@ -28,29 +28,20 @@ export type TelegramWebhookRelayHandle = Readonly<{ export async function startTelegramWebhookRelay(params: Readonly<{ port: number; host?: string; - secretPathToken: string; - secretHeaderToken?: string; + secretToken: string; onUpdate: (update: unknown) => void | Promise; }>): Promise { - const secretPathToken = String(params.secretPathToken ?? '').trim(); - if (!secretPathToken) { + const secretToken = String(params.secretToken ?? '').trim(); + if (!secretToken) { throw new Error('Webhook secret token is required'); } - if (secretPathToken.length > MAX_WEBHOOK_SECRET_TOKEN_LENGTH) { + if (secretToken.length > MAX_WEBHOOK_SECRET_TOKEN_LENGTH) { throw new Error('Webhook secret token is too long'); } - if (!/^[A-Za-z0-9_-]+$/.test(secretPathToken)) { + if (!/^[A-Za-z0-9_-]+$/.test(secretToken)) { throw new Error('Webhook secret token must match [A-Za-z0-9_-]'); } - const secretHeaderToken = String(params.secretHeaderToken ?? '').trim(); - if (!secretHeaderToken) { - throw new Error('Webhook header secret token is required'); - } - if (secretHeaderToken.length > MAX_WEBHOOK_SECRET_TOKEN_LENGTH) { - throw new Error('Webhook header secret token is too long'); - } - const host = String(params.host ?? '127.0.0.1').trim() || '127.0.0.1'; if (!isLoopbackHostname(host)) { throw new Error('Webhook host must be loopback-only'); @@ -62,37 +53,52 @@ export async function startTelegramWebhookRelay(params: Readonly<{ if (requestedPort < 0 || requestedPort > 65_535) { throw new Error('Webhook port must be between 0 and 65535'); } - const path = `/telegram/webhook/${secretPathToken}`; - const redactedPath = '/telegram/webhook/*'; + const path = '/telegram/webhook'; + const redactedPath = path; - const app = fastify({ logger: false }); - app.post(path, async (request, reply) => { - const providedHeader = request.headers['x-telegram-bot-api-secret-token']; - const providedToken = - typeof providedHeader === 'string' - ? providedHeader - : Array.isArray(providedHeader) && providedHeader.length > 0 - ? providedHeader[0] ?? '' - : ''; - const trimmedProvidedToken = providedToken.trim(); - if (trimmedProvidedToken.length > MAX_WEBHOOK_SECRET_TOKEN_LENGTH) { - return reply.status(431).send({ ok: false, error: 'Request Header Fields Too Large' }); - } - if (!secureCompareToken(trimmedProvidedToken, secretHeaderToken)) { - return reply.status(401).send({ ok: false, error: 'Unauthorized' }); - } + const app = fastify({ logger: false, bodyLimit: 1_000_000 }); + app.route({ + method: 'POST', + url: path, + onRequest: async (request, reply) => { + const providedHeader = request.headers['x-telegram-bot-api-secret-token']; + const providedToken = + typeof providedHeader === 'string' + ? providedHeader + : Array.isArray(providedHeader) && providedHeader.length > 0 + ? providedHeader[0] ?? '' + : ''; + const trimmedProvidedToken = providedToken.trim(); + if (trimmedProvidedToken.length > MAX_WEBHOOK_SECRET_TOKEN_LENGTH) { + return reply.status(431).send({ ok: false, error: 'Request Header Fields Too Large' }); + } + if (!secureCompareToken(trimmedProvidedToken, secretToken)) { + return reply.status(401).send({ ok: false, error: 'Unauthorized' }); + } + return undefined; + }, + handler: async (request, reply) => { + const providedHeader = request.headers['x-telegram-bot-api-secret-token']; + const providedToken = + typeof providedHeader === 'string' + ? providedHeader + : Array.isArray(providedHeader) && providedHeader.length > 0 + ? providedHeader[0] ?? '' + : ''; + const trimmedProvidedToken = providedToken.trim(); - try { - await params.onUpdate(request.body); - return reply.send({ ok: true }); - } catch (error) { - logger.warn('[TELEGRAM_WEBHOOK_RELAY] Failed to process inbound webhook update', { - path: redactedPath, - tokenLength: trimmedProvidedToken.length, - error: error instanceof Error ? error.message : String(error), - }); - return reply.status(500).send({ ok: false, error: 'Internal Server Error' }); - } + try { + await params.onUpdate(request.body); + return reply.send({ ok: true }); + } catch (error) { + logger.warn('[TELEGRAM_WEBHOOK_RELAY] Failed to process inbound webhook update', { + path: redactedPath, + tokenLength: trimmedProvidedToken.length, + error: error instanceof Error ? error.message : String(error), + }); + return reply.status(500).send({ ok: false, error: 'Internal Server Error' }); + } + }, }); await app.listen({ port: requestedPort, host }); diff --git a/apps/cli/src/channels/runtime/startChannelBridgeRuntime.ts b/apps/cli/src/channels/runtime/startChannelBridgeRuntime.ts index a1f1f8f08e..4b05215671 100644 --- a/apps/cli/src/channels/runtime/startChannelBridgeRuntime.ts +++ b/apps/cli/src/channels/runtime/startChannelBridgeRuntime.ts @@ -7,6 +7,7 @@ import type { import { startChannelBridgeWorker } from '@/channels/core/channelBridgeWorker'; import type { ChannelBridgeRuntimeConfig } from '@/channels/channelBridgeConfig'; import { listChannelBridgeProviderIds, resolveChannelBridgeProviderDefinition } from '@/channels/providers/_registry/channelBridgeProviderRegistry'; +import type { ChannelBridgeProviderRuntimeContext } from '@/channels/providers/_registry/types'; import { serializeAxiosErrorForLog } from '@/api/client/serializeAxiosErrorForLog'; import { logger } from '@/ui/logger'; @@ -14,6 +15,7 @@ export type ChannelBridgeRuntimeHandle = ChannelBridgeWorkerHandle; async function startProviderRuntimes(params: Readonly<{ runtimeConfig: ChannelBridgeRuntimeConfig; + context: ChannelBridgeProviderRuntimeContext; }>): Promise Promise }>> { const adapters: ChannelBridgeAdapter[] = []; const stopFns: Array<() => Promise> = []; @@ -23,7 +25,7 @@ async function startProviderRuntimes(params: Readonly<{ const providerConfig = provider.readConfig(params.runtimeConfig); let runtime: Awaited> | null; try { - runtime = await provider.createRuntime({ config: providerConfig }); + runtime = await provider.createRuntime({ config: providerConfig, context: params.context }); } catch (error) { logger.warn( `[channelBridge] Failed to start provider runtime (provider=${providerId}); skipping provider`, @@ -54,8 +56,9 @@ export async function startChannelBridgeRuntime(params: Readonly<{ store: ChannelBindingStore; deps: ChannelBridgeDeps; runtimeConfig: ChannelBridgeRuntimeConfig; + context: ChannelBridgeProviderRuntimeContext; }>): Promise { - const providers = await startProviderRuntimes({ runtimeConfig: params.runtimeConfig }); + const providers = await startProviderRuntimes({ runtimeConfig: params.runtimeConfig, context: params.context }); if (providers.adapters.length === 0) return null; let worker: ChannelBridgeWorkerHandle; diff --git a/apps/cli/src/channels/startChannelBridgeWorker.ts b/apps/cli/src/channels/startChannelBridgeWorker.ts index 774bd0f71e..23aeee277f 100644 --- a/apps/cli/src/channels/startChannelBridgeWorker.ts +++ b/apps/cli/src/channels/startChannelBridgeWorker.ts @@ -418,6 +418,10 @@ export async function startChannelBridgeFromEnv(params: Readonly<{ store, deps, runtimeConfig, + context: { + serverId: typeof params.serverId === 'string' ? params.serverId.trim() || null : null, + accountId: typeof params.accountId === 'string' ? params.accountId.trim() || null : null, + }, }); if (!worker) { diff --git a/docs/telegram-channel-bridge.md b/docs/telegram-channel-bridge.md index 76154a28c9..c682dd4256 100644 --- a/docs/telegram-channel-bridge.md +++ b/docs/telegram-channel-bridge.md @@ -166,7 +166,7 @@ happier daemon stop && happier daemon start Telegram bridge example: - if Telegram bridge is configured but bot token is missing, doctor prints a red bridge error and the final line is `❌` -- if `webhook.enabled=true` and `secrets.webhookSecret` (or `HAPPIER_TELEGRAM_WEBHOOK_SECRET`) is empty, doctor prints a warning and the daemon will fall back to polling +- if `webhook.enabled=true` and `secrets.webhookSecret` (or `HAPPIER_TELEGRAM_WEBHOOK_SECRET`) is empty, doctor prints a critical bridge error and the final line is `❌` ## Webhook Setup (daemon relay mode) @@ -183,10 +183,9 @@ HAPPIER_TELEGRAM_WEBHOOK_PORT=8787 `HAPPIER_TELEGRAM_WEBHOOK_SECRET` is currently used for both: -- webhook URL path token (`/telegram/webhook/`) - Telegram `secret_token` header validation -This is an implementation limitation today (single configured secret) and is weaker than using separate secrets for path and header checks. +This is an implementation limitation today (single configured secret). The webhook endpoint path does not include any secrets. Important: @@ -197,24 +196,23 @@ Important: Expose/proxy this daemon endpoint publicly: ```text -POST /telegram/webhook/ +POST /telegram/webhook ``` Example: ```text -https://your-public-host/telegram/webhook/ - -> http://127.0.0.1:8787/telegram/webhook/ +https://your-public-host/telegram/webhook + -> http://127.0.0.1:8787/telegram/webhook ``` Set Telegram webhook: ```bash -curl "https://api.telegram.org/bot/setWebhook?url=https://your-public-host/telegram/webhook/&secret_token=" +curl "https://api.telegram.org/bot/setWebhook?url=https://your-public-host/telegram/webhook&secret_token=" ``` -Current implementation requires the same `` value for both webhook URL path token and `secret_token`. -Use a high-entropy random value and avoid sharing/logging webhook URLs. +Use a high-entropy random value and avoid sharing/logging webhook secrets. If you switch back to polling mode, clear webhook first: From d33abc357eccd87086bcd90fb0f375edd9b5bf36 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Fri, 27 Mar 2026 19:21:27 +0100 Subject: [PATCH 67/81] refactor(channel-bridges): remove unused shared update helpers --- .../channelBridgeAccountConfig.test.ts | 59 ------------- .../channels/channelBridgeAccountConfig.ts | 86 ------------------- apps/cli/src/cli/commands/bridge.ts | 4 +- 3 files changed, 1 insertion(+), 148 deletions(-) diff --git a/apps/cli/src/channels/channelBridgeAccountConfig.test.ts b/apps/cli/src/channels/channelBridgeAccountConfig.test.ts index a30d057411..e655545ab2 100644 --- a/apps/cli/src/channels/channelBridgeAccountConfig.test.ts +++ b/apps/cli/src/channels/channelBridgeAccountConfig.test.ts @@ -1,10 +1,8 @@ import { describe, expect, it } from 'vitest'; import { - hasSharedTelegramBridgeUpdate, readScopedTelegramBridgeConfig, removeScopedTelegramBridgeConfig, - splitScopedTelegramBridgeUpdate, upsertScopedTelegramBridgeConfig, } from './channelBridgeAccountConfig'; @@ -66,42 +64,6 @@ describe('channelBridgeAccountConfig', () => { expect((next as any).channelBridge.byServerId['local-3005'].byAccountId['acct-1'].providers.telegram.webhook.secret).toBeUndefined(); }); - it('splits local secret updates from shared update payload', () => { - const split = splitScopedTelegramBridgeUpdate({ - update: { - botToken: 'bot-token', - webhookSecret: 'secret-1', - allowedChatIds: ['-100111'], - requireTopics: true, - }, - }); - - expect(split.localUpdate).toEqual({ - botToken: 'bot-token', - webhookSecret: 'secret-1', - allowedChatIds: ['-100111'], - requireTopics: true, - }); - expect(split.sharedUpdate).toEqual({ - allowedChatIds: ['-100111'], - requireTopics: true, - }); - expect(hasSharedTelegramBridgeUpdate({ update: split.sharedUpdate })).toBe(true); - }); - - it('does not alias local and shared allowedChatIds arrays', () => { - const split = splitScopedTelegramBridgeUpdate({ - update: { - allowedChatIds: ['-100111'], - }, - }); - - split.localUpdate.allowedChatIds?.push('-100222'); - - expect(split.localUpdate.allowedChatIds).toEqual(['-100111', '-100222']); - expect(split.sharedUpdate.allowedChatIds).toEqual(['-100111']); - }); - it('normalizes allowedChatIds when writing scoped config', () => { const next = upsertScopedTelegramBridgeConfig({ settings: {}, @@ -142,27 +104,6 @@ describe('channelBridgeAccountConfig', () => { expect(telegram).toBeNull(); }); - it('reports no shared update when only secrets are provided', () => { - const split = splitScopedTelegramBridgeUpdate({ - update: { - botToken: 'bot-token', - webhookSecret: 'secret-1', - }, - }); - - expect(split.sharedUpdate).toEqual({}); - expect(hasSharedTelegramBridgeUpdate({ update: split.sharedUpdate })).toBe(false); - }); - - it('ignores undefined-valued shared fields when checking for shared updates', () => { - const update = { - webhookPort: undefined, - } as unknown as Parameters[0]['update']; - - expect(hasSharedTelegramBridgeUpdate({ update })).toBe(false); - expect(hasSharedTelegramBridgeUpdate({ update: { allowedChatIds: [] } })).toBe(true); - }); - it('preserves webhook secret when only webhook host/port settings are updated', () => { const configured = upsertScopedTelegramBridgeConfig({ settings: {}, diff --git a/apps/cli/src/channels/channelBridgeAccountConfig.ts b/apps/cli/src/channels/channelBridgeAccountConfig.ts index 3e94aa43ed..25645a9134 100644 --- a/apps/cli/src/channels/channelBridgeAccountConfig.ts +++ b/apps/cli/src/channels/channelBridgeAccountConfig.ts @@ -68,92 +68,6 @@ export type ScopedTelegramBridgeUpdate = Readonly<{ webhookPort?: number; }>; -export type SharedTelegramBridgeUpdate = Readonly<{ - tickMs?: number; - allowedChatIds?: string[]; - allowAllSharedChats?: boolean; - requireTopics?: boolean; - webhookEnabled?: boolean; - webhookHost?: string; - webhookPort?: number; -}>; - -function hasAnyUpdateValue(update: Record): boolean { - return Object.values(update).some((value) => value !== undefined); -} - -export function splitScopedTelegramBridgeUpdate(params: Readonly<{ - update: ScopedTelegramBridgeUpdate; -}>): Readonly<{ localUpdate: ScopedTelegramBridgeUpdate; sharedUpdate: SharedTelegramBridgeUpdate }> { - const source = params.update; - const localUpdate: { - tickMs?: number; - botToken?: string; - allowedChatIds?: string[]; - allowAllSharedChats?: boolean; - requireTopics?: boolean; - webhookEnabled?: boolean; - webhookSecret?: string; - webhookHost?: string; - webhookPort?: number; - } = {}; - const sharedUpdate: { - tickMs?: number; - allowedChatIds?: string[]; - allowAllSharedChats?: boolean; - requireTopics?: boolean; - webhookEnabled?: boolean; - webhookHost?: string; - webhookPort?: number; - } = {}; - - if (typeof source.tickMs === 'number' && Number.isFinite(source.tickMs)) { - const value = Math.trunc(source.tickMs); - localUpdate.tickMs = value; - sharedUpdate.tickMs = value; - } - if (typeof source.botToken === 'string') { - localUpdate.botToken = source.botToken; - } - if (Array.isArray(source.allowedChatIds)) { - localUpdate.allowedChatIds = [...source.allowedChatIds]; - sharedUpdate.allowedChatIds = [...source.allowedChatIds]; - } - if (typeof source.allowAllSharedChats === 'boolean') { - localUpdate.allowAllSharedChats = source.allowAllSharedChats; - sharedUpdate.allowAllSharedChats = source.allowAllSharedChats; - } - if (typeof source.requireTopics === 'boolean') { - localUpdate.requireTopics = source.requireTopics; - sharedUpdate.requireTopics = source.requireTopics; - } - if (typeof source.webhookEnabled === 'boolean') { - localUpdate.webhookEnabled = source.webhookEnabled; - sharedUpdate.webhookEnabled = source.webhookEnabled; - } - if (typeof source.webhookSecret === 'string') { - localUpdate.webhookSecret = source.webhookSecret; - } - if (typeof source.webhookHost === 'string') { - localUpdate.webhookHost = source.webhookHost; - sharedUpdate.webhookHost = source.webhookHost; - } - if (typeof source.webhookPort === 'number' && Number.isFinite(source.webhookPort)) { - const value = Math.trunc(source.webhookPort); - localUpdate.webhookPort = value; - sharedUpdate.webhookPort = value; - } - - return { - localUpdate, - sharedUpdate, - }; -} - -export function hasSharedTelegramBridgeUpdate(params: Readonly<{ update: SharedTelegramBridgeUpdate }>): boolean { - return hasAnyUpdateValue(params.update as Record); -} - export function readScopedTelegramBridgeConfig(params: Readonly<{ settings: unknown; serverId: string; diff --git a/apps/cli/src/cli/commands/bridge.ts b/apps/cli/src/cli/commands/bridge.ts index 9e9915a93e..2b549b9ff7 100644 --- a/apps/cli/src/cli/commands/bridge.ts +++ b/apps/cli/src/cli/commands/bridge.ts @@ -7,7 +7,6 @@ import { checkIfDaemonRunningAndCleanupStaleState } from '@/daemon/controlClient import { readScopedTelegramBridgeConfig, removeScopedTelegramBridgeConfig, - splitScopedTelegramBridgeUpdate, upsertScopedTelegramBridgeConfig, } from '@/channels/channelBridgeAccountConfig'; import { resolveChannelBridgeRuntimeConfig } from '@/channels/channelBridgeConfig'; @@ -299,14 +298,13 @@ async function cmdTelegramSet(args: string[]): Promise { ); } - const split = splitScopedTelegramBridgeUpdate({ update }); await updateSettings(async (current) => ensureExperimentalSettingsFeatureToggleEnabled({ settings: upsertScopedTelegramBridgeConfig({ settings: current, serverId, accountId, - update: split.localUpdate, + update, }), featureId: 'channelBridges', }), From 1a5bd586fac17c1f6ca6c07277ad9a46e63e93de Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Fri, 27 Mar 2026 19:23:10 +0100 Subject: [PATCH 68/81] fix(ui): hide channel bridges entry until decision resolves --- apps/ui/sources/components/settings/SettingsView.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/ui/sources/components/settings/SettingsView.tsx b/apps/ui/sources/components/settings/SettingsView.tsx index d983e8976d..0f1cddcbf8 100644 --- a/apps/ui/sources/components/settings/SettingsView.tsx +++ b/apps/ui/sources/components/settings/SettingsView.tsx @@ -69,7 +69,8 @@ export const SettingsView = React.memo(function SettingsView() { const automationsNeedLocalEnablement = automationsSupport?.blockedBy === 'local_policy'; const channelBridgesDecision = useFeatureDecision('channelBridges'); const showChannelBridges = getFeatureBuildPolicyDecision('channelBridges') !== 'deny' - && channelBridgesDecision?.state !== 'unsupported'; + && channelBridgesDecision !== null + && channelBridgesDecision.state !== 'unsupported'; const channelBridgesNeedLocalEnablement = channelBridgesDecision?.blockedBy === 'local_policy'; const profile = useProfile(); const displayName = getDisplayName(profile); From a0a0514a495df100aa974f55f754ffca329864ac Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Fri, 27 Mar 2026 21:20:00 +0100 Subject: [PATCH 69/81] fix(channelBridges): preserve class adapter methods Avoid object-spread wrappers that drop prototype methods; ensure binding directories are account-scoped and chmod 0700. --- .../channelBridgeWorker.classAdapter.test.ts | 67 +++++++++++++++++++ .../src/channels/core/channelBridgeWorker.ts | 26 +++++-- .../channels/state/localBindingStore.test.ts | 33 +++++++++ .../src/channels/state/localBindingStore.ts | 13 +++- 4 files changed, 131 insertions(+), 8 deletions(-) create mode 100644 apps/cli/src/channels/core/channelBridgeWorker.classAdapter.test.ts diff --git a/apps/cli/src/channels/core/channelBridgeWorker.classAdapter.test.ts b/apps/cli/src/channels/core/channelBridgeWorker.classAdapter.test.ts new file mode 100644 index 0000000000..74cf00385a --- /dev/null +++ b/apps/cli/src/channels/core/channelBridgeWorker.classAdapter.test.ts @@ -0,0 +1,67 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createInMemoryChannelBindingStore, startChannelBridgeWorker } from './channelBridgeWorker'; + +describe('startChannelBridgeWorker (class-based adapters)', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('does not drop prototype methods when wrapping adapters for single-flight pull', async () => { + class PrototypeAdapter { + providerId = 'proto'; + sent: Array> = []; + + async pullInboundMessages() { + return []; + } + + async sendMessage(params: Readonly<{ conversationId: string; threadId: string | null; text: string }>) { + this.sent.push(params); + } + } + + const adapter = new PrototypeAdapter(); + const store = createInMemoryChannelBindingStore(); + await store.upsertBinding({ + providerId: 'proto', + conversationId: 'conv-1', + threadId: null, + sessionId: 'sess-1', + lastForwardedSeq: 0, + ownerSenderId: 'owner-1', + inboundMode: 'ownerOnly', + allowMissingSenderId: false, + }); + + const onWarning = vi.fn(); + + const worker = startChannelBridgeWorker({ + store, + adapters: [adapter], + deps: { + listSessions: async () => [], + resolveSessionIdOrPrefix: async () => ({ ok: false, code: 'unsupported' }), + resolveLatestSessionSeq: async () => 0, + fetchAgentMessagesAfterSeq: async ({ afterSeq }) => { + if (afterSeq >= 1) return { messages: [], highestSeenSeq: afterSeq }; + return { + messages: [{ seq: 1, text: 'hello from agent' }], + highestSeenSeq: 1, + }; + }, + sendUserMessageToSession: async () => {}, + onWarning, + }, + tickMs: 250, + }); + + await worker.stop(); + + expect(onWarning).not.toHaveBeenCalled(); + expect(adapter.sent).toEqual([ + { conversationId: 'conv-1', threadId: null, text: 'hello from agent' }, + ]); + }); +}); + diff --git a/apps/cli/src/channels/core/channelBridgeWorker.ts b/apps/cli/src/channels/core/channelBridgeWorker.ts index d66ba66be0..fa8440ede3 100644 --- a/apps/cli/src/channels/core/channelBridgeWorker.ts +++ b/apps/cli/src/channels/core/channelBridgeWorker.ts @@ -1420,9 +1420,8 @@ export function startChannelBridgeWorker(params: Readonly<{ const warnedMissingAdapterBindings = new Set(); const warnedAdapterPullFailures = new Set(); const adapterPullSingleFlightByAdapter = new WeakMap>(); - const adaptersForTick: ChannelBridgeAdapter[] = params.adapters.map((adapter) => ({ - ...adapter, - pullInboundMessages: () => { + const adaptersForTick: ChannelBridgeAdapter[] = params.adapters.map((adapter) => { + const pullInboundMessages = (): Promise => { const existing = adapterPullSingleFlightByAdapter.get(adapter); if (existing) { return existing; @@ -1439,8 +1438,25 @@ export function startChannelBridgeWorker(params: Readonly<{ adapterPullSingleFlightByAdapter.set(adapter, inflight); return inflight; - }, - })); + }; + + const wrapped: ChannelBridgeAdapter = { + providerId: adapter.providerId, + pullInboundMessages, + sendMessage: async (params) => { + await adapter.sendMessage(params); + }, + ...(adapter.ackInboundMessages + ? { + ackInboundMessages: async (messages) => { + await adapter.ackInboundMessages!(messages); + }, + } + : {}), + }; + + return wrapped; + }); let inFlightTick: Promise | null = null; const runTick = async (): Promise => { diff --git a/apps/cli/src/channels/state/localBindingStore.test.ts b/apps/cli/src/channels/state/localBindingStore.test.ts index b1c5587e04..0fcfd495d8 100644 --- a/apps/cli/src/channels/state/localBindingStore.test.ts +++ b/apps/cli/src/channels/state/localBindingStore.test.ts @@ -1,4 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; +import { stat } from 'node:fs/promises'; +import { join } from 'node:path'; import { createEnvKeyScope } from '@/testkit/env/envScope'; import { withTempDir } from '@/testkit/fs/tempDir'; @@ -94,4 +96,35 @@ describe('createLocalChannelBindingStore', () => { ); }); }); + + it('creates account-scoped binding directories with 0700 permissions on unix', async () => { + if (process.platform === 'win32') return; + + await withTempDir('happier-channel-bridge-bindings-perms-', async (homeDir) => { + envScope.patch({ HAPPIER_HOME_DIR: homeDir }); + vi.resetModules(); + + const { configuration } = await import('@/configuration'); + const { createLocalChannelBindingStore } = await import('./localBindingStore'); + + const store = createLocalChannelBindingStore({ + accountId: 'acct-1', + }); + + await store.upsertBinding({ + providerId: 'telegram', + conversationId: '-1001', + threadId: null, + sessionId: 'sess-1', + lastForwardedSeq: 0, + ownerSenderId: 'user-1', + inboundMode: 'ownerOnly', + allowMissingSenderId: false, + }); + + const accountDir = join(configuration.activeServerDir, 'channel-bridges', 'v1', 'account', 'acct-1'); + const perms = (await stat(accountDir)).mode & 0o777; + expect(perms).toBe(0o700); + }); + }); }); diff --git a/apps/cli/src/channels/state/localBindingStore.ts b/apps/cli/src/channels/state/localBindingStore.ts index d08fdd1078..5562c318bf 100644 --- a/apps/cli/src/channels/state/localBindingStore.ts +++ b/apps/cli/src/channels/state/localBindingStore.ts @@ -195,7 +195,12 @@ export function createLocalChannelBindingStore(params: Readonly<{ } async function persist(bindings: readonly ChannelSessionBinding[]): Promise { - await mkdir(join(configuration.activeServerDir, 'channel-bridges', 'v1'), { recursive: true, mode: 0o700 }); + const channelBridgesDir = join(configuration.activeServerDir, 'channel-bridges'); + const v1Dir = join(channelBridgesDir, 'v1'); + const accountsDir = join(v1Dir, 'account'); + const accountDir = join(accountsDir, accountId); + + await mkdir(accountDir, { recursive: true, mode: 0o700 }); await bestEffortChmod0700(configuration.activeServerDir); const doc: StoredBindingsDocV1 = { @@ -214,8 +219,10 @@ export function createLocalChannelBindingStore(params: Readonly<{ })), }; await writeJsonAtomic(bindingsFile, doc); - await bestEffortChmod0700(join(configuration.activeServerDir, 'channel-bridges')); - await bestEffortChmod0700(join(configuration.activeServerDir, 'channel-bridges', 'v1')); + await bestEffortChmod0700(channelBridgesDir); + await bestEffortChmod0700(v1Dir); + await bestEffortChmod0700(accountsDir); + await bestEffortChmod0700(accountDir); } function setCache(bindings: readonly ChannelSessionBinding[]): void { From dfa1f517f774457833e46a1fa6f9f115ebcaab96 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Fri, 27 Mar 2026 21:20:06 +0100 Subject: [PATCH 70/81] fix(ui): harden Channel Bridges navigation Use pushRoute (blur+defer on web) and align docs with fixed webhook path + feature gating. --- .../SettingsView.serversEntry.test.tsx | 54 ++++++++++++++++++- .../components/settings/SettingsView.tsx | 2 +- docs/channel-bridge-uat.md | 2 +- docs/telegram-channel-bridge.md | 29 +++++----- 4 files changed, 72 insertions(+), 15 deletions(-) diff --git a/apps/ui/sources/components/settings/SettingsView.serversEntry.test.tsx b/apps/ui/sources/components/settings/SettingsView.serversEntry.test.tsx index d7261d7a8d..c0255e208a 100644 --- a/apps/ui/sources/components/settings/SettingsView.serversEntry.test.tsx +++ b/apps/ui/sources/components/settings/SettingsView.serversEntry.test.tsx @@ -223,7 +223,18 @@ vi.mock('@/hooks/server/useFeatureEnabled', () => ({ })); vi.mock('@/hooks/server/useFeatureDecision', () => ({ - useFeatureDecision: () => null, + useFeatureDecision: (featureId: string) => { + if (featureId !== 'channelBridges') return null; + return { + featureId: 'channelBridges', + state: 'enabled', + blockedBy: null, + blockerCode: 'none', + diagnostics: [], + evaluatedAt: 0, + scope: { scopeKind: 'runtime' }, + }; + }, })); vi.mock('@/sync/domains/server/serverProfiles', () => ({ @@ -296,6 +307,47 @@ describe('SettingsView', () => { expect(shared.routerPushSpy).toHaveBeenCalledWith('/(app)/settings/features'); }); + it('blurs the active element before routing to Channel Bridges on web', async () => { + const previousBuildAllows = process.env.EXPO_PUBLIC_HAPPIER_BUILD_FEATURES_ALLOW; + const previousBuildDenies = process.env.EXPO_PUBLIC_HAPPIER_BUILD_FEATURES_DENY; + const previousEmbeddedPolicyEnv = process.env.EXPO_PUBLIC_HAPPIER_FEATURE_POLICY_ENV; + process.env.EXPO_PUBLIC_HAPPIER_BUILD_FEATURES_ALLOW = 'channelBridges'; + delete process.env.EXPO_PUBLIC_HAPPIER_BUILD_FEATURES_DENY; + delete process.env.EXPO_PUBLIC_HAPPIER_FEATURE_POLICY_ENV; + + try { + vi.resetModules(); + const { SettingsView } = await import('./SettingsView'); + const screen = await renderSettingsView(React.createElement(SettingsView)); + + expect(screen.findRowByTitle('settings.channelBridges')).toBeTruthy(); + + await act(async () => { + screen.pressRowByTitle('settings.channelBridges'); + }); + + expect(shared.deferOnWebSpy).toHaveBeenCalled(); + expect(shared.navigateWithBlurOnWebSpy).toHaveBeenCalled(); + expect(shared.routerPushSpy).toHaveBeenCalledWith('/(app)/settings/channel-bridges'); + } finally { + if (typeof previousBuildAllows === 'string') { + process.env.EXPO_PUBLIC_HAPPIER_BUILD_FEATURES_ALLOW = previousBuildAllows; + } else { + delete process.env.EXPO_PUBLIC_HAPPIER_BUILD_FEATURES_ALLOW; + } + if (typeof previousBuildDenies === 'string') { + process.env.EXPO_PUBLIC_HAPPIER_BUILD_FEATURES_DENY = previousBuildDenies; + } else { + delete process.env.EXPO_PUBLIC_HAPPIER_BUILD_FEATURES_DENY; + } + if (typeof previousEmbeddedPolicyEnv === 'string') { + process.env.EXPO_PUBLIC_HAPPIER_FEATURE_POLICY_ENV = previousEmbeddedPolicyEnv; + } else { + delete process.env.EXPO_PUBLIC_HAPPIER_FEATURE_POLICY_ENV; + } + } + }); + it('routes to the in-app bug report composer by default when Report issue is pressed', async () => { const { SettingsView } = await import('./SettingsView'); const screen = await renderSettingsView(React.createElement(SettingsView)); diff --git a/apps/ui/sources/components/settings/SettingsView.tsx b/apps/ui/sources/components/settings/SettingsView.tsx index 0f1cddcbf8..591a7972f6 100644 --- a/apps/ui/sources/components/settings/SettingsView.tsx +++ b/apps/ui/sources/components/settings/SettingsView.tsx @@ -415,7 +415,7 @@ export const SettingsView = React.memo(function SettingsView() { ? t('settingsFeatures.expChannelBridgesSubtitle') : t('settings.channelBridgesSubtitle')} icon={} - onPress={() => router.push(channelBridgesNeedLocalEnablement ? '/(app)/settings/features' : '/(app)/settings/channel-bridges')} + onPress={() => pushRoute(channelBridgesNeedLocalEnablement ? '/(app)/settings/features' : '/(app)/settings/channel-bridges')} /> ) : null} {mcpServersEnabled && ( diff --git a/docs/channel-bridge-uat.md b/docs/channel-bridge-uat.md index 410b420fe3..716cdafd75 100644 --- a/docs/channel-bridge-uat.md +++ b/docs/channel-bridge-uat.md @@ -91,7 +91,7 @@ Expected: - Set `allowedChatIds` to a different chat and verify current chat is blocked. - Revert to `--allow-all` and verify chat works again. - If using webhook mode, verify secret mismatch returns a non-200 response: - - `404` when URL path token is wrong (route mismatch) + - `404` when the URL path is wrong (route mismatch) - `401` when `X-Telegram-Bot-Api-Secret-Token` is missing/invalid ## Notes diff --git a/docs/telegram-channel-bridge.md b/docs/telegram-channel-bridge.md index c682dd4256..a23c3e7940 100644 --- a/docs/telegram-channel-bridge.md +++ b/docs/telegram-channel-bridge.md @@ -72,7 +72,9 @@ Precedence is: 1. `HAPPIER_*` environment variables 2. `settings.json` bridge config (local, scoped by `serverId` + `accountId`, includes secrets) -3. built-in defaults +3. `settings.json` bridge config (local, scoped by `serverId`) +4. `settings.json` bridge config (local, global defaults) +5. built-in defaults `allowedChatIds` behavior: @@ -104,6 +106,16 @@ For new adapters (Discord/Slack/WhatsApp/etc), use the same model: } ``` +## Feature gating (experimental) + +Channel bridges are gated in three places: + +- Server feature gates: `channelBridges` and `channelBridges.telegram` must be enabled in `/v1/features` (server env/build policy can hard-disable them). +- Local experimental opt-in: users must enable the `Channel bridges` experimental toggle in Settings → Features → Experimental options. +- Daemon runtime: the daemon starts the worker only when both the server gates are enabled and the local toggle is enabled. + +Note: `happier bridge telegram set ...` automatically enables the local experimental toggle (`channelBridges`). + ## Environment Variables (`.env.local`) Set these to override `settings.json` or run env-only: @@ -181,17 +193,12 @@ HAPPIER_TELEGRAM_WEBHOOK_HOST=127.0.0.1 HAPPIER_TELEGRAM_WEBHOOK_PORT=8787 ``` -`HAPPIER_TELEGRAM_WEBHOOK_SECRET` is currently used for both: +`HAPPIER_TELEGRAM_WEBHOOK_SECRET` is used for Telegram `secret_token` header validation (`X-Telegram-Bot-Api-Secret-Token`). -- Telegram `secret_token` header validation +The webhook endpoint path is fixed (`POST /telegram/webhook`) and does not include any secrets. -This is an implementation limitation today (single configured secret). The webhook endpoint path does not include any secrets. - -Important: - -- `HAPPIER_SERVER_URL` is the Telegram callback target only when server-relay mode exists and is enabled (planned, not currently implemented). -- In the currently implemented daemon-relay mode, Telegram must call a public URL that forwards to daemon host/port/path. -- If you do not have inbound public endpoint/tunnel to daemon, use polling mode (`getUpdates`). +In daemon-relay mode, Telegram must call a public URL that forwards to the daemon relay (loopback-only). +If you do not have an inbound public endpoint/tunnel to the daemon, use polling mode (`getUpdates`). Expose/proxy this daemon endpoint publicly: @@ -220,8 +227,6 @@ If you switch back to polling mode, clear webhook first: curl "https://api.telegram.org/bot/deleteWebhook" ``` -For server-relay and standalone-relay planning details, see [docs/channel-bridge.md](./channel-bridge.md). - ## Telegram Commands (inside DM/topic) - `/help` — command list From 2f440b458c016b40855c4b69409ba0c88fc6329c Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Sat, 28 Mar 2026 13:49:17 +0100 Subject: [PATCH 71/81] fix(channel-bridges): harden config, worker authz, and Telegram provider --- .../channels/channelBridgeAccountConfig.ts | 21 +- .../src/channels/channelBridgeConfig.test.ts | 115 +++--- apps/cli/src/channels/channelBridgeConfig.ts | 145 ++----- .../channels/channelBridgeConfigParsing.ts | 78 ++++ .../channels/channelBridgeSettingsScopes.ts | 40 ++ .../channels/core/channelBridgeWorker.test.ts | 77 ++++ .../src/channels/core/channelBridgeWorker.ts | 44 ++- .../telegramChannelBridgeProvider.ts | 9 +- .../telegram/telegramAdapter.test.ts | 74 ++++ .../providers/telegram/telegramAdapter.ts | 374 +++++++++++++++--- .../telegram/telegramWebhookRelay.ts | 23 +- .../telegram/telegramWebhookSecretToken.ts | 28 ++ .../telegramWebhookUpdateStore.test.ts | 74 ++++ .../telegram/telegramWebhookUpdateStore.ts | 204 ++++++++++ apps/cli/src/cli/commands/bridge.ts | 15 +- .../resolveChannelBridgesDaemonEnabled.ts | 53 ++- .../ui/doctor/channelBridgesDoctor.test.ts | 38 ++ .../cli/src/ui/doctor/channelBridgesDoctor.ts | 29 +- 18 files changed, 1172 insertions(+), 269 deletions(-) create mode 100644 apps/cli/src/channels/channelBridgeConfigParsing.ts create mode 100644 apps/cli/src/channels/channelBridgeSettingsScopes.ts create mode 100644 apps/cli/src/channels/providers/telegram/telegramWebhookSecretToken.ts create mode 100644 apps/cli/src/channels/providers/telegram/telegramWebhookUpdateStore.test.ts create mode 100644 apps/cli/src/channels/providers/telegram/telegramWebhookUpdateStore.ts create mode 100644 apps/cli/src/ui/doctor/channelBridgesDoctor.test.ts diff --git a/apps/cli/src/channels/channelBridgeAccountConfig.ts b/apps/cli/src/channels/channelBridgeAccountConfig.ts index 25645a9134..6f2cd5ff26 100644 --- a/apps/cli/src/channels/channelBridgeAccountConfig.ts +++ b/apps/cli/src/channels/channelBridgeAccountConfig.ts @@ -8,6 +8,8 @@ * * Secrets remain local-only and are never emitted into shared payload helpers. */ +import { assertTelegramWebhookSecretToken } from '@/channels/providers/telegram/telegramWebhookSecretToken'; + type RecordLike = Record; function asRecord(value: unknown): RecordLike | null { @@ -43,19 +45,6 @@ function parseStringArray(value: unknown): string[] { .filter((entry) => entry.length > 0); } -const TELEGRAM_WEBHOOK_SECRET_TOKEN_PATTERN = /^[A-Za-z0-9_-]+$/; - -function assertTelegramWebhookSecretToken(value: string): string { - const token = value.trim(); - if (!token) { - throw new Error('Invalid webhookSecret: cannot be empty'); - } - if (!TELEGRAM_WEBHOOK_SECRET_TOKEN_PATTERN.test(token)) { - throw new Error('Invalid webhookSecret: must match [A-Za-z0-9_-]'); - } - return token; -} - export type ScopedTelegramBridgeUpdate = Readonly<{ tickMs?: number; botToken?: string; @@ -229,7 +218,11 @@ export function upsertScopedTelegramBridgeConfig(params: Readonly<{ } } if (typeof params.update.webhookSecret === 'string') { - secrets.webhookSecret = assertTelegramWebhookSecretToken(params.update.webhookSecret); + secrets.webhookSecret = assertTelegramWebhookSecretToken(params.update.webhookSecret, { + empty: 'Invalid webhookSecret: cannot be empty', + invalid: 'Invalid webhookSecret: must match [A-Za-z0-9_-]', + tooLong: 'Webhook secret token is too long', + }); const webhook = asRecord(telegram.webhook); if (webhook && 'secret' in webhook) { delete webhook.secret; diff --git a/apps/cli/src/channels/channelBridgeConfig.test.ts b/apps/cli/src/channels/channelBridgeConfig.test.ts index 428edbe138..fad43292b7 100644 --- a/apps/cli/src/channels/channelBridgeConfig.test.ts +++ b/apps/cli/src/channels/channelBridgeConfig.test.ts @@ -3,6 +3,33 @@ import { describe, expect, it } from 'vitest'; import { resolveChannelBridgeRuntimeConfig } from './channelBridgeConfig'; describe('resolveChannelBridgeRuntimeConfig', () => { + it('exposes provider configs under config.providers.', () => { + const config = resolveChannelBridgeRuntimeConfig({ + env: {}, + settings: { + channelBridge: { + tickMs: 3_100, + providers: { + telegram: { + botToken: 'settings-bot-token', + allowedChatIds: ['-100111', '-100222'], + requireTopics: true, + webhook: { + enabled: true, + secret: 'settings-secret', + host: '0.0.0.0', + port: 9_001, + }, + }, + }, + }, + }, + }); + + expect(config.tickMs).toBe(3_100); + expect((config as any).providers?.telegram).toBeTruthy(); + }); + it('uses settings.json bridge values when env is not set', () => { const config = resolveChannelBridgeRuntimeConfig({ env: {}, @@ -27,14 +54,14 @@ describe('resolveChannelBridgeRuntimeConfig', () => { }); expect(config.tickMs).toBe(3_100); - expect(config.telegram.botToken).toBe('settings-bot-token'); - expect(config.telegram.allowedChatIds).toEqual(['-100111', '-100222']); - expect(config.telegram.allowAllSharedChats).toBe(false); - expect(config.telegram.requireTopics).toBe(true); - expect(config.telegram.webhookEnabled).toBe(true); - expect(config.telegram.webhookSecret).toBe('settings-secret'); - expect(config.telegram.webhookHost).toBe('127.0.0.1'); - expect(config.telegram.webhookPort).toBe(9_001); + expect(config.providers.telegram.botToken).toBe('settings-bot-token'); + expect(config.providers.telegram.allowedChatIds).toEqual(['-100111', '-100222']); + expect(config.providers.telegram.allowAllSharedChats).toBe(false); + expect(config.providers.telegram.requireTopics).toBe(true); + expect(config.providers.telegram.webhookEnabled).toBe(true); + expect(config.providers.telegram.webhookSecret).toBe('settings-secret'); + expect(config.providers.telegram.webhookHost).toBe('127.0.0.1'); + expect(config.providers.telegram.webhookPort).toBe(9_001); }); it('reads secret fields from telegram.secrets local-only block', () => { @@ -61,11 +88,11 @@ describe('resolveChannelBridgeRuntimeConfig', () => { }, }); - expect(config.telegram.botToken).toBe('secret-bot-token'); - expect(config.telegram.webhookSecret).toBe('secret-webhook-token'); - expect(config.telegram.allowedChatIds).toEqual(['-100111']); - expect(config.telegram.allowAllSharedChats).toBe(false); - expect(config.telegram.requireTopics).toBe(true); + expect(config.providers.telegram.botToken).toBe('secret-bot-token'); + expect(config.providers.telegram.webhookSecret).toBe('secret-webhook-token'); + expect(config.providers.telegram.allowedChatIds).toEqual(['-100111']); + expect(config.providers.telegram.allowAllSharedChats).toBe(false); + expect(config.providers.telegram.requireTopics).toBe(true); }); it('applies env overrides and falls back to settings for invalid env webhook port', () => { @@ -101,14 +128,14 @@ describe('resolveChannelBridgeRuntimeConfig', () => { }); expect(config.tickMs).toBe(700); - expect(config.telegram.botToken).toBe('env-token'); - expect(config.telegram.allowedChatIds).toEqual(['-100333', '-100444']); - expect(config.telegram.allowAllSharedChats).toBe(false); - expect(config.telegram.requireTopics).toBe(false); - expect(config.telegram.webhookEnabled).toBe(true); - expect(config.telegram.webhookSecret).toBe('env-secret'); - expect(config.telegram.webhookHost).toBe('127.0.0.9'); - expect(config.telegram.webhookPort).toBe(9_001); + expect(config.providers.telegram.botToken).toBe('env-token'); + expect(config.providers.telegram.allowedChatIds).toEqual(['-100333', '-100444']); + expect(config.providers.telegram.allowAllSharedChats).toBe(false); + expect(config.providers.telegram.requireTopics).toBe(false); + expect(config.providers.telegram.webhookEnabled).toBe(true); + expect(config.providers.telegram.webhookSecret).toBe('env-secret'); + expect(config.providers.telegram.webhookHost).toBe('127.0.0.9'); + expect(config.providers.telegram.webhookPort).toBe(9_001); }); it('does not override settings bot token when env bot token is empty/whitespace', () => { @@ -127,7 +154,7 @@ describe('resolveChannelBridgeRuntimeConfig', () => { }, }); - expect(config.telegram.botToken).toBe('settings-token'); + expect(config.providers.telegram.botToken).toBe('settings-token'); }); it('applies a valid env webhook port override', () => { @@ -148,7 +175,7 @@ describe('resolveChannelBridgeRuntimeConfig', () => { }, }); - expect(config.telegram.webhookPort).toBe(8_877); + expect(config.providers.telegram.webhookPort).toBe(8_877); }); it('reads allowAllSharedChats from env', () => { @@ -159,7 +186,7 @@ describe('resolveChannelBridgeRuntimeConfig', () => { settings: {}, }); - expect(config.telegram.allowAllSharedChats).toBe(true); + expect(config.providers.telegram.allowAllSharedChats).toBe(true); }); it('falls back to settings allowedChatIds when env CSV is effectively empty', () => { @@ -178,7 +205,7 @@ describe('resolveChannelBridgeRuntimeConfig', () => { }, }); - expect(config.telegram.allowedChatIds).toEqual(['-100settings']); + expect(config.providers.telegram.allowedChatIds).toEqual(['-100settings']); }); it('falls back to lower scope when account allowedChatIds is malformed', () => { @@ -215,7 +242,7 @@ describe('resolveChannelBridgeRuntimeConfig', () => { }, }); - expect(config.telegram.allowedChatIds).toEqual(['-100-server']); + expect(config.providers.telegram.allowedChatIds).toEqual(['-100-server']); }); it('falls back to lower scope when account allowedChatIds string normalizes empty', () => { @@ -247,7 +274,7 @@ describe('resolveChannelBridgeRuntimeConfig', () => { }, }); - expect(config.telegram.allowedChatIds).toEqual(['-100-server']); + expect(config.providers.telegram.allowedChatIds).toEqual(['-100-server']); }); it('falls back to lower scope when higher-scope string secrets are blank', () => { @@ -293,8 +320,8 @@ describe('resolveChannelBridgeRuntimeConfig', () => { }, }); - expect(config.telegram.botToken).toBe('server-token'); - expect(config.telegram.webhookSecret).toBe('server-secret'); + expect(config.providers.telegram.botToken).toBe('server-token'); + expect(config.providers.telegram.webhookSecret).toBe('server-secret'); }); it('falls back to settings webhook secret when env secret token is invalid', () => { @@ -315,7 +342,7 @@ describe('resolveChannelBridgeRuntimeConfig', () => { }, }); - expect(config.telegram.webhookSecret).toBe('settings-secret'); + expect(config.providers.telegram.webhookSecret).toBe('settings-secret'); }); it('treats invalid settings webhook secret token as missing', () => { @@ -333,7 +360,7 @@ describe('resolveChannelBridgeRuntimeConfig', () => { }, }); - expect(config.telegram.webhookSecret).toBe(''); + expect(config.providers.telegram.webhookSecret).toBe(''); }); it('ignores non-loopback env webhook host and keeps settings host', () => { @@ -354,7 +381,7 @@ describe('resolveChannelBridgeRuntimeConfig', () => { }, }); - expect(config.telegram.webhookHost).toBe('127.0.0.1'); + expect(config.providers.telegram.webhookHost).toBe('127.0.0.1'); }); it('rejects webhook port zero and falls back to default', () => { @@ -375,7 +402,7 @@ describe('resolveChannelBridgeRuntimeConfig', () => { }, }); - expect(config.telegram.webhookPort).toBe(8_787); + expect(config.providers.telegram.webhookPort).toBe(8_787); }); it('resolves account-scoped bridge config with server/global fallback', () => { @@ -424,12 +451,12 @@ describe('resolveChannelBridgeRuntimeConfig', () => { }); expect(config.tickMs).toBe(1_800); - expect(config.telegram.botToken).toBe('account-token'); - expect(config.telegram.allowedChatIds).toEqual(['-100-server']); - expect(config.telegram.requireTopics).toBe(true); - expect(config.telegram.webhookEnabled).toBe(false); - expect(config.telegram.webhookHost).toBe('127.0.0.1'); - expect(config.telegram.webhookPort).toBe(8_787); + expect(config.providers.telegram.botToken).toBe('account-token'); + expect(config.providers.telegram.allowedChatIds).toEqual(['-100-server']); + expect(config.providers.telegram.requireTopics).toBe(true); + expect(config.providers.telegram.webhookEnabled).toBe(false); + expect(config.providers.telegram.webhookHost).toBe('127.0.0.1'); + expect(config.providers.telegram.webhookPort).toBe(8_787); }); it('falls back to global settings when scoped bridge config is missing', () => { @@ -452,9 +479,9 @@ describe('resolveChannelBridgeRuntimeConfig', () => { }); expect(config.tickMs).toBe(4_200); - expect(config.telegram.botToken).toBe('global-token'); - expect(config.telegram.allowedChatIds).toEqual([]); - expect(config.telegram.requireTopics).toBe(true); + expect(config.providers.telegram.botToken).toBe('global-token'); + expect(config.providers.telegram.allowedChatIds).toEqual([]); + expect(config.providers.telegram.requireTopics).toBe(true); }); it('keeps settings allowedChatIds when env override is empty', () => { @@ -473,7 +500,7 @@ describe('resolveChannelBridgeRuntimeConfig', () => { }, }); - expect(config.telegram.allowedChatIds).toEqual(['-100111']); + expect(config.providers.telegram.allowedChatIds).toEqual(['-100111']); }); it('accepts numeric allowedChatIds from settings arrays', () => { @@ -490,6 +517,6 @@ describe('resolveChannelBridgeRuntimeConfig', () => { }, }); - expect(config.telegram.allowedChatIds).toEqual(['-1001234567890']); + expect(config.providers.telegram.allowedChatIds).toEqual(['-1001234567890']); }); }); diff --git a/apps/cli/src/channels/channelBridgeConfig.ts b/apps/cli/src/channels/channelBridgeConfig.ts index 11d7ddf9a7..7d3f020118 100644 --- a/apps/cli/src/channels/channelBridgeConfig.ts +++ b/apps/cli/src/channels/channelBridgeConfig.ts @@ -1,91 +1,21 @@ import { isLoopbackHostname } from '@/server/serverUrlClassification'; +import { readTelegramWebhookSecretToken } from '@/channels/providers/telegram/telegramWebhookSecretToken'; +import { + asRecord, + firstParsed, + parseBoolean, + parseCsv, + parseInteger, + parseStrictInteger, + parseStringArray, + readTrimmedString, +} from '@/channels/channelBridgeConfigParsing'; +import { resolveChannelBridgeSettingsScopes } from '@/channels/channelBridgeSettingsScopes'; type RecordLike = Record; -function asRecord(value: unknown): RecordLike | null { - if (!value || typeof value !== 'object' || Array.isArray(value)) return null; - return value as RecordLike; -} - -function parseBoolean(value: unknown): boolean | null { - if (typeof value === 'boolean') return value; - if (typeof value !== 'string') return null; - const normalized = value.trim().toLowerCase(); - if (normalized.length === 0) return null; - if (['1', 'true', 'yes', 'on'].includes(normalized)) return true; - if (['0', 'false', 'no', 'off'].includes(normalized)) return false; - return null; -} - -function parseStrictInteger(raw: string, min: number, max: number): number | null { - const trimmed = raw.trim(); - if (!/^[-]?\d+$/.test(trimmed)) return null; - const parsed = Number.parseInt(trimmed, 10); - if (!Number.isFinite(parsed) || parsed < min || parsed > max) return null; - return Math.trunc(parsed); -} - -function parseInteger(value: unknown, min: number, max: number): number | null { - if (typeof value === 'number' && Number.isFinite(value)) { - const candidate = Math.trunc(value); - if (candidate < min || candidate > max) return null; - return candidate; - } - if (typeof value === 'string') { - return parseStrictInteger(value, min, max); - } - return null; -} - -function parseCsv(raw: string): string[] { - return raw - .split(',') - .map((item) => item.trim()) - .filter((item) => item.length > 0); -} - -function parseStringArray(value: unknown): string[] | null { - if (typeof value === 'string') { - const parsed = parseCsv(value); - return parsed.length > 0 ? parsed : null; - } - if (!Array.isArray(value)) return null; - const out = value - .map((entry) => { - if (typeof entry === 'string') return entry.trim(); - if (typeof entry === 'number' && Number.isFinite(entry)) return String(Math.trunc(entry)); - return ''; - }) - .filter((entry) => entry.length > 0); - if (out.length === 0 && value.length > 0) { - return null; - } - return out; -} - -function readTrimmedString(value: unknown): string | null { - if (typeof value !== 'string') return null; - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; -} - -const TELEGRAM_WEBHOOK_SECRET_TOKEN_PATTERN = /^[A-Za-z0-9_-]+$/; - function readWebhookSecretToken(value: unknown): string | null { - const token = readTrimmedString(value); - if (!token) return null; - if (!TELEGRAM_WEBHOOK_SECRET_TOKEN_PATTERN.test(token)) return null; - return token; -} - -function firstParsed(values: readonly unknown[], parse: (value: unknown) => T | null): T | null { - for (const value of values) { - const parsed = parse(value); - if (parsed !== null) { - return parsed; - } - } - return null; + return readTelegramWebhookSecretToken(value); } type TelegramChannelBridgeRuntimeConfig = Readonly<{ @@ -101,7 +31,9 @@ type TelegramChannelBridgeRuntimeConfig = Readonly<{ export type ChannelBridgeRuntimeConfig = Readonly<{ tickMs: number; - telegram: TelegramChannelBridgeRuntimeConfig; + providers: Readonly<{ + telegram: TelegramChannelBridgeRuntimeConfig; + }>; }>; export function resolveChannelBridgeRuntimeConfig(params: Readonly<{ @@ -111,23 +43,14 @@ export function resolveChannelBridgeRuntimeConfig(params: Readonly<{ accountId?: string | null; }>): ChannelBridgeRuntimeConfig { const env = params.env ?? process.env; - const settingsRoot = asRecord(params.settings); - const channelBridgeGlobal = asRecord(settingsRoot?.channelBridge); - - const byServerId = asRecord(channelBridgeGlobal?.byServerId); - const scopedServerId = readTrimmedString(params.serverId) ?? ''; - const scopedAccountId = readTrimmedString(params.accountId) ?? ''; - - const channelBridgeServer = - scopedServerId.length > 0 && byServerId - ? asRecord(byServerId[scopedServerId]) - : null; - - const byAccountId = asRecord(channelBridgeServer?.byAccountId); - const channelBridgeAccount = - scopedAccountId.length > 0 && byAccountId - ? asRecord(byAccountId[scopedAccountId]) - : null; + const scopes = resolveChannelBridgeSettingsScopes({ + settings: params.settings, + serverId: params.serverId, + accountId: params.accountId, + }); + const channelBridgeGlobal = scopes.channelBridgeGlobal; + const channelBridgeServer = scopes.channelBridgeServer; + const channelBridgeAccount = scopes.channelBridgeAccount; const providersGlobal = asRecord(channelBridgeGlobal?.providers); const providersServer = asRecord(channelBridgeServer?.providers); @@ -272,15 +195,17 @@ export function resolveChannelBridgeRuntimeConfig(params: Readonly<{ return { tickMs, - telegram: { - botToken, - allowedChatIds, - allowAllSharedChats, - requireTopics, - webhookEnabled, - webhookSecret, - webhookHost, - webhookPort, + providers: { + telegram: { + botToken, + allowedChatIds, + allowAllSharedChats, + requireTopics, + webhookEnabled, + webhookSecret, + webhookHost, + webhookPort, + }, }, }; } diff --git a/apps/cli/src/channels/channelBridgeConfigParsing.ts b/apps/cli/src/channels/channelBridgeConfigParsing.ts new file mode 100644 index 0000000000..db86b11e24 --- /dev/null +++ b/apps/cli/src/channels/channelBridgeConfigParsing.ts @@ -0,0 +1,78 @@ +type RecordLike = Record; + +export function asRecord(value: unknown): RecordLike | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + return value as RecordLike; +} + +export function parseBoolean(value: unknown): boolean | null { + if (typeof value === 'boolean') return value; + if (typeof value !== 'string') return null; + const normalized = value.trim().toLowerCase(); + if (normalized.length === 0) return null; + if (['1', 'true', 'yes', 'on'].includes(normalized)) return true; + if (['0', 'false', 'no', 'off'].includes(normalized)) return false; + return null; +} + +export function parseStrictInteger(raw: string, min: number, max: number): number | null { + const trimmed = raw.trim(); + if (!/^[-]?\d+$/.test(trimmed)) return null; + const parsed = Number.parseInt(trimmed, 10); + if (!Number.isFinite(parsed) || parsed < min || parsed > max) return null; + return Math.trunc(parsed); +} + +export function parseInteger(value: unknown, min: number, max: number): number | null { + if (typeof value === 'number' && Number.isFinite(value)) { + const candidate = Math.trunc(value); + if (candidate < min || candidate > max) return null; + return candidate; + } + if (typeof value === 'string') { + return parseStrictInteger(value, min, max); + } + return null; +} + +export function parseCsv(raw: string): string[] { + return raw + .split(',') + .map((item) => item.trim()) + .filter((item) => item.length > 0); +} + +export function parseStringArray(value: unknown): string[] | null { + if (typeof value === 'string') { + const parsed = parseCsv(value); + return parsed.length > 0 ? parsed : null; + } + if (!Array.isArray(value)) return null; + const out = value + .map((entry) => { + if (typeof entry === 'string') return entry.trim(); + if (typeof entry === 'number' && Number.isFinite(entry)) return String(Math.trunc(entry)); + return ''; + }) + .filter((entry) => entry.length > 0); + if (out.length === 0 && value.length > 0) { + return null; + } + return out; +} + +export function readTrimmedString(value: unknown): string | null { + if (typeof value !== 'string') return null; + const trimmed = value.trim(); + return trimmed.length > 0 ? trimmed : null; +} + +export function firstParsed(values: readonly unknown[], parse: (value: unknown) => T | null): T | null { + for (const value of values) { + const parsed = parse(value); + if (parsed !== null) { + return parsed; + } + } + return null; +} diff --git a/apps/cli/src/channels/channelBridgeSettingsScopes.ts b/apps/cli/src/channels/channelBridgeSettingsScopes.ts new file mode 100644 index 0000000000..dcce1128cd --- /dev/null +++ b/apps/cli/src/channels/channelBridgeSettingsScopes.ts @@ -0,0 +1,40 @@ +import { asRecord, readTrimmedString } from './channelBridgeConfigParsing'; + +type RecordLike = Record; + +export type ChannelBridgeSettingsScopes = Readonly<{ + channelBridgeGlobal: RecordLike | null; + channelBridgeServer: RecordLike | null; + channelBridgeAccount: RecordLike | null; +}>; + +export function resolveChannelBridgeSettingsScopes(params: Readonly<{ + settings: unknown; + serverId?: string | null; + accountId?: string | null; +}>): ChannelBridgeSettingsScopes { + const settingsRoot = asRecord(params.settings); + const channelBridgeGlobal = asRecord(settingsRoot?.channelBridge); + + const byServerId = asRecord(channelBridgeGlobal?.byServerId); + const scopedServerId = readTrimmedString(params.serverId) ?? ''; + const scopedAccountId = readTrimmedString(params.accountId) ?? ''; + + const channelBridgeServer = + scopedServerId.length > 0 && byServerId + ? asRecord(byServerId[scopedServerId]) + : null; + + const byAccountId = asRecord(channelBridgeServer?.byAccountId); + const channelBridgeAccount = + scopedAccountId.length > 0 && byAccountId + ? asRecord(byAccountId[scopedAccountId]) + : null; + + return { + channelBridgeGlobal, + channelBridgeServer, + channelBridgeAccount, + }; +} + diff --git a/apps/cli/src/channels/core/channelBridgeWorker.test.ts b/apps/cli/src/channels/core/channelBridgeWorker.test.ts index 92155b0ab5..e53cc308f6 100644 --- a/apps/cli/src/channels/core/channelBridgeWorker.test.ts +++ b/apps/cli/src/channels/core/channelBridgeWorker.test.ts @@ -771,6 +771,8 @@ describe('executeChannelBridgeTick', () => { providerId: 'telegram', conversationId: '-1001', threadId: '99', + senderId: 'user-1', + conversationKind: 'dm', text: '/sessions', messageId: 'm-sessions', }); @@ -778,6 +780,8 @@ describe('executeChannelBridgeTick', () => { providerId: 'telegram', conversationId: '-1001', threadId: '99', + senderId: 'user-1', + conversationKind: 'dm', text: '/detach', messageId: 'm-detach', }); @@ -796,6 +800,37 @@ describe('executeChannelBridgeTick', () => { expect(remaining).toHaveLength(0); }); + it('blocks /sessions in non-DM conversations', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + + const { deps } = createDepsHarness({ + sessions: [{ sessionId: 'sess-1', label: 'build-docs' }], + }); + + harness.pushInbound({ + providerId: 'telegram', + conversationId: '-1001', + threadId: '99', + senderId: 'user-1', + conversationKind: 'group', + text: '/sessions', + messageId: 'm-sessions-group', + }); + + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(harness.sent.some((row) => row.text.includes('For safety, /sessions is only available in direct messages.'))).toBe( + true, + ); + expect(harness.sent.some((row) => row.text.includes('Recent sessions'))).toBe(false); + }); + it('warns and replies when /sessions fails to list sessions', async () => { const store = createInMemoryChannelBindingStore(); const harness = createAdapterHarness(); @@ -810,6 +845,8 @@ describe('executeChannelBridgeTick', () => { providerId: 'telegram', conversationId: '-1001', threadId: '99', + senderId: 'user-1', + conversationKind: 'dm', text: '/sessions', messageId: 'm-sessions-fail', }); @@ -843,6 +880,7 @@ describe('executeChannelBridgeTick', () => { providerId: 'telegram', conversationId: '-1001', threadId: '99', + senderId: 'user-1', text: '/session', messageId: 'm-session-bound', }); @@ -856,6 +894,39 @@ describe('executeChannelBridgeTick', () => { expect(harness.sent.some((row) => row.text.includes('Attached session: sess-bound'))).toBe(true); }); + it('denies /session command for attached conversations when sender is not authorized', async () => { + const store = createInMemoryChannelBindingStore(); + const harness = createAdapterHarness(); + const { deps } = createDepsHarness(); + + await store.upsertBinding({ + ...DEFAULT_BINDING_POLICY, + providerId: 'telegram', + conversationId: '-1001', + threadId: '99', + sessionId: 'sess-bound', + lastForwardedSeq: 3, + }); + + harness.pushInbound({ + providerId: 'telegram', + conversationId: '-1001', + threadId: '99', + senderId: 'user-2', + text: '/session', + messageId: 'm-session-bound-unauthorized', + }); + await executeChannelBridgeTick({ + store, + adapters: [harness.adapter], + deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(harness.sent.some((row) => row.text.includes('not authorized'))).toBe(true); + expect(harness.sent.some((row) => row.text.includes('Attached session: sess-bound'))).toBe(false); + }); + it('supports /session command for non-attached conversations', async () => { const store = createInMemoryChannelBindingStore(); const harness = createAdapterHarness(); @@ -1247,6 +1318,8 @@ describe('executeChannelBridgeTick', () => { providerId: 'telegram', conversationId: '-1001', threadId: '99', + senderId: 'user-1', + conversationKind: 'dm', text: '/sessions', messageId: 'm-sessions-truncated', }); @@ -1691,6 +1764,8 @@ describe('executeChannelBridgeTick', () => { providerId: 'telegram', conversationId: '-1009', threadId: null, + senderId: 'user-1', + conversationKind: 'dm', text: '/sessions', messageId: 'authz-throws', }); @@ -2823,6 +2898,8 @@ describe('startChannelBridgeWorker', () => { providerId: 'telegram', conversationId: '-2001', threadId: null, + senderId: 'user-1', + conversationKind: 'dm', text: '/sessions', messageId: 'startup-sessions', }); diff --git a/apps/cli/src/channels/core/channelBridgeWorker.ts b/apps/cli/src/channels/core/channelBridgeWorker.ts index fa8440ede3..f939c7d149 100644 --- a/apps/cli/src/channels/core/channelBridgeWorker.ts +++ b/apps/cli/src/channels/core/channelBridgeWorker.ts @@ -27,6 +27,8 @@ export type ChannelBridgeConversationRef = Readonly<{ threadId: string | null; }>; +export type ChannelBridgeConversationKind = 'dm' | 'group' | 'channel' | 'unknown'; + /** * Inbound message event produced by an adapter. * @@ -34,6 +36,7 @@ export type ChannelBridgeConversationRef = Readonly<{ */ export type ChannelBridgeInboundMessage = ChannelBridgeConversationRef & Readonly<{ senderId?: string | null; + conversationKind?: ChannelBridgeConversationKind | null; text: string; messageId: string; }>; @@ -43,6 +46,7 @@ export type ChannelBridgeActorContext = Readonly<{ conversationId: string; threadId: string | null; senderId: string | null; + conversationKind: ChannelBridgeConversationKind; }>; /** @@ -183,6 +187,17 @@ function normalizeSenderId(raw: unknown): string | null { return trimmed.length > 0 ? trimmed : null; } +function normalizeConversationKind(raw: unknown): ChannelBridgeConversationKind { + switch (raw) { + case 'dm': + case 'group': + case 'channel': + return raw; + default: + return 'unknown'; + } +} + export class ChannelBridgePermanentDeliveryError extends Error { readonly code: 'forbidden' | 'conversation_not_found' | 'unknown'; @@ -617,19 +632,27 @@ async function authorizeCommand(params: Readonly<{ event: ChannelBridgeInboundMessage; deps: ChannelBridgeDeps; }>): Promise> { - const authorize = params.deps.authorizeCommand; - if (!authorize) { - return { allowed: true, message: null }; - } - const senderId = normalizeSenderId(params.event.senderId); const actor: ChannelBridgeActorContext = { providerId: params.event.providerId, conversationId: params.event.conversationId, threadId: params.event.threadId, senderId, + conversationKind: normalizeConversationKind(params.event.conversationKind), }; + if (params.commandName === 'sessions' && actor.conversationKind !== 'dm') { + return { + allowed: false, + message: 'For safety, /sessions is only available in direct messages.', + }; + } + + const authorize = params.deps.authorizeCommand; + if (!authorize) { + return { allowed: true, message: null }; + } + try { const result = await withTimeout( authorize({ @@ -745,6 +768,15 @@ async function handleCommand(params: Readonly<{ await replyForCommand('No session is attached here. Use /attach .'); return; } + + const controlAuthz = authorizeBindingControl({ + binding: existing, + senderId: normalizeSenderId(event.senderId), + }); + if (!controlAuthz.allowed) { + await replyForCommand(controlAuthz.message ?? 'You are not authorized to view this binding.'); + return; + } await replyForCommand(`Attached session: ${existing.sessionId}`); return; } @@ -1181,7 +1213,7 @@ export async function executeChannelBridgeTick(params: Readonly<{ const failureReplyKey: ChannelBridgeInboundMessage = { ...event, - messageId: `forward-failure:${String(event.messageId ?? '').trim()}`, + messageId: `forward-failure:${String(event.messageId ?? '').trim()}:${giveUp ? 'give-up' : 'retrying'}`, }; if (!deduper.hasSeen(failureReplyKey)) { diff --git a/apps/cli/src/channels/providers/_registry/telegramChannelBridgeProvider.ts b/apps/cli/src/channels/providers/_registry/telegramChannelBridgeProvider.ts index c8460b3ad0..037e275781 100644 --- a/apps/cli/src/channels/providers/_registry/telegramChannelBridgeProvider.ts +++ b/apps/cli/src/channels/providers/_registry/telegramChannelBridgeProvider.ts @@ -1,6 +1,7 @@ import { serializeAxiosErrorForLog } from '@/api/client/serializeAxiosErrorForLog'; import { createTelegramChannelAdapter } from '@/channels/providers/telegram/telegramAdapter'; import { createTelegramPollingCursorStore } from '@/channels/providers/telegram/telegramPollingCursorStore'; +import { createTelegramWebhookUpdateStore } from '@/channels/providers/telegram/telegramWebhookUpdateStore'; import { startTelegramWebhookRelay, type TelegramWebhookRelayHandle, @@ -22,10 +23,10 @@ async function stopRelayBestEffort(relayHandle: TelegramWebhookRelayHandle | nul export const telegramChannelBridgeProvider: ChannelBridgeProviderDefinition< 'telegram', ChannelBridgeRuntimeConfig, - ChannelBridgeRuntimeConfig['telegram'] + ChannelBridgeRuntimeConfig['providers']['telegram'] > = { providerId: 'telegram', - readConfig: (root) => root.telegram, + readConfig: (root) => root.providers.telegram, createRuntime: async ({ config, context }) => { const botToken = config.botToken; if (!botToken) return null; @@ -47,6 +48,9 @@ export const telegramChannelBridgeProvider: ChannelBridgeProviderDefinition< const pollingCursorStore = context.accountId ? createTelegramPollingCursorStore({ accountId: context.accountId, botToken }) : null; + const webhookUpdateStore = webhookModeRequested && context.accountId + ? createTelegramWebhookUpdateStore({ accountId: context.accountId, botToken }) + : null; let relayHandle: TelegramWebhookRelayHandle | null = null; let adapter = createTelegramChannelAdapter({ @@ -56,6 +60,7 @@ export const telegramChannelBridgeProvider: ChannelBridgeProviderDefinition< requireTopics, webhookMode: webhookModeRequested, pollingCursorStore: pollingCursorStore ?? undefined, + webhookUpdateStore: webhookUpdateStore ?? undefined, }); if (webhookModeRequested) { diff --git a/apps/cli/src/channels/providers/telegram/telegramAdapter.test.ts b/apps/cli/src/channels/providers/telegram/telegramAdapter.test.ts index 2c39537934..08847e2d44 100644 --- a/apps/cli/src/channels/providers/telegram/telegramAdapter.test.ts +++ b/apps/cli/src/channels/providers/telegram/telegramAdapter.test.ts @@ -68,6 +68,7 @@ describe('createTelegramChannelAdapter', () => { conversationId: '-100555', threadId: '9001', senderId: '42', + conversationKind: 'group', text: 'hello from topic', messageId: '5001', }, @@ -112,6 +113,7 @@ describe('createTelegramChannelAdapter', () => { conversationId: '1234', threadId: null, senderId: '42', + conversationKind: 'dm', text: 'hello from dm', messageId: '5001', }, @@ -148,6 +150,7 @@ describe('createTelegramChannelAdapter', () => { conversationId: '-100555', threadId: null, senderId: '42', + conversationKind: 'group', text: 'hello from group', messageId: '5001', }, @@ -209,6 +212,7 @@ describe('createTelegramChannelAdapter', () => { conversationId: '1234', threadId: null, senderId: '456', + conversationKind: 'dm', text: '/sessions', messageId: '88', }, @@ -257,12 +261,82 @@ describe('createTelegramChannelAdapter', () => { conversationId: '1234', threadId: null, senderId: '456', + conversationKind: 'dm', text: 'retry me', messageId: '9001', }, ]); }); + it('persists webhook updates across adapter restarts and ignores duplicate webhook deliveries while queued', async () => { + type TelegramWebhookUpdateStoreSnapshot = Readonly<{ + lastHandledWebhookUpdateId: number | null; + nextQueuedWebhookId: number; + queuedWebhookUpdates: readonly Readonly<{ + id: number; + update: unknown; + }>[]; + }>; + + let storedSnapshot: TelegramWebhookUpdateStoreSnapshot | null = null; + const webhookUpdateStore = { + load: vi.fn(async () => storedSnapshot), + save: vi.fn(async (snapshot: TelegramWebhookUpdateStoreSnapshot) => { + storedSnapshot = snapshot; + }), + }; + + const api = { + getMe: vi.fn(async () => ({ id: 9, username: 'happier_bot' })), + getUpdates: vi.fn(async () => []), + sendMessage: vi.fn(async () => undefined), + }; + + const webhookUpdate = { + update_id: 901, + message: { + message_id: 88, + text: '/sessions', + chat: { id: 1234, type: 'private' }, + from: { id: 456, is_bot: false, first_name: 'Grace' }, + }, + }; + + const firstAdapter = createTelegramChannelAdapter({ + botToken: 'test-token', + api, + webhookMode: true, + webhookUpdateStore: webhookUpdateStore as never, + } as never); + + await firstAdapter.enqueueWebhookUpdate(webhookUpdate); + const firstInbound = await firstAdapter.pullInboundMessages(); + expect(firstInbound).toEqual([ + { + providerId: 'telegram', + conversationId: '1234', + threadId: null, + senderId: '456', + conversationKind: 'dm', + text: '/sessions', + messageId: '88', + }, + ]); + expect(webhookUpdateStore.save).toHaveBeenCalledTimes(1); + + const secondAdapter = createTelegramChannelAdapter({ + botToken: 'test-token', + api, + webhookMode: true, + webhookUpdateStore: webhookUpdateStore as never, + } as never); + + await expect(secondAdapter.pullInboundMessages()).resolves.toEqual(firstInbound); + + await secondAdapter.enqueueWebhookUpdate(webhookUpdate); + await expect(secondAdapter.pullInboundMessages()).resolves.toEqual(firstInbound); + }); + it('bounds webhook queue size to prevent unbounded growth', async () => { const api = { getMe: vi.fn(async () => ({ id: 9, username: 'happier_bot' })), diff --git a/apps/cli/src/channels/providers/telegram/telegramAdapter.ts b/apps/cli/src/channels/providers/telegram/telegramAdapter.ts index 718de97eb0..8d21eaf50d 100644 --- a/apps/cli/src/channels/providers/telegram/telegramAdapter.ts +++ b/apps/cli/src/channels/providers/telegram/telegramAdapter.ts @@ -4,6 +4,11 @@ import { ChannelBridgePermanentDeliveryError } from '@/channels/core/channelBrid import type { ChannelBridgeAdapter, ChannelBridgeInboundMessage } from '@/channels/core/channelBridgeWorker'; import { logger } from '@/ui/logger'; +import type { + TelegramWebhookUpdateStore, + TelegramWebhookUpdateStoreSnapshot, +} from './telegramWebhookUpdateStore'; + type TelegramSelfUser = Readonly<{ id: number; username: string | null }>; type TelegramApiMethod = 'getMe' | 'getUpdates' | 'sendMessage'; @@ -13,6 +18,19 @@ type TelegramApiClient = Readonly<{ sendMessage: (params: Readonly<{ chatId: string; threadId: string | null; text: string }>) => Promise; }>; +type TelegramWebhookQueuedUpdate = Readonly<{ + id: number; + update: unknown; + updateId: number | null; +}>; + +type TelegramWebhookQueueState = Readonly<{ + lastHandledWebhookUpdateId: number | null; + nextQueuedWebhookId: number; + queuedWebhookUpdates: TelegramWebhookQueuedUpdate[]; + queuedUpdateIds: ReadonlySet; +}>; + const TELEGRAM_GET_UPDATES_LONG_POLL_TIMEOUT_SECONDS = 25; const TELEGRAM_GET_UPDATES_HTTP_TIMEOUT_MS = (TELEGRAM_GET_UPDATES_LONG_POLL_TIMEOUT_SECONDS + 4) * 1_000; const TELEGRAM_MAX_SEND_MESSAGE_TEXT_LENGTH = 4_096; @@ -165,6 +183,15 @@ function parseInboundFromUpdate(params: Readonly<{ } } + const conversationKind = + isPrivateChat + ? 'dm' + : chatType === 'channel' + ? 'channel' + : chatType === 'group' || chatType === 'supergroup' + ? 'group' + : 'unknown'; + const threadId = typeof rawMessage.message_thread_id === 'number' && Number.isFinite(rawMessage.message_thread_id) ? String(Math.trunc(rawMessage.message_thread_id)) @@ -195,6 +222,7 @@ function parseInboundFromUpdate(params: Readonly<{ conversationId, threadId, senderId: senderId === null ? null : String(senderId), + conversationKind, text, messageId, }; @@ -227,6 +255,82 @@ function inboundMessageKey(message: Readonly<{ return JSON.stringify([message.providerId, message.conversationId, message.threadId, message.messageId]); } +function cloneWebhookQueueState(state: TelegramWebhookQueueState): TelegramWebhookQueueState { + return { + lastHandledWebhookUpdateId: state.lastHandledWebhookUpdateId, + nextQueuedWebhookId: state.nextQueuedWebhookId, + queuedWebhookUpdates: state.queuedWebhookUpdates.map((row) => ({ + id: row.id, + update: row.update, + updateId: row.updateId, + })), + queuedUpdateIds: new Set(state.queuedUpdateIds), + }; +} + +function normalizeWebhookQueueSnapshot(snapshot: TelegramWebhookUpdateStoreSnapshot | null): TelegramWebhookQueueState { + if (!snapshot) { + return { + lastHandledWebhookUpdateId: null, + nextQueuedWebhookId: 1, + queuedWebhookUpdates: [], + queuedUpdateIds: new Set(), + }; + } + + const queuedWebhookUpdates: TelegramWebhookQueuedUpdate[] = []; + const queuedUpdateIds = new Set(); + let maxQueuedWebhookId = 0; + + for (const row of snapshot.queuedWebhookUpdates) { + const id = Number.isFinite(row.id) ? Math.max(1, Math.trunc(row.id)) : null; + if (id === null) { + continue; + } + const updateId = parseUpdateId(row.update); + queuedWebhookUpdates.push({ + id, + update: row.update, + updateId, + }); + if (updateId !== null) { + queuedUpdateIds.add(updateId); + } + if (id > maxQueuedWebhookId) { + maxQueuedWebhookId = id; + } + } + + queuedWebhookUpdates.sort((left, right) => left.id - right.id); + + const nextQueuedWebhookId = Math.max( + 1, + Math.trunc(snapshot.nextQueuedWebhookId), + maxQueuedWebhookId + 1, + ); + + return { + lastHandledWebhookUpdateId: + typeof snapshot.lastHandledWebhookUpdateId === 'number' && Number.isFinite(snapshot.lastHandledWebhookUpdateId) + ? Math.max(0, Math.trunc(snapshot.lastHandledWebhookUpdateId)) + : null, + nextQueuedWebhookId, + queuedWebhookUpdates, + queuedUpdateIds, + }; +} + +function webhookQueueSnapshotToStore(snapshot: TelegramWebhookQueueState): TelegramWebhookUpdateStoreSnapshot { + return { + lastHandledWebhookUpdateId: snapshot.lastHandledWebhookUpdateId, + nextQueuedWebhookId: snapshot.nextQueuedWebhookId, + queuedWebhookUpdates: snapshot.queuedWebhookUpdates.map((row) => ({ + id: row.id, + update: row.update, + })), + }; +} + export function createTelegramChannelAdapter(params: Readonly<{ botToken: string; api?: TelegramApiClient; @@ -239,7 +343,8 @@ export function createTelegramChannelAdapter(params: Readonly<{ load: () => Promise; save: (offset: number) => Promise; }>; -}>): ChannelBridgeAdapter & Readonly<{ enqueueWebhookUpdate: (update: unknown) => void }> { + webhookUpdateStore?: TelegramWebhookUpdateStore; +}>): ChannelBridgeAdapter & Readonly<{ enqueueWebhookUpdate: (update: unknown) => void | Promise }> { const api = params.api ?? createDefaultTelegramApiClient(params.botToken); const webhookMode = params.webhookMode === true; const updateLimit = @@ -251,11 +356,6 @@ export function createTelegramChannelAdapter(params: Readonly<{ const requireTopics = params.requireTopics === true; const MAX_WEBHOOK_QUEUE_SIZE = 2_000; - type QueuedWebhookUpdate = { - id: number; - update: unknown; - }; - type PendingWebhookAck = { queueIds: Set; maxUpdateId: number | null; @@ -282,11 +382,14 @@ export function createTelegramChannelAdapter(params: Readonly<{ let pollingCursorLoaded = false; let lastPersistedPollingOffset: number | null = null; let pollingCursorSaveQueue = Promise.resolve(); - const queuedWebhookUpdates: QueuedWebhookUpdate[] = []; - let nextQueuedWebhookId = 1; + let webhookQueueState: TelegramWebhookQueueState | null = null; + let webhookQueueLoaded = false; + let webhookQueueLoadPromise: Promise | null = null; + let webhookQueueMutationQueue = Promise.resolve(); let droppedWebhookUpdates = 0; const pendingWebhookAcksByMessageKey = new Map(); let pendingPollingBatchAck: PendingPollingBatchAck | null = null; + const webhookUpdateStore = params.webhookUpdateStore ?? null; async function ensurePollingCursorLoaded(): Promise { if (pollingCursorLoaded) return; @@ -319,6 +422,166 @@ export function createTelegramChannelAdapter(params: Readonly<{ await pollingCursorSaveQueue; } + function withWebhookQueueMutation(work: () => Promise): Promise { + const run = webhookQueueMutationQueue.then(work, work); + webhookQueueMutationQueue = run.then(() => undefined, () => undefined); + return run; + } + + function getWebhookQueueState(): TelegramWebhookQueueState { + if (!webhookQueueState) { + throw new Error('Telegram webhook queue is not loaded'); + } + return webhookQueueState; + } + + function setWebhookQueueState(nextState: TelegramWebhookQueueState): void { + webhookQueueState = cloneWebhookQueueState(nextState); + } + + async function ensureWebhookQueueLoaded(): Promise { + if (webhookQueueLoaded) return; + if (webhookQueueLoadPromise) { + await webhookQueueLoadPromise; + return; + } + + webhookQueueLoadPromise = (async () => { + try { + if (!webhookUpdateStore) { + setWebhookQueueState(normalizeWebhookQueueSnapshot(null)); + return; + } + + const loaded = await webhookUpdateStore.load(); + setWebhookQueueState(normalizeWebhookQueueSnapshot(loaded)); + } catch (error) { + logger.warn('[channelBridge] Failed to load Telegram webhook queue; continuing with empty queue', error); + setWebhookQueueState(normalizeWebhookQueueSnapshot(null)); + } finally { + webhookQueueLoaded = true; + webhookQueueLoadPromise = null; + } + })(); + + await webhookQueueLoadPromise; + } + + async function saveWebhookQueueState(nextState: TelegramWebhookQueueState): Promise { + if (!webhookUpdateStore) return; + await webhookUpdateStore.save(webhookQueueSnapshotToStore(nextState)); + } + + function queueWebhookUpdateIdExists(state: TelegramWebhookQueueState, updateId: number): boolean { + return state.queuedUpdateIds.has(updateId); + } + + function removeQueuedWebhookUpdatesById(state: TelegramWebhookQueueState, ids: ReadonlySet): TelegramWebhookQueueState { + if (ids.size === 0) return state; + + const nextQueuedWebhookUpdates: TelegramWebhookQueuedUpdate[] = []; + const nextQueuedUpdateIds = new Set(state.queuedUpdateIds); + let lastHandledWebhookUpdateId = state.lastHandledWebhookUpdateId; + + for (const row of state.queuedWebhookUpdates) { + if (!ids.has(row.id)) { + nextQueuedWebhookUpdates.push(row); + continue; + } + + if (row.updateId !== null) { + nextQueuedUpdateIds.delete(row.updateId); + lastHandledWebhookUpdateId = + lastHandledWebhookUpdateId === null + ? row.updateId + : Math.max(lastHandledWebhookUpdateId, row.updateId); + } + } + + return { + lastHandledWebhookUpdateId, + nextQueuedWebhookId: state.nextQueuedWebhookId, + queuedWebhookUpdates: nextQueuedWebhookUpdates, + queuedUpdateIds: nextQueuedUpdateIds, + }; + } + + function dropOldestWebhookUpdate(state: TelegramWebhookQueueState): Readonly<{ + state: TelegramWebhookQueueState; + droppedQueueIds: ReadonlySet; + }> { + if (state.queuedWebhookUpdates.length === 0) { + return { + state, + droppedQueueIds: new Set(), + }; + } + const [oldest, ...rest] = state.queuedWebhookUpdates; + const nextQueuedUpdateIds = new Set(state.queuedUpdateIds); + if (oldest?.updateId !== null) { + nextQueuedUpdateIds.delete(oldest.updateId); + } + return { + state: { + lastHandledWebhookUpdateId: state.lastHandledWebhookUpdateId, + nextQueuedWebhookId: state.nextQueuedWebhookId, + queuedWebhookUpdates: rest, + queuedUpdateIds: nextQueuedUpdateIds, + }, + droppedQueueIds: oldest ? new Set([oldest.id]) : new Set(), + }; + } + + function appendWebhookUpdate(state: TelegramWebhookQueueState, update: unknown): Readonly<{ + state: TelegramWebhookQueueState; + droppedQueueIds: ReadonlySet; + }> { + const updateId = parseUpdateId(update); + if (updateId !== null) { + if (state.lastHandledWebhookUpdateId !== null && updateId <= state.lastHandledWebhookUpdateId) { + return { state, droppedQueueIds: new Set() }; + } + if (queueWebhookUpdateIdExists(state, updateId)) { + return { state, droppedQueueIds: new Set() }; + } + } + + let nextState: TelegramWebhookQueueState = { + lastHandledWebhookUpdateId: state.lastHandledWebhookUpdateId, + nextQueuedWebhookId: state.nextQueuedWebhookId + 1, + queuedWebhookUpdates: [ + ...state.queuedWebhookUpdates, + { + id: state.nextQueuedWebhookId, + update, + updateId, + }, + ], + queuedUpdateIds: updateId === null ? new Set(state.queuedUpdateIds) : new Set(state.queuedUpdateIds).add(updateId), + }; + + let droppedQueueIds = new Set(); + if (nextState.queuedWebhookUpdates.length > MAX_WEBHOOK_QUEUE_SIZE) { + const dropped = dropOldestWebhookUpdate(nextState); + nextState = dropped.state; + droppedQueueIds = new Set(dropped.droppedQueueIds); + } + + return { + state: nextState, + droppedQueueIds, + }; + } + + async function persistWebhookQueueOrWarn(state: TelegramWebhookQueueState): Promise { + if (!webhookUpdateStore) return; + try { + await saveWebhookQueueState(state); + } catch (error) { + logger.warn('[channelBridge] Failed to persist Telegram webhook queue; continuing without persistence', error); + } + } + function dropPendingWebhookAckIds(ids: ReadonlySet): void { if (ids.size === 0) return; @@ -332,17 +595,6 @@ export function createTelegramChannelAdapter(params: Readonly<{ } } - function removeQueuedWebhookUpdatesById(ids: ReadonlySet): void { - if (ids.size === 0) return; - - for (let index = queuedWebhookUpdates.length - 1; index >= 0; index -= 1) { - if (ids.has(queuedWebhookUpdates[index]!.id)) { - queuedWebhookUpdates.splice(index, 1); - } - } - dropPendingWebhookAckIds(ids); - } - async function ensureSelfIdentity(): Promise { if (selfBotId !== null) return; const self = await api.getMe(); @@ -367,22 +619,33 @@ export function createTelegramChannelAdapter(params: Readonly<{ return { providerId: 'telegram', - enqueueWebhookUpdate: (update: unknown) => { - if (queuedWebhookUpdates.length >= MAX_WEBHOOK_QUEUE_SIZE) { - const dropped = queuedWebhookUpdates.shift(); - if (dropped) { - droppedWebhookUpdates += 1; - dropPendingWebhookAckIds(new Set([dropped.id])); - } + enqueueWebhookUpdate: (update: unknown) => withWebhookQueueMutation(async () => { + await ensureWebhookQueueLoaded(); + const currentState = getWebhookQueueState(); + const appended = appendWebhookUpdate(currentState, update); + if (appended.state === currentState) { + return; } - queuedWebhookUpdates.push({ - id: nextQueuedWebhookId, - update, - }); - nextQueuedWebhookId += 1; - }, + + await saveWebhookQueueState(appended.state); + setWebhookQueueState(appended.state); + if (appended.droppedQueueIds.size > 0) { + dropPendingWebhookAckIds(appended.droppedQueueIds); + droppedWebhookUpdates += appended.droppedQueueIds.size; + } + }), pullInboundMessages: async () => { if (webhookMode) { + const snapshot = await withWebhookQueueMutation(async () => { + await ensureWebhookQueueLoaded(); + const currentState = getWebhookQueueState(); + return currentState.queuedWebhookUpdates.map((row) => ({ + id: row.id, + update: row.update, + updateId: row.updateId, + })); + }); + if (droppedWebhookUpdates > 0) { logger.warn( `[channelBridge] Telegram webhook queue overflow: dropped ${droppedWebhookUpdates} oldest update(s)`, @@ -390,7 +653,6 @@ export function createTelegramChannelAdapter(params: Readonly<{ droppedWebhookUpdates = 0; } - const snapshot = queuedWebhookUpdates.slice(); await ensureSelfIdentity(); const parsed: ChannelBridgeInboundMessage[] = []; @@ -413,15 +675,25 @@ export function createTelegramChannelAdapter(params: Readonly<{ const pending = pendingWebhookAcksByMessageKey.get(key) ?? { queueIds: new Set(), maxUpdateId: null }; pending.queueIds.add(row.id); - const updateId = parseUpdateId(row.update); - if (updateId !== null) { - pending.maxUpdateId = pending.maxUpdateId === null ? updateId : Math.max(pending.maxUpdateId, updateId); + if (row.updateId !== null) { + pending.maxUpdateId = pending.maxUpdateId === null ? row.updateId : Math.max(pending.maxUpdateId, row.updateId); } pendingWebhookAcksByMessageKey.set(key, pending); } - removeQueuedWebhookUpdatesById(consumedWithoutAck); + if (consumedWithoutAck.size > 0) { + await withWebhookQueueMutation(async () => { + await ensureWebhookQueueLoaded(); + const currentState = getWebhookQueueState(); + const nextState = removeQueuedWebhookUpdatesById(currentState, consumedWithoutAck); + if (nextState !== currentState) { + setWebhookQueueState(nextState); + await persistWebhookQueueOrWarn(nextState); + } + dropPendingWebhookAckIds(consumedWithoutAck); + }); + } return parsed; } @@ -492,7 +764,6 @@ export function createTelegramChannelAdapter(params: Readonly<{ } const consumedIds = new Set(); - let maxAckedUpdateId: number | null = null; for (const message of messages) { const key = inboundMessageKey(message); @@ -505,21 +776,26 @@ export function createTelegramChannelAdapter(params: Readonly<{ consumedIds.add(queueId); } - if (pending.maxUpdateId !== null) { - maxAckedUpdateId = maxAckedUpdateId === null - ? pending.maxUpdateId - : Math.max(maxAckedUpdateId, pending.maxUpdateId); - } - pendingWebhookAcksByMessageKey.delete(key); } - removeQueuedWebhookUpdatesById(consumedIds); + if (consumedIds.size > 0) { + await withWebhookQueueMutation(async () => { + await ensureWebhookQueueLoaded(); + const currentState = getWebhookQueueState(); + const nextState = removeQueuedWebhookUpdatesById(currentState, consumedIds); + if (nextState !== currentState) { + setWebhookQueueState(nextState); + await persistWebhookQueueOrWarn(nextState); + } + dropPendingWebhookAckIds(consumedIds); - if (maxAckedUpdateId !== null) { - const nextOffset = maxAckedUpdateId + 1; - updateOffset = updateOffset === null ? nextOffset : Math.max(updateOffset, nextOffset); - await persistPollingOffset(updateOffset); + if (nextState.lastHandledWebhookUpdateId !== null) { + const nextOffset = nextState.lastHandledWebhookUpdateId + 1; + updateOffset = updateOffset === null ? nextOffset : Math.max(updateOffset, nextOffset); + await persistPollingOffset(updateOffset); + } + }); } }, sendMessage: async (message) => { diff --git a/apps/cli/src/channels/providers/telegram/telegramWebhookRelay.ts b/apps/cli/src/channels/providers/telegram/telegramWebhookRelay.ts index b09da4764e..d9bb3db4fd 100644 --- a/apps/cli/src/channels/providers/telegram/telegramWebhookRelay.ts +++ b/apps/cli/src/channels/providers/telegram/telegramWebhookRelay.ts @@ -4,8 +4,10 @@ import fastify from 'fastify'; import { isLoopbackHostname } from '@/server/serverUrlClassification'; import { logger } from '@/ui/logger'; - -const MAX_WEBHOOK_SECRET_TOKEN_LENGTH = 256; +import { + assertTelegramWebhookSecretToken, + TELEGRAM_WEBHOOK_SECRET_TOKEN_MAX_LENGTH, +} from './telegramWebhookSecretToken'; function secureCompareToken(providedToken: string, expectedToken: string): boolean { const providedBytes = Buffer.from(providedToken, 'utf8'); @@ -31,16 +33,11 @@ export async function startTelegramWebhookRelay(params: Readonly<{ secretToken: string; onUpdate: (update: unknown) => void | Promise; }>): Promise { - const secretToken = String(params.secretToken ?? '').trim(); - if (!secretToken) { - throw new Error('Webhook secret token is required'); - } - if (secretToken.length > MAX_WEBHOOK_SECRET_TOKEN_LENGTH) { - throw new Error('Webhook secret token is too long'); - } - if (!/^[A-Za-z0-9_-]+$/.test(secretToken)) { - throw new Error('Webhook secret token must match [A-Za-z0-9_-]'); - } + const secretToken = assertTelegramWebhookSecretToken(params.secretToken, { + empty: 'Webhook secret token is required', + invalid: 'Webhook secret token must match [A-Za-z0-9_-]', + tooLong: 'Webhook secret token is too long', + }); const host = String(params.host ?? '127.0.0.1').trim() || '127.0.0.1'; if (!isLoopbackHostname(host)) { @@ -69,7 +66,7 @@ export async function startTelegramWebhookRelay(params: Readonly<{ ? providedHeader[0] ?? '' : ''; const trimmedProvidedToken = providedToken.trim(); - if (trimmedProvidedToken.length > MAX_WEBHOOK_SECRET_TOKEN_LENGTH) { + if (trimmedProvidedToken.length > TELEGRAM_WEBHOOK_SECRET_TOKEN_MAX_LENGTH) { return reply.status(431).send({ ok: false, error: 'Request Header Fields Too Large' }); } if (!secureCompareToken(trimmedProvidedToken, secretToken)) { diff --git a/apps/cli/src/channels/providers/telegram/telegramWebhookSecretToken.ts b/apps/cli/src/channels/providers/telegram/telegramWebhookSecretToken.ts new file mode 100644 index 0000000000..642da72476 --- /dev/null +++ b/apps/cli/src/channels/providers/telegram/telegramWebhookSecretToken.ts @@ -0,0 +1,28 @@ +const TELEGRAM_WEBHOOK_SECRET_TOKEN_PATTERN = /^[A-Za-z0-9_-]+$/; +export const TELEGRAM_WEBHOOK_SECRET_TOKEN_MAX_LENGTH = 256; + +export function readTelegramWebhookSecretToken(value: unknown): string | null { + if (typeof value !== 'string') return null; + const token = value.trim(); + if (token.length === 0) return null; + if (token.length > TELEGRAM_WEBHOOK_SECRET_TOKEN_MAX_LENGTH) return null; + if (!TELEGRAM_WEBHOOK_SECRET_TOKEN_PATTERN.test(token)) return null; + return token; +} + +export function assertTelegramWebhookSecretToken( + value: string, + errors: Readonly<{ empty: string; invalid: string; tooLong: string }>, +): string { + if (value.trim().length === 0) { + throw new Error(errors.empty); + } + if (value.trim().length > TELEGRAM_WEBHOOK_SECRET_TOKEN_MAX_LENGTH) { + throw new Error(errors.tooLong); + } + const token = readTelegramWebhookSecretToken(value); + if (!token) { + throw new Error(errors.invalid); + } + return token; +} diff --git a/apps/cli/src/channels/providers/telegram/telegramWebhookUpdateStore.test.ts b/apps/cli/src/channels/providers/telegram/telegramWebhookUpdateStore.test.ts new file mode 100644 index 0000000000..bcefeab006 --- /dev/null +++ b/apps/cli/src/channels/providers/telegram/telegramWebhookUpdateStore.test.ts @@ -0,0 +1,74 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createEnvKeyScope } from '@/testkit/env/envScope'; +import { withTempDir } from '@/testkit/fs/tempDir'; + +describe('createTelegramWebhookUpdateStore', () => { + const envKeys = ['HAPPIER_HOME_DIR'] as const; + let envScope = createEnvKeyScope(envKeys); + + afterEach(() => { + envScope.restore(); + envScope = createEnvKeyScope(envKeys); + vi.resetModules(); + }); + + it('persists queued webhook updates scoped by account and bot token', async () => { + await withTempDir('happier-channel-bridge-telegram-webhook-updates-', async (homeDir) => { + envScope.patch({ HAPPIER_HOME_DIR: homeDir }); + vi.resetModules(); + + const { createTelegramWebhookUpdateStore } = await import('./telegramWebhookUpdateStore'); + + const storeA = createTelegramWebhookUpdateStore({ + accountId: 'acct-1', + botToken: 'token-1', + }); + + await storeA.save({ + lastHandledWebhookUpdateId: 41, + nextQueuedWebhookId: 2, + queuedWebhookUpdates: [ + { + id: 1, + update: { + update_id: 42, + message: { text: 'hello' }, + }, + }, + ], + }); + + const storeB = createTelegramWebhookUpdateStore({ + accountId: 'acct-1', + botToken: 'token-1', + }); + + await expect(storeB.load()).resolves.toEqual({ + lastHandledWebhookUpdateId: 41, + nextQueuedWebhookId: 2, + queuedWebhookUpdates: [ + { + id: 1, + update: { + update_id: 42, + message: { text: 'hello' }, + }, + }, + ], + }); + + const otherAccountStore = createTelegramWebhookUpdateStore({ + accountId: 'acct-2', + botToken: 'token-1', + }); + await expect(otherAccountStore.load()).resolves.toBe(null); + + const otherTokenStore = createTelegramWebhookUpdateStore({ + accountId: 'acct-1', + botToken: 'token-2', + }); + await expect(otherTokenStore.load()).resolves.toBe(null); + }); + }); +}); diff --git a/apps/cli/src/channels/providers/telegram/telegramWebhookUpdateStore.ts b/apps/cli/src/channels/providers/telegram/telegramWebhookUpdateStore.ts new file mode 100644 index 0000000000..1b72cb59d4 --- /dev/null +++ b/apps/cli/src/channels/providers/telegram/telegramWebhookUpdateStore.ts @@ -0,0 +1,204 @@ +import { createHash } from 'node:crypto'; +import { chmod, mkdir, readFile } from 'node:fs/promises'; +import { join } from 'node:path'; + +import { configuration } from '@/configuration'; +import { writeJsonAtomic } from '@/utils/fs/writeJsonAtomic'; +import { logger } from '@/ui/logger'; + +type RecordLike = Record; + +export type TelegramWebhookUpdateStoreSnapshot = Readonly<{ + lastHandledWebhookUpdateId: number | null; + nextQueuedWebhookId: number; + queuedWebhookUpdates: readonly Readonly<{ + id: number; + update: unknown; + }>[]; +}>; + +export type TelegramWebhookUpdateStore = Readonly<{ + load: () => Promise; + save: (snapshot: TelegramWebhookUpdateStoreSnapshot) => Promise; +}>; + +type StoredTelegramWebhookUpdateDocV1 = Readonly<{ + schemaVersion: 1; + lastHandledWebhookUpdateId: number | null; + nextQueuedWebhookId: number; + queuedWebhookUpdates: Array>; +}>; + +const STORE_SCHEMA_VERSION = 1; +const BOT_TOKEN_HASH_LENGTH = 32; +const ACCOUNT_ID_SAFE_RE = /^[A-Za-z0-9._-]{1,128}$/; + +function asRecord(value: unknown): RecordLike | null { + if (!value || typeof value !== 'object' || Array.isArray(value)) return null; + return value as RecordLike; +} + +function assertFilesystemSafeAccountId(raw: string): string { + const value = raw.trim(); + if (!value) { + throw new Error('Invalid accountId: empty'); + } + if (value === '.' || value === '..') { + throw new Error(`Invalid accountId: ${value}`); + } + if (value.includes('/') || value.includes('\\')) { + throw new Error(`Invalid accountId: ${value}`); + } + if (!ACCOUNT_ID_SAFE_RE.test(value)) { + throw new Error(`Invalid accountId: ${value}`); + } + return value; +} + +function toNonNegativeInt(value: unknown): number | null { + if (typeof value !== 'number' || !Number.isFinite(value)) return null; + const truncated = Math.trunc(value); + return truncated >= 0 ? truncated : null; +} + +function toPositiveInt(value: unknown): number | null { + const candidate = toNonNegativeInt(value); + return candidate !== null && candidate > 0 ? candidate : null; +} + +function parseStoredDoc(value: unknown): StoredTelegramWebhookUpdateDocV1 | null { + const record = asRecord(value); + if (!record) return null; + if (record.schemaVersion !== STORE_SCHEMA_VERSION) return null; + + const queuedWebhookUpdatesRaw = Array.isArray(record.queuedWebhookUpdates) ? record.queuedWebhookUpdates : []; + const queuedWebhookUpdates: StoredTelegramWebhookUpdateDocV1['queuedWebhookUpdates'] = []; + let maxQueuedWebhookId = 0; + + for (const entry of queuedWebhookUpdatesRaw) { + const row = asRecord(entry); + if (!row) continue; + const id = toPositiveInt(row.id); + if (id === null) continue; + queuedWebhookUpdates.push({ + id, + update: row.update, + }); + if (id > maxQueuedWebhookId) { + maxQueuedWebhookId = id; + } + } + + queuedWebhookUpdates.sort((left, right) => left.id - right.id); + + const nextQueuedWebhookIdCandidate = toPositiveInt(record.nextQueuedWebhookId); + const nextQueuedWebhookId = Math.max( + maxQueuedWebhookId + 1, + nextQueuedWebhookIdCandidate === null ? 1 : nextQueuedWebhookIdCandidate, + ); + + return { + schemaVersion: STORE_SCHEMA_VERSION, + lastHandledWebhookUpdateId: toNonNegativeInt(record.lastHandledWebhookUpdateId), + nextQueuedWebhookId, + queuedWebhookUpdates, + }; +} + +function hashBotToken(botToken: string): string { + const normalized = botToken.trim(); + if (!normalized) { + throw new Error('Telegram bot token is required'); + } + return createHash('sha256').update(normalized).digest('hex').slice(0, BOT_TOKEN_HASH_LENGTH); +} + +async function bestEffortChmod0700(path: string): Promise { + if (process.platform === 'win32') return; + await chmod(path, 0o700).catch(() => {}); +} + +function resolveStoreFilePath(accountId: string, botToken: string): string { + const tokenHash = hashBotToken(botToken); + return join( + configuration.activeServerDir, + 'channel-bridges', + 'v1', + 'account', + accountId, + 'providers', + 'telegram', + 'webhook-updates', + `${tokenHash}.json`, + ); +} + +function snapshotToDoc(snapshot: TelegramWebhookUpdateStoreSnapshot): StoredTelegramWebhookUpdateDocV1 { + return { + schemaVersion: STORE_SCHEMA_VERSION, + lastHandledWebhookUpdateId: snapshot.lastHandledWebhookUpdateId === null + ? null + : Math.max(0, Math.trunc(snapshot.lastHandledWebhookUpdateId)), + nextQueuedWebhookId: Math.max(1, Math.trunc(snapshot.nextQueuedWebhookId)), + queuedWebhookUpdates: snapshot.queuedWebhookUpdates.map((row) => ({ + id: Math.max(1, Math.trunc(row.id)), + update: row.update, + })), + }; +} + +export function createTelegramWebhookUpdateStore(params: Readonly<{ accountId: string; botToken: string }>): TelegramWebhookUpdateStore { + const accountId = assertFilesystemSafeAccountId(params.accountId); + const updateFile = resolveStoreFilePath(accountId, params.botToken); + let queue = Promise.resolve(); + + const enqueue = (work: () => Promise): Promise => { + const run = queue.then(work, work); + queue = run.then(() => undefined, () => undefined); + return run; + }; + + return { + load: () => enqueue(async () => { + try { + const raw = await readFile(updateFile, { encoding: 'utf-8' }).catch((error: unknown) => { + const err = error as NodeJS.ErrnoException; + if (err?.code === 'ENOENT') return null; + throw error; + }); + const parsed = raw ? parseStoredDoc(JSON.parse(raw)) : null; + return parsed + ? { + lastHandledWebhookUpdateId: parsed.lastHandledWebhookUpdateId, + nextQueuedWebhookId: parsed.nextQueuedWebhookId, + queuedWebhookUpdates: parsed.queuedWebhookUpdates.map((row) => ({ + id: row.id, + update: row.update, + })), + } + : null; + } catch (error) { + logger.warn('[channelBridge] Failed to read Telegram webhook update queue; treating queue as missing', error); + return null; + } + }), + save: (snapshot) => enqueue(async () => { + const doc = snapshotToDoc(snapshot); + const accountDir = join(configuration.activeServerDir, 'channel-bridges', 'v1', 'account', accountId); + const telegramDir = join(accountDir, 'providers', 'telegram'); + const updatesDir = join(telegramDir, 'webhook-updates'); + + await mkdir(updatesDir, { recursive: true, mode: 0o700 }); + await bestEffortChmod0700(configuration.activeServerDir); + + await writeJsonAtomic(updateFile, doc); + await bestEffortChmod0700(join(configuration.activeServerDir, 'channel-bridges')); + await bestEffortChmod0700(join(configuration.activeServerDir, 'channel-bridges', 'v1')); + await bestEffortChmod0700(accountDir); + await bestEffortChmod0700(updatesDir); + }), + }; +} diff --git a/apps/cli/src/cli/commands/bridge.ts b/apps/cli/src/cli/commands/bridge.ts index 2b549b9ff7..1e5bd70a1c 100644 --- a/apps/cli/src/cli/commands/bridge.ts +++ b/apps/cli/src/cli/commands/bridge.ts @@ -136,18 +136,19 @@ async function cmdList(): Promise { } console.log(chalk.bold('\nTelegram (effective runtime: env > settings.json)')); - console.log(` botToken: ${maskSecret(effective.telegram.botToken)}`); - if (effective.telegram.allowAllSharedChats) { + const telegramEffective = effective.providers.telegram; + console.log(` botToken: ${maskSecret(telegramEffective.botToken)}`); + if (telegramEffective.allowAllSharedChats) { console.log(' allowedChatIds: (allow all shared chats - DANGEROUS)'); } else { console.log( - ` allowedChatIds: ${effective.telegram.allowedChatIds.length > 0 ? effective.telegram.allowedChatIds.join(', ') : '(dm-only)'}`, + ` allowedChatIds: ${telegramEffective.allowedChatIds.length > 0 ? telegramEffective.allowedChatIds.join(', ') : '(dm-only)'}`, ); } - console.log(` requireTopics: ${effective.telegram.requireTopics ? 'true' : 'false'}`); - console.log(` webhook.enabled: ${effective.telegram.webhookEnabled ? 'true' : 'false'}`); - console.log(` webhook.host: ${effective.telegram.webhookHost}`); - console.log(` webhook.port: ${effective.telegram.webhookPort}`); + console.log(` requireTopics: ${telegramEffective.requireTopics ? 'true' : 'false'}`); + console.log(` webhook.enabled: ${telegramEffective.webhookEnabled ? 'true' : 'false'}`); + console.log(` webhook.host: ${telegramEffective.webhookHost}`); + console.log(` webhook.port: ${telegramEffective.webhookPort}`); try { const store = createLocalChannelBindingStore({ accountId }); diff --git a/apps/cli/src/daemon/channels/resolveChannelBridgesDaemonEnabled.ts b/apps/cli/src/daemon/channels/resolveChannelBridgesDaemonEnabled.ts index f66db79ccd..ca0fa6d118 100644 --- a/apps/cli/src/daemon/channels/resolveChannelBridgesDaemonEnabled.ts +++ b/apps/cli/src/daemon/channels/resolveChannelBridgesDaemonEnabled.ts @@ -1,5 +1,11 @@ -import { resolveCliFeatureDecisionForServer } from '@/features/featureDecisionService'; +import type { FeatureId } from '@happier-dev/protocol'; + +import { + resolveCliFeatureDecision, + resolveCliFeatureDecisionForServer, +} from '@/features/featureDecisionService'; import { resolveExperimentalSettingsFeatureToggleEnabled } from '@/features/settingsFeatureToggles'; +import { listChannelBridgeProviderIds } from '@/channels/providers/_registry/channelBridgeProviderRegistry'; export async function resolveChannelBridgesDaemonEnabled(params: { env: NodeJS.ProcessEnv; @@ -7,20 +13,47 @@ export async function resolveChannelBridgesDaemonEnabled(params: { settings?: unknown; timeoutMs?: number; }): Promise { - const resolved = await resolveCliFeatureDecisionForServer({ - featureId: 'channelBridges.telegram', - env: params.env, - serverUrl: params.serverUrl, - timeoutMs: params.timeoutMs, + const localToggleEnabled = resolveExperimentalSettingsFeatureToggleEnabled({ + settings: params.settings, + featureId: 'channelBridges', + defaultEnabled: false, }); - if (resolved.decision.state !== 'enabled') { + if (!localToggleEnabled) { return false; } - return resolveExperimentalSettingsFeatureToggleEnabled({ - settings: params.settings, + const providerFeatureIds = listChannelBridgeProviderIds() + .map((providerId) => `channelBridges.${providerId}` as const); + + // Avoid probing the server when global policy (build/local) denies all providers. + const anyProviderPotentiallyEnabled = providerFeatureIds.some((featureId) => { + const decision = resolveCliFeatureDecision({ + featureId: featureId as FeatureId, + env: params.env, + serverSnapshot: undefined, + }); + return decision.state === 'enabled' || decision.blockedBy === 'server' || decision.state === 'unknown'; + }); + if (!anyProviderPotentiallyEnabled) { + return false; + } + + const resolved = await resolveCliFeatureDecisionForServer({ featureId: 'channelBridges', - defaultEnabled: false, + env: params.env, + serverUrl: params.serverUrl, + timeoutMs: params.timeoutMs, }); + + const providerEnabled = providerFeatureIds.some((featureId) => { + const decision = resolveCliFeatureDecision({ + featureId: featureId as FeatureId, + env: params.env, + serverSnapshot: resolved.serverSnapshot, + }); + return decision.state === 'enabled'; + }); + + return providerEnabled; } diff --git a/apps/cli/src/ui/doctor/channelBridgesDoctor.test.ts b/apps/cli/src/ui/doctor/channelBridgesDoctor.test.ts new file mode 100644 index 0000000000..c098cabfcf --- /dev/null +++ b/apps/cli/src/ui/doctor/channelBridgesDoctor.test.ts @@ -0,0 +1,38 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { runChannelBridgeDoctorSection } from './channelBridgesDoctor'; + +describe('runChannelBridgeDoctorSection', () => { + it('treats webhook enabled with missing secret as a critical failure', async () => { + const logSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + try { + const result = await runChannelBridgeDoctorSection({ + credentialsToken: null, + settings: { + channelBridge: { + providers: { + telegram: { + secrets: { + botToken: 'bot-token', + webhookSecret: '', + }, + webhook: { + enabled: true, + host: '127.0.0.1', + port: 8787, + }, + }, + }, + }, + }, + }); + + expect(result.hasCriticalFailures).toBe(true); + expect(logSpy.mock.calls.map((call) => String(call[0]))).toContain( + '❌ webhook.enabled=true but webhook.secret is missing (bridge will not start)', + ); + } finally { + logSpy.mockRestore(); + } + }); +}); diff --git a/apps/cli/src/ui/doctor/channelBridgesDoctor.ts b/apps/cli/src/ui/doctor/channelBridgesDoctor.ts index 25c056a6af..5c0c4ed9f8 100644 --- a/apps/cli/src/ui/doctor/channelBridgesDoctor.ts +++ b/apps/cli/src/ui/doctor/channelBridgesDoctor.ts @@ -27,7 +27,7 @@ function formatInboundMode(binding: ChannelSessionBinding): string { return `${mode}${missing}`; } -function isTelegramConfigured(runtime: ReturnType['telegram']): boolean { +function isTelegramConfigured(runtime: ReturnType['providers']['telegram']): boolean { return ( runtime.botToken.trim().length > 0 || runtime.webhookEnabled @@ -60,12 +60,13 @@ export async function runChannelBridgeDoctorSection(params: Readonly<{ let hasCriticalFailures = false; - if (!isTelegramConfigured(runtime.telegram)) { + const telegram = runtime.providers.telegram; + if (!isTelegramConfigured(telegram)) { console.log(chalk.gray('Telegram bridge not configured for active scope')); return { hasCriticalFailures }; } - const tokenMissing = runtime.telegram.botToken.trim().length === 0; + const tokenMissing = telegram.botToken.trim().length === 0; if (tokenMissing) { console.log(chalk.red('❌ Telegram bridge configuration present but bot token is missing (bridge will not start)')); hasCriticalFailures = true; @@ -73,28 +74,29 @@ export async function runChannelBridgeDoctorSection(params: Readonly<{ console.log(chalk.green('✓ Telegram bridge configured (bot token present)')); } - if (runtime.telegram.allowAllSharedChats) { + if (telegram.allowAllSharedChats) { console.log(chalk.yellow('⚠️ allowAllSharedChats=true (any shared chat can be attached; high risk)')); } const allowedChatIdsLabel = (() => { - if (runtime.telegram.allowAllSharedChats) { + if (telegram.allowAllSharedChats) { return '(allow all shared chats - UNSAFE)'; } - if (runtime.telegram.allowedChatIds.length > 0) { - return runtime.telegram.allowedChatIds.join(', '); + if (telegram.allowedChatIds.length > 0) { + return telegram.allowedChatIds.join(', '); } return '(dm-only)'; })(); console.log(` allowedChatIds: ${allowedChatIdsLabel}`); - console.log(` requireTopics: ${runtime.telegram.requireTopics ? 'true' : 'false'}`); - console.log(` webhook.enabled: ${runtime.telegram.webhookEnabled ? 'true' : 'false'}`); - console.log(` webhook.host: ${runtime.telegram.webhookHost}`); - console.log(` webhook.port: ${runtime.telegram.webhookPort}`); + console.log(` requireTopics: ${telegram.requireTopics ? 'true' : 'false'}`); + console.log(` webhook.enabled: ${telegram.webhookEnabled ? 'true' : 'false'}`); + console.log(` webhook.host: ${telegram.webhookHost}`); + console.log(` webhook.port: ${telegram.webhookPort}`); - if (runtime.telegram.webhookEnabled && runtime.telegram.webhookSecret.trim().length === 0) { - console.log(chalk.yellow('⚠️ webhook.enabled=true but webhook.secret is missing (daemon will fall back to polling)')); + if (telegram.webhookEnabled && telegram.webhookSecret.trim().length === 0) { + console.log(chalk.red('❌ webhook.enabled=true but webhook.secret is missing (bridge will not start)')); + hasCriticalFailures = true; } if (!accountId) { @@ -134,4 +136,3 @@ export async function runChannelBridgeDoctorSection(params: Readonly<{ return { hasCriticalFailures }; } - From d086bcf4e6668f23ffa68ed17e59732a823ee2db Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Sat, 28 Mar 2026 13:49:30 +0100 Subject: [PATCH 72/81] fix(ui): hide Channel Bridges unless enabled --- apps/ui/metro.config.js | 15 ++++- .../(app)/settings/features.gating.spec.tsx | 25 ++++++++ .../sources/app/(app)/settings/features.tsx | 16 +++++ .../SettingsView.serversEntry.test.tsx | 61 +++++++++++++++---- .../components/settings/SettingsView.tsx | 14 ++--- .../ChannelBridgesSettingsView.tsx | 15 +++-- .../channelBridgesVisibility.ts | 39 ++++++++++++ ...etro.config.expoResolutionFallback.spec.ts | 10 +++ 8 files changed, 162 insertions(+), 33 deletions(-) create mode 100644 apps/ui/sources/components/settings/channelBridges/channelBridgesVisibility.ts diff --git a/apps/ui/metro.config.js b/apps/ui/metro.config.js index 2cac02519b..58b316d82b 100644 --- a/apps/ui/metro.config.js +++ b/apps/ui/metro.config.js @@ -47,16 +47,25 @@ config.transformer.getTransformOptions = async () => ({ const testRouteBlockList = /[\\/]sources[\\/]app[\\/].*\.(test|spec)\.[jt]sx?$/; const projectArtifactsBlockList = /[\\/]\.project[\\/]/; const nextBuildArtifactsBlockList = /[\\/]\.next[\\/]/; +const projectRootLivesUnderProjectWorktrees = /[\\/]\.project[\\/]worktrees[\\/]/.test( + path.resolve(String(config.projectRoot ?? __dirname)), +); // Avoid scanning duplicate workspace-local `node_modules/**` trees (typically symlink-heavy) when Metro falls back // to the native `find` crawler (no Watchman). We still keep the monorepo root `node_modules` and `apps/ui/node_modules`. const workspaceNodeModulesBlockList = /[\\/]apps[\\/](?!ui[\\/])[^\\/]+[\\/]node_modules[\\/]|[\\/]packages[\\/][^\\/]+[\\/]node_modules[\\/]/; const existingBlockList = config.resolver.blockList; +const projectLocalBlockListEntries = [ + testRouteBlockList, + ...(projectRootLivesUnderProjectWorktrees ? [] : [projectArtifactsBlockList]), + nextBuildArtifactsBlockList, + workspaceNodeModulesBlockList, +]; config.resolver.blockList = Array.isArray(existingBlockList) - ? [...existingBlockList, testRouteBlockList, projectArtifactsBlockList, nextBuildArtifactsBlockList, workspaceNodeModulesBlockList] + ? [...existingBlockList, ...projectLocalBlockListEntries] : existingBlockList - ? [existingBlockList, testRouteBlockList, projectArtifactsBlockList, nextBuildArtifactsBlockList, workspaceNodeModulesBlockList] - : [testRouteBlockList, projectArtifactsBlockList, nextBuildArtifactsBlockList, workspaceNodeModulesBlockList]; + ? [existingBlockList, ...projectLocalBlockListEntries] + : projectLocalBlockListEntries; const existingWatchFolders = Array.isArray(config.watchFolders) ? config.watchFolders : []; config.watchFolders = existingWatchFolders.filter( diff --git a/apps/ui/sources/__tests__/routes/(app)/settings/features.gating.spec.tsx b/apps/ui/sources/__tests__/routes/(app)/settings/features.gating.spec.tsx index f2bdcf557a..127f917cb6 100644 --- a/apps/ui/sources/__tests__/routes/(app)/settings/features.gating.spec.tsx +++ b/apps/ui/sources/__tests__/routes/(app)/settings/features.gating.spec.tsx @@ -208,6 +208,31 @@ describe('FeaturesSettingsScreen gating', () => { expect(bridgeItem).toBeTruthy(); }); + it('hides the channel bridges toggle when telegram is hard-disabled by the server', async () => { + process.env.EXPO_PUBLIC_HAPPIER_BUILD_FEATURES_ALLOW = 'channelBridges'; + + useEffectiveServerSelectionMock.mockReturnValue({ serverIds: ['server-1'] }); + useServerFeaturesMainSelectionSnapshotMock.mockReturnValue({ + status: 'ready', + serverIds: ['server-1'], + snapshotsByServerId: { + 'server-1': { + status: 'ready', + features: createRootLayoutFeaturesResponse({ + features: { + channelBridges: { enabled: true, telegram: { enabled: false } }, + }, + }), + }, + }, + }); + + const { default: FeaturesSettingsScreen } = await import('@/app/(app)/settings/features'); + + const screen = await renderSettingsView(React.createElement(FeaturesSettingsScreen)); + expect(screen.findRowByTitle('settingsFeatures.expChannelBridges')).toBeNull(); + }); + it('turning off connectedServices also disables connectedServices.quotas', async () => { vi.resetModules(); const setFeatureToggles = vi.fn(); diff --git a/apps/ui/sources/app/(app)/settings/features.tsx b/apps/ui/sources/app/(app)/settings/features.tsx index 93f9ef95ea..f91caecf72 100644 --- a/apps/ui/sources/app/(app)/settings/features.tsx +++ b/apps/ui/sources/app/(app)/settings/features.tsx @@ -26,6 +26,7 @@ import { import { getFeatureBuildPolicyDecision } from '@/sync/domains/features/featureBuildPolicy'; import { useEffectiveServerSelection } from '@/hooks/server/useEffectiveServerSelection'; import { useServerFeaturesMainSelectionSnapshot } from '@/sync/domains/features/featureDecisionRuntime'; +import { isChannelBridgesFamilyHardDisabledByServer } from '@/components/settings/channelBridges/channelBridgesVisibility'; export default React.memo(function FeaturesSettingsScreen() { const { theme } = useUnistyles(); @@ -129,6 +130,21 @@ export default React.memo(function FeaturesSettingsScreen() { const isToggleHardDisabledByServer = React.useCallback( (featureId: FeatureId): boolean => { + if (featureId === 'channelBridges') { + if (serverSnapshot.status !== 'ready') return false; + if (serverSnapshot.serverIds.length === 0) return false; + + for (const serverId of serverSnapshot.serverIds) { + const snapshot = serverSnapshot.snapshotsByServerId[serverId]; + if (!snapshot) return false; + if (snapshot.status === 'error') return false; + if (snapshot.status === 'unsupported') return true; + if (isChannelBridgesFamilyHardDisabledByServer(snapshot)) return true; + } + + return false; + } + if (!featureRequiresServerSnapshot(featureId)) return false; if (serverSnapshot.status !== 'ready') return false; if (serverSnapshot.serverIds.length === 0) return false; diff --git a/apps/ui/sources/components/settings/SettingsView.serversEntry.test.tsx b/apps/ui/sources/components/settings/SettingsView.serversEntry.test.tsx index c0255e208a..11a97b30d3 100644 --- a/apps/ui/sources/components/settings/SettingsView.serversEntry.test.tsx +++ b/apps/ui/sources/components/settings/SettingsView.serversEntry.test.tsx @@ -22,6 +22,21 @@ const shared = vi.hoisted(() => ({ canRequestReviewSpy: vi.fn(async () => true), })); +const useFeatureDecisionMock = vi.hoisted(() => vi.fn()); + +useFeatureDecisionMock.mockImplementation((featureId: string) => { + if (featureId !== 'channelBridges') return null; + return { + featureId: 'channelBridges', + state: 'enabled', + blockedBy: null, + blockerCode: 'none', + diagnostics: [], + evaluatedAt: 0, + scope: { scopeKind: 'runtime' }, + }; +}); + const settingsViewWebDimensions = { width: 1600, height: 900, scale: 2, fontScale: 1 }; function createPassthroughNode(name: string) { @@ -223,18 +238,7 @@ vi.mock('@/hooks/server/useFeatureEnabled', () => ({ })); vi.mock('@/hooks/server/useFeatureDecision', () => ({ - useFeatureDecision: (featureId: string) => { - if (featureId !== 'channelBridges') return null; - return { - featureId: 'channelBridges', - state: 'enabled', - blockedBy: null, - blockerCode: 'none', - diagnostics: [], - evaluatedAt: 0, - scope: { scopeKind: 'runtime' }, - }; - }, + useFeatureDecision: (featureId: string, scope?: unknown) => useFeatureDecisionMock(featureId, scope), })); vi.mock('@/sync/domains/server/serverProfiles', () => ({ @@ -263,6 +267,19 @@ afterEach(() => { shared.requestReviewSpy.mockClear(); shared.canRequestReviewSpy.mockReset(); shared.canRequestReviewSpy.mockResolvedValue(true); + useFeatureDecisionMock.mockReset(); + useFeatureDecisionMock.mockImplementation((featureId: string) => { + if (featureId !== 'channelBridges') return null; + return { + featureId: 'channelBridges', + state: 'enabled', + blockedBy: null, + blockerCode: 'none', + diagnostics: [], + evaluatedAt: 0, + scope: { scopeKind: 'runtime' }, + }; + }); }); describe('SettingsView', () => { @@ -348,6 +365,26 @@ describe('SettingsView', () => { } }); + it('hides Channel Bridges in Settings when the runtime decision is disabled', async () => { + useFeatureDecisionMock.mockImplementation((featureId: string) => { + if (featureId !== 'channelBridges') return null; + return { + featureId: 'channelBridges', + state: 'disabled', + blockedBy: 'server', + blockerCode: 'feature_disabled', + diagnostics: [], + evaluatedAt: 0, + scope: { scopeKind: 'runtime' }, + }; + }); + + const { SettingsView } = await import('./SettingsView'); + const screen = await renderSettingsView(React.createElement(SettingsView)); + + expect(screen.findRowByTitle('settings.channelBridges')).toBeNull(); + }); + it('routes to the in-app bug report composer by default when Report issue is pressed', async () => { const { SettingsView } = await import('./SettingsView'); const screen = await renderSettingsView(React.createElement(SettingsView)); diff --git a/apps/ui/sources/components/settings/SettingsView.tsx b/apps/ui/sources/components/settings/SettingsView.tsx index 591a7972f6..b008fd6814 100644 --- a/apps/ui/sources/components/settings/SettingsView.tsx +++ b/apps/ui/sources/components/settings/SettingsView.tsx @@ -32,7 +32,6 @@ import { resolveSupportUsAction } from '@/components/settings/supportUsBehavior' import { recordBugReportUserAction } from '@/utils/system/bugReportActionTrail'; import { fireAndForget } from '@/utils/system/fireAndForget'; import { useAutomationsSupport } from '@/hooks/server/useAutomationsSupport'; -import { useFeatureDecision } from '@/hooks/server/useFeatureDecision'; import { useFeatureEnabled } from '@/hooks/server/useFeatureEnabled'; import type { FeatureId } from '@happier-dev/protocol'; import { getFeatureBuildPolicyDecision } from '@/sync/domains/features/featureBuildPolicy'; @@ -40,6 +39,7 @@ import { isRunningOnMac } from '@/utils/platform/platform'; import { isWebMobileLikeQrScannerHost } from '@/utils/platform/webMobileHeuristics'; import { navigateWithBlurOnWeb } from '@/utils/platform/navigateWithBlurOnWeb'; import { deferOnWeb } from '@/utils/platform/deferOnWeb'; +import { useChannelBridgesRuntimeVisibility } from '@/components/settings/channelBridges/channelBridgesVisibility'; export const SettingsView = React.memo(function SettingsView() { const { theme } = useUnistyles(); @@ -67,11 +67,7 @@ export const SettingsView = React.memo(function SettingsView() { const automationsSupport = useAutomationsSupport(); const showAutomations = automationsSupport?.discoverable !== false; const automationsNeedLocalEnablement = automationsSupport?.blockedBy === 'local_policy'; - const channelBridgesDecision = useFeatureDecision('channelBridges'); - const showChannelBridges = getFeatureBuildPolicyDecision('channelBridges') !== 'deny' - && channelBridgesDecision !== null - && channelBridgesDecision.state !== 'unsupported'; - const channelBridgesNeedLocalEnablement = channelBridgesDecision?.blockedBy === 'local_policy'; + const { showSettingsEntry: showChannelBridges } = useChannelBridgesRuntimeVisibility(); const profile = useProfile(); const displayName = getDisplayName(profile); const avatarUrl = getAvatarUrl(profile); @@ -411,11 +407,9 @@ export const SettingsView = React.memo(function SettingsView() { {showChannelBridges ? ( } - onPress={() => pushRoute(channelBridgesNeedLocalEnablement ? '/(app)/settings/features' : '/(app)/settings/channel-bridges')} + onPress={() => pushRoute('/(app)/settings/channel-bridges')} /> ) : null} {mcpServersEnabled && ( diff --git a/apps/ui/sources/components/settings/channelBridges/ChannelBridgesSettingsView.tsx b/apps/ui/sources/components/settings/channelBridges/ChannelBridgesSettingsView.tsx index 4f7440b740..078319f04d 100644 --- a/apps/ui/sources/components/settings/channelBridges/ChannelBridgesSettingsView.tsx +++ b/apps/ui/sources/components/settings/channelBridges/ChannelBridgesSettingsView.tsx @@ -12,19 +12,18 @@ import { CodeView } from '@/components/ui/media/CodeView'; import { Text } from '@/components/ui/text/Text'; import type { FeatureId } from '@happier-dev/protocol'; import { FeatureDiagnosticsPanel } from '@/components/settings/features/FeatureDiagnosticsPanel'; -import { useFeatureDecision } from '@/hooks/server/useFeatureDecision'; import { t } from '@/text'; +import { useChannelBridgesRuntimeVisibility } from './channelBridgesVisibility'; export const ChannelBridgesSettingsView = React.memo(function ChannelBridgesSettingsView() { const { theme } = useUnistyles(); const router = useRouter(); - const channelBridgesDecision = useFeatureDecision('channelBridges', { scopeKind: 'runtime' }); - const telegramDecision = useFeatureDecision('channelBridges.telegram', { scopeKind: 'runtime' }); - - const loading = channelBridgesDecision === null; - const needsLocalEnablement = channelBridgesDecision?.blockedBy === 'local_policy'; - const supported = channelBridgesDecision?.state !== 'unsupported'; - const telegramEnabled = telegramDecision?.state === 'enabled'; + const { + loading, + needsLocalEnablement, + supported, + telegramEnabled, + } = useChannelBridgesRuntimeVisibility(); const configureTelegramCommand = React.useMemo(() => { return [ diff --git a/apps/ui/sources/components/settings/channelBridges/channelBridgesVisibility.ts b/apps/ui/sources/components/settings/channelBridges/channelBridgesVisibility.ts new file mode 100644 index 0000000000..143321b768 --- /dev/null +++ b/apps/ui/sources/components/settings/channelBridges/channelBridgesVisibility.ts @@ -0,0 +1,39 @@ +import type { FeatureDecision } from '@happier-dev/protocol'; + +import { readServerEnabledBit } from '@happier-dev/protocol'; + +import { useFeatureDecision } from '@/hooks/server/useFeatureDecision'; +import type { ServerFeaturesSnapshot } from '@/sync/api/capabilities/serverFeaturesClient'; + +export function useChannelBridgesRuntimeVisibility() { + const channelBridgesDecision = useFeatureDecision('channelBridges', { scopeKind: 'runtime' }); + const telegramDecision = useFeatureDecision('channelBridges.telegram', { scopeKind: 'runtime' }); + + const loading = channelBridgesDecision === null; + const supported = channelBridgesDecision?.state !== 'unsupported'; + const needsLocalEnablement = channelBridgesDecision?.blockedBy === 'local_policy'; + const telegramEnabled = telegramDecision?.state === 'enabled'; + const showSettingsEntry = channelBridgesDecision?.state === 'enabled'; + + return { + channelBridgesDecision, + telegramDecision, + loading, + supported, + needsLocalEnablement, + telegramEnabled, + showSettingsEntry, + } as const; +} + +export function isChannelBridgesFamilyHardDisabledByServer(snapshot: ServerFeaturesSnapshot): boolean { + if (snapshot.status === 'error') return false; + if (snapshot.status === 'unsupported') return true; + + return readServerEnabledBit(snapshot.features, 'channelBridges') !== true + || readServerEnabledBit(snapshot.features, 'channelBridges.telegram') !== true; +} + +export function isChannelBridgesRuntimeEnabled(decision: FeatureDecision | null): boolean { + return decision?.state === 'enabled'; +} diff --git a/apps/ui/sources/dev/metro.config.expoResolutionFallback.spec.ts b/apps/ui/sources/dev/metro.config.expoResolutionFallback.spec.ts index 60e267f819..75e2b3fb25 100644 --- a/apps/ui/sources/dev/metro.config.expoResolutionFallback.spec.ts +++ b/apps/ui/sources/dev/metro.config.expoResolutionFallback.spec.ts @@ -63,4 +63,14 @@ describe('apps/ui/metro.config.js (Expo resolution fallbacks)', () => { const config = requireFreshMetroConfig(); expect(config?.resolver?.useWatchman).toBe(false); }); + + it('does not block the current UI workspace when the checkout itself lives under .project/worktrees', () => { + const config = requireFreshMetroConfig(); + const blockList = Array.isArray(config?.resolver?.blockList) + ? config.resolver.blockList + : [config?.resolver?.blockList].filter(Boolean); + const workspaceEntryPath = path.resolve(String(config?.projectRoot ?? ''), 'index.ts'); + + expect(blockList.some((pattern: RegExp) => pattern.test(workspaceEntryPath))).toBe(false); + }); }); From 49b04d22b25d1490ee4f3736ce270d7ec947d689 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Sat, 28 Mar 2026 13:57:47 +0100 Subject: [PATCH 73/81] fix(ui): show runtime-scoped experimental toggles --- .../(app)/settings/features.gating.spec.tsx | 25 ++++++- .../sources/app/(app)/settings/features.tsx | 70 +++++++++++++------ .../SettingsView.serversEntry.test.tsx | 8 +-- .../sync/domains/features/featureRegistry.ts | 4 +- .../features/registry/uiFeatureRegistry.ts | 4 ++ .../features/registry/uiFeatureToggles.ts | 15 +++- 6 files changed, 94 insertions(+), 32 deletions(-) diff --git a/apps/ui/sources/__tests__/routes/(app)/settings/features.gating.spec.tsx b/apps/ui/sources/__tests__/routes/(app)/settings/features.gating.spec.tsx index 127f917cb6..64ad3bc359 100644 --- a/apps/ui/sources/__tests__/routes/(app)/settings/features.gating.spec.tsx +++ b/apps/ui/sources/__tests__/routes/(app)/settings/features.gating.spec.tsx @@ -11,6 +11,7 @@ import { (globalThis as any).__DEV__ = false; const useServerFeaturesMainSelectionSnapshotMock = vi.fn(); +const useServerFeaturesRuntimeSnapshotMock = vi.fn(); const useEffectiveServerSelectionMock = vi.fn(); vi.mock('@/sync/domains/features/featureDecisionRuntime', async (importOriginal) => { @@ -18,6 +19,7 @@ vi.mock('@/sync/domains/features/featureDecisionRuntime', async (importOriginal) return { ...actual, useServerFeaturesMainSelectionSnapshot: (...args: any[]) => useServerFeaturesMainSelectionSnapshotMock(...args), + useServerFeaturesRuntimeSnapshot: (...args: any[]) => useServerFeaturesRuntimeSnapshotMock(...args), }; }); @@ -82,6 +84,7 @@ describe('FeaturesSettingsScreen gating', () => { useEffectiveServerSelectionMock.mockReturnValue({ serverIds: [] }); useServerFeaturesMainSelectionSnapshotMock.mockReturnValue({ status: 'ready', serverIds: [], snapshotsByServerId: {} }); + useServerFeaturesRuntimeSnapshotMock.mockReturnValue({ status: 'loading' }); useSettingMutableMock.mockImplementation((key: string) => { if (key === 'experiments') return createNoopMutable(true); @@ -194,12 +197,20 @@ describe('FeaturesSettingsScreen gating', () => { status: 'ready', features: createRootLayoutFeaturesResponse({ features: { - channelBridges: { enabled: true, telegram: { enabled: true } }, + voice: { enabled: true, happierVoice: { enabled: false } }, }, }), }, }, }); + useServerFeaturesRuntimeSnapshotMock.mockReturnValue({ + status: 'ready', + features: createRootLayoutFeaturesResponse({ + features: { + channelBridges: { enabled: true, telegram: { enabled: true } }, + }, + }), + }); const { default: FeaturesSettingsScreen } = await import('@/app/(app)/settings/features'); @@ -208,7 +219,7 @@ describe('FeaturesSettingsScreen gating', () => { expect(bridgeItem).toBeTruthy(); }); - it('hides the channel bridges toggle when telegram is hard-disabled by the server', async () => { + it('hides the channel bridges toggle when channel bridges are hard-disabled by the runtime server snapshot', async () => { process.env.EXPO_PUBLIC_HAPPIER_BUILD_FEATURES_ALLOW = 'channelBridges'; useEffectiveServerSelectionMock.mockReturnValue({ serverIds: ['server-1'] }); @@ -220,12 +231,20 @@ describe('FeaturesSettingsScreen gating', () => { status: 'ready', features: createRootLayoutFeaturesResponse({ features: { - channelBridges: { enabled: true, telegram: { enabled: false } }, + channelBridges: { enabled: true, telegram: { enabled: true } }, }, }), }, }, }); + useServerFeaturesRuntimeSnapshotMock.mockReturnValue({ + status: 'ready', + features: createRootLayoutFeaturesResponse({ + features: { + channelBridges: { enabled: false, telegram: { enabled: true } }, + }, + }), + }); const { default: FeaturesSettingsScreen } = await import('@/app/(app)/settings/features'); diff --git a/apps/ui/sources/app/(app)/settings/features.tsx b/apps/ui/sources/app/(app)/settings/features.tsx index f91caecf72..338018c202 100644 --- a/apps/ui/sources/app/(app)/settings/features.tsx +++ b/apps/ui/sources/app/(app)/settings/features.tsx @@ -22,11 +22,14 @@ import { buildUiFeatureToggleDefaults, listUiFeatureToggleDefinitions, resolveUiFeatureToggleEnabled, + type UiFeatureToggleDefinition, } from '@/sync/domains/features/featureRegistry'; import { getFeatureBuildPolicyDecision } from '@/sync/domains/features/featureBuildPolicy'; import { useEffectiveServerSelection } from '@/hooks/server/useEffectiveServerSelection'; -import { useServerFeaturesMainSelectionSnapshot } from '@/sync/domains/features/featureDecisionRuntime'; -import { isChannelBridgesFamilyHardDisabledByServer } from '@/components/settings/channelBridges/channelBridgesVisibility'; +import { + useServerFeaturesMainSelectionSnapshot, + useServerFeaturesRuntimeSnapshot, +} from '@/sync/domains/features/featureDecisionRuntime'; export default React.memo(function FeaturesSettingsScreen() { const { theme } = useUnistyles(); @@ -84,12 +87,20 @@ export default React.memo(function FeaturesSettingsScreen() { const shouldProbeServerForToggleVisibility = React.useMemo(() => { for (const def of toggleDefinitions) { if (getFeatureBuildPolicyDecision(def.featureId) === 'deny') continue; - if (featureRequiresServerSnapshot(def.featureId)) return true; + if (def.serverVisibilityScope === 'main_selection' && featureRequiresServerSnapshot(def.featureId)) return true; + } + return false; + }, [toggleDefinitions]); + const shouldProbeRuntimeServerForToggleVisibility = React.useMemo(() => { + for (const def of toggleDefinitions) { + if (getFeatureBuildPolicyDecision(def.featureId) === 'deny') continue; + if (def.serverVisibilityScope === 'runtime' && featureRequiresServerSnapshot(def.featureId)) return true; } return false; }, [toggleDefinitions]); const serverSnapshot = useServerFeaturesMainSelectionSnapshot(selection.serverIds, { enabled: shouldProbeServerForToggleVisibility }); + const runtimeServerSnapshot = useServerFeaturesRuntimeSnapshot({ enabled: shouldProbeRuntimeServerForToggleVisibility }); const serverProbeFeatureIdsByFeatureId = React.useMemo(() => { const memo = new Map(); @@ -128,24 +139,39 @@ export default React.memo(function FeaturesSettingsScreen() { return memo; }, [toggleDefinitions]); - const isToggleHardDisabledByServer = React.useCallback( + const isKnownFeatureId = React.useCallback((featureId: unknown): featureId is FeatureId => { + return typeof featureId === 'string' && (FEATURE_IDS as readonly string[]).includes(featureId); + }, []); + + const isRuntimeFeatureHardDisabledByServer = React.useCallback( (featureId: FeatureId): boolean => { - if (featureId === 'channelBridges') { - if (serverSnapshot.status !== 'ready') return false; - if (serverSnapshot.serverIds.length === 0) return false; - - for (const serverId of serverSnapshot.serverIds) { - const snapshot = serverSnapshot.snapshotsByServerId[serverId]; - if (!snapshot) return false; - if (snapshot.status === 'error') return false; - if (snapshot.status === 'unsupported') return true; - if (isChannelBridgesFamilyHardDisabledByServer(snapshot)) return true; - } + if (!isKnownFeatureId(featureId)) return false; + if (!featureRequiresServerSnapshot(featureId)) return false; + if (runtimeServerSnapshot.status === 'loading') return false; + if (runtimeServerSnapshot.status === 'error') return false; + if (runtimeServerSnapshot.status === 'unsupported') return true; - return false; + const serverFeatureIdsToProbe = serverProbeFeatureIdsByFeatureId.get(featureId) ?? []; + if (serverFeatureIdsToProbe.length === 0) return false; + + for (const serverFeatureId of serverFeatureIdsToProbe) { + const enabled = readServerEnabledBit(runtimeServerSnapshot.features, serverFeatureId) === true; + if (!enabled) return true; } + return false; + }, + [isKnownFeatureId, runtimeServerSnapshot, serverProbeFeatureIdsByFeatureId], + ); + + const isFeatureHardDisabledByServer = React.useCallback( + (definition: UiFeatureToggleDefinition): boolean => { + const featureId = definition.featureId; + if (!isKnownFeatureId(featureId)) return false; if (!featureRequiresServerSnapshot(featureId)) return false; + if (definition.serverVisibilityScope === 'runtime') { + return isRuntimeFeatureHardDisabledByServer(featureId); + } if (serverSnapshot.status !== 'ready') return false; if (serverSnapshot.serverIds.length === 0) return false; @@ -174,16 +200,16 @@ export default React.memo(function FeaturesSettingsScreen() { return false; }, - [serverProbeFeatureIdsByFeatureId, serverSnapshot], + [isKnownFeatureId, isRuntimeFeatureHardDisabledByServer, serverProbeFeatureIdsByFeatureId, serverSnapshot], ); const visibleToggleDefinitions = React.useMemo(() => { return toggleDefinitions.filter((d) => { if (getFeatureBuildPolicyDecision(d.featureId) === 'deny') return false; - if (isToggleHardDisabledByServer(d.featureId)) return false; + if (isFeatureHardDisabledByServer(d)) return false; return true; }); - }, [isToggleHardDisabledByServer, toggleDefinitions]); + }, [isFeatureHardDisabledByServer, toggleDefinitions]); const standardToggleDefinitions = visibleToggleDefinitions.filter((d) => !d.isExperimental); const experimentalToggleDefinitions = visibleToggleDefinitions.filter((d) => d.isExperimental); @@ -228,10 +254,12 @@ export default React.memo(function FeaturesSettingsScreen() { const embeddedTerminalDockSettingVisible = React.useMemo(() => { if (!experiments) return false; - if (isToggleHardDisabledByServer('terminal.embeddedPty')) return false; + const embeddedTerminalDockToggle = + toggleDefinitions.find((definition) => definition.featureId === 'terminal.embeddedPty') ?? null; + if (embeddedTerminalDockToggle && isFeatureHardDisabledByServer(embeddedTerminalDockToggle)) return false; if (isLocallyBlockedByDependencies('terminal.embeddedPty')) return false; return resolveUiFeatureToggleEnabled(toggleSettings, 'terminal.embeddedPty'); - }, [experiments, isToggleHardDisabledByServer, isLocallyBlockedByDependencies, toggleSettings]); + }, [experiments, isFeatureHardDisabledByServer, isLocallyBlockedByDependencies, toggleDefinitions, toggleSettings]); const embeddedTerminalDockLocationLabel = React.useMemo(() => { switch (embeddedTerminalDockLocation) { diff --git a/apps/ui/sources/components/settings/SettingsView.serversEntry.test.tsx b/apps/ui/sources/components/settings/SettingsView.serversEntry.test.tsx index 11a97b30d3..8a7c62a713 100644 --- a/apps/ui/sources/components/settings/SettingsView.serversEntry.test.tsx +++ b/apps/ui/sources/components/settings/SettingsView.serversEntry.test.tsx @@ -25,9 +25,9 @@ const shared = vi.hoisted(() => ({ const useFeatureDecisionMock = vi.hoisted(() => vi.fn()); useFeatureDecisionMock.mockImplementation((featureId: string) => { - if (featureId !== 'channelBridges') return null; + if (featureId !== 'channelBridges' && featureId !== 'channelBridges.telegram') return null; return { - featureId: 'channelBridges', + featureId, state: 'enabled', blockedBy: null, blockerCode: 'none', @@ -269,9 +269,9 @@ afterEach(() => { shared.canRequestReviewSpy.mockResolvedValue(true); useFeatureDecisionMock.mockReset(); useFeatureDecisionMock.mockImplementation((featureId: string) => { - if (featureId !== 'channelBridges') return null; + if (featureId !== 'channelBridges' && featureId !== 'channelBridges.telegram') return null; return { - featureId: 'channelBridges', + featureId, state: 'enabled', blockedBy: null, blockerCode: 'none', diff --git a/apps/ui/sources/sync/domains/features/featureRegistry.ts b/apps/ui/sources/sync/domains/features/featureRegistry.ts index c0e8df37fa..49f59446ef 100644 --- a/apps/ui/sources/sync/domains/features/featureRegistry.ts +++ b/apps/ui/sources/sync/domains/features/featureRegistry.ts @@ -1,4 +1,4 @@ -export type { UiFeatureDefinition } from './registry/uiFeatureRegistry'; +export type { UiFeatureDefinition, UiFeatureToggleServerVisibilityScope } from './registry/uiFeatureRegistry'; export { UI_FEATURE_REGISTRY, getUiFeatureDefinition } from './registry/uiFeatureRegistry'; export type { UiFeatureToggleDefinition } from './registry/uiFeatureToggles'; @@ -6,5 +6,5 @@ export { listUiFeatureToggleDefinitions, resolveUiFeatureToggleEnabled, buildUiFeatureToggleDefaults, + resolveUiFeatureToggleServerVisibilityScope, } from './registry/uiFeatureToggles'; - diff --git a/apps/ui/sources/sync/domains/features/registry/uiFeatureRegistry.ts b/apps/ui/sources/sync/domains/features/registry/uiFeatureRegistry.ts index 5df6ecd041..7bcc3cbacf 100644 --- a/apps/ui/sources/sync/domains/features/registry/uiFeatureRegistry.ts +++ b/apps/ui/sources/sync/domains/features/registry/uiFeatureRegistry.ts @@ -1,11 +1,14 @@ import type { FeatureId } from '@happier-dev/protocol'; import type { TranslationKey } from '@/text'; +export type UiFeatureToggleServerVisibilityScope = 'main_selection' | 'runtime'; + export type UiFeatureDefinition = Readonly<{ settingsToggle?: Readonly<{ showInSettings: boolean; isExperimental: boolean; defaultEnabled: boolean; + serverVisibilityScope?: UiFeatureToggleServerVisibilityScope; titleKey: TranslationKey; subtitleKey: TranslationKey; icon: Readonly<{ @@ -94,6 +97,7 @@ export const UI_FEATURE_REGISTRY = { showInSettings: true, isExperimental: true, defaultEnabled: false, + serverVisibilityScope: 'runtime', titleKey: 'settingsFeatures.expChannelBridges', subtitleKey: 'settingsFeatures.expChannelBridgesSubtitle', icon: { ioniconName: 'swap-horizontal-outline', color: '#FF9500' }, diff --git a/apps/ui/sources/sync/domains/features/registry/uiFeatureToggles.ts b/apps/ui/sources/sync/domains/features/registry/uiFeatureToggles.ts index 016c3c0ac5..72583cc28e 100644 --- a/apps/ui/sources/sync/domains/features/registry/uiFeatureToggles.ts +++ b/apps/ui/sources/sync/domains/features/registry/uiFeatureToggles.ts @@ -1,7 +1,12 @@ import type { FeatureId } from '@happier-dev/protocol'; import type { TranslationKey } from '@/text'; -import { getUiFeatureDefinition, UI_FEATURE_REGISTRY } from './uiFeatureRegistry'; +import { + getUiFeatureDefinition, + UI_FEATURE_REGISTRY, + type UiFeatureDefinition, + type UiFeatureToggleServerVisibilityScope, +} from './uiFeatureRegistry'; type FeatureToggleSettings = Readonly<{ experiments?: boolean | null | undefined; @@ -12,6 +17,7 @@ export type UiFeatureToggleDefinition = Readonly<{ featureId: FeatureId; isExperimental: boolean; defaultEnabled: boolean; + serverVisibilityScope: UiFeatureToggleServerVisibilityScope; titleKey: TranslationKey; subtitleKey: TranslationKey; icon: Readonly<{ @@ -24,12 +30,13 @@ export function listUiFeatureToggleDefinitions(): ReadonlyArray Date: Sat, 28 Mar 2026 13:58:01 +0100 Subject: [PATCH 74/81] fix(cli): validate Telegram webhook secret length --- .../channelBridgeAccountConfig.test.ts | 13 +++++++++ apps/cli/src/cli/commands/bridge.test.ts | 22 +++++++++++++++ apps/cli/src/cli/commands/bridge.ts | 4 +++ docs/channel-bridge-uat.md | 4 +++ docs/telegram-channel-bridge.md | 28 ++++++++++++++++--- 5 files changed, 67 insertions(+), 4 deletions(-) diff --git a/apps/cli/src/channels/channelBridgeAccountConfig.test.ts b/apps/cli/src/channels/channelBridgeAccountConfig.test.ts index e655545ab2..449f8c4643 100644 --- a/apps/cli/src/channels/channelBridgeAccountConfig.test.ts +++ b/apps/cli/src/channels/channelBridgeAccountConfig.test.ts @@ -20,6 +20,19 @@ describe('channelBridgeAccountConfig', () => { ).toThrow('Invalid webhookSecret: must match [A-Za-z0-9_-]'); }); + it('rejects webhook secrets that exceed Telegram maximum length', () => { + expect(() => + upsertScopedTelegramBridgeConfig({ + settings: {}, + serverId: 'local-3005', + accountId: 'acct-1', + update: { + webhookSecret: 'x'.repeat(257), + }, + }) + ).toThrow('Webhook secret token is too long'); + }); + it('writes scoped telegram config under server/account with secrets in local-only block', () => { const next = upsertScopedTelegramBridgeConfig({ settings: {}, diff --git a/apps/cli/src/cli/commands/bridge.test.ts b/apps/cli/src/cli/commands/bridge.test.ts index 1676b65931..08f18ad742 100644 --- a/apps/cli/src/cli/commands/bridge.test.ts +++ b/apps/cli/src/cli/commands/bridge.test.ts @@ -124,6 +124,28 @@ describe('happier bridge command (local-only v1)', () => { } }); + it('rejects overlong webhook secret values before persisting settings', async () => { + const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); + try { + const { handleBridgeCliCommand } = await import('./bridge'); + + await handleBridgeCliCommand({ + args: ['bridge', 'telegram', 'set', '--webhook-secret', 'x'.repeat(257)], + rawArgv: [], + terminalRuntime: null, + }); + + expect(updateSettingsMock).not.toHaveBeenCalled(); + expect(process.exitCode).toBe(1); + expect(errorSpy).toHaveBeenCalledWith( + expect.anything(), + expect.stringContaining('Webhook secret token is too long'), + ); + } finally { + errorSpy.mockRestore(); + } + }); + it('rejects explicitly passed empty --bot-token values', async () => { const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => undefined); try { diff --git a/apps/cli/src/cli/commands/bridge.ts b/apps/cli/src/cli/commands/bridge.ts index 1e5bd70a1c..ea01aa7639 100644 --- a/apps/cli/src/cli/commands/bridge.ts +++ b/apps/cli/src/cli/commands/bridge.ts @@ -16,6 +16,7 @@ import { ensureExperimentalSettingsFeatureToggleEnabled } from '@/features/setti import { readCredentials, readSettings, updateSettings } from '@/persistence'; import { argvValue } from '@/cli/commands/server/commandUtilities'; import { join } from 'node:path'; +import { TELEGRAM_WEBHOOK_SECRET_TOKEN_MAX_LENGTH } from '@/channels/providers/telegram/telegramWebhookSecretToken'; function parseBooleanInput(raw: string, flagName: string): boolean { const value = raw.trim().toLowerCase(); @@ -44,6 +45,9 @@ function parseCsvList(raw: string): string[] { } function validateTelegramWebhookSecretToken(raw: string, flagName: string): void { + if (raw.trim().length > TELEGRAM_WEBHOOK_SECRET_TOKEN_MAX_LENGTH) { + throw new Error('Webhook secret token is too long'); + } if (!/^[A-Za-z0-9_-]+$/.test(raw)) { throw new Error(`Invalid ${flagName} value: must match [A-Za-z0-9_-] (Telegram webhook token restriction)`); } diff --git a/docs/channel-bridge-uat.md b/docs/channel-bridge-uat.md index 716cdafd75..16e7140e1d 100644 --- a/docs/channel-bridge-uat.md +++ b/docs/channel-bridge-uat.md @@ -90,6 +90,10 @@ Expected: - Set `allowedChatIds` to a different chat and verify current chat is blocked. - Revert to `--allow-all` and verify chat works again. +- In a shared chat/topic, validate inbound authorization: + - User A runs `/attach ` (default owner-only). + - User B sends a normal message and verify the bot replies with an authorization error and does **not** forward into the session. + - User A runs `/attach --anyone`, then User B sends a message again and verify it **is** forwarded. - If using webhook mode, verify secret mismatch returns a non-200 response: - `404` when the URL path is wrong (route mismatch) - `401` when `X-Telegram-Bot-Api-Secret-Token` is missing/invalid diff --git a/docs/telegram-channel-bridge.md b/docs/telegram-channel-bridge.md index a23c3e7940..3a465ed5ba 100644 --- a/docs/telegram-channel-bridge.md +++ b/docs/telegram-channel-bridge.md @@ -18,6 +18,10 @@ For core bridge architecture, ingress mode matrix, and relay deployment model, s - **DM** = `thread_id = null` - **Topic** = `thread_id = ` - Outbound agent replies are forwarded back into the mapped DM/topic. +- Inbound forwarding policy is configured **per binding** at attach-time: + - Default is **owner-only** (only the user who ran `/attach` can forward messages into the session from that conversation). + - Use `/attach ... --anyone` to allow any member of a shared chat/topic to forward messages (only recommended for fully-trusted chats). + - If sender identity is missing, `/attach` and forwarding are **denied by default** (safe-by-default). You can override with `--allow-missing-sender-id` (unsafe). ## BotFather + Telegram Setup @@ -116,15 +120,20 @@ Channel bridges are gated in three places: Note: `happier bridge telegram set ...` automatically enables the local experimental toggle (`channelBridges`). -## Environment Variables (`.env.local`) +Server env keys (v1): +- `HAPPIER_FEATURE_CHANNEL_BRIDGES__ENABLED=0|1` +- `HAPPIER_FEATURE_CHANNEL_BRIDGES_TELEGRAM__ENABLED=0|1` -Set these to override `settings.json` or run env-only: +## Environment Variables (daemon process env) + +Set these on the daemon process to override `settings.json` (or to run env-only). Environment variables are read directly from `process.env` (there is no automatic `.env.local` loading for the CLI/daemon). ```bash HAPPIER_TELEGRAM_BOT_TOKEN= # Optional hardening: HAPPIER_TELEGRAM_ALLOWED_CHAT_IDS=-1001234567890,-10055555555 +HAPPIER_TELEGRAM_ALLOW_ALL_SHARED_CHATS=0 HAPPIER_TELEGRAM_REQUIRE_TOPICS=1 # Bridge tick cadence (ms) @@ -194,6 +203,7 @@ HAPPIER_TELEGRAM_WEBHOOK_PORT=8787 ``` `HAPPIER_TELEGRAM_WEBHOOK_SECRET` is used for Telegram `secret_token` header validation (`X-Telegram-Bot-Api-Secret-Token`). +The token must match Telegram’s webhook `secret_token` constraints: `[A-Za-z0-9_-]` and a max length of 256 characters. The webhook endpoint path is fixed (`POST /telegram/webhook`) and does not include any secrets. @@ -216,10 +226,14 @@ https://your-public-host/telegram/webhook Set Telegram webhook: ```bash -curl "https://api.telegram.org/bot/setWebhook?url=https://your-public-host/telegram/webhook&secret_token=" +# Recommended: use a POST body so the secret does not end up in URLs (shell history, proxy logs, etc.) +curl -X POST "https://api.telegram.org/bot/setWebhook" \ + -H "Content-Type: application/json" \ + -d '{"url":"https://your-public-host/telegram/webhook","secret_token":""}' ``` Use a high-entropy random value and avoid sharing/logging webhook secrets. +Ensure your public reverse proxy/tunnel forwards the `X-Telegram-Bot-Api-Secret-Token` header unchanged to the daemon relay. If you switch back to polling mode, clear webhook first: @@ -231,12 +245,18 @@ curl "https://api.telegram.org/bot/deleteWebhook" - `/help` — command list - `/sessions` — list active Happier sessions -- `/attach ` — bind current DM/topic to a session +- `/attach [--anyone] [--allow-missing-sender-id]` — bind current DM/topic to a session - `/session` — show current binding - `/detach` — remove binding After `/attach`, normal messages in that DM/topic are forwarded into the mapped session, and agent replies flow back into that same DM/topic. +Authorization notes: +- By default, inbound forwarding is **owner-only** (the user who attached the conversation). +- In shared chats, other users who try to send messages will receive a denial reply (and the message will not be forwarded). +- `--anyone` opts a binding into allowing messages from any sender in that conversation. +- `--allow-missing-sender-id` disables sender-based safety checks for that binding (unsafe; only use if the platform/conversation does not provide stable sender identities). + ## Extending Beyond Telegram To add another provider, implement the same adapter shape used by Telegram: From 5a5a53108ac961216115965e87ee88226e709065 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Sat, 28 Mar 2026 14:03:09 +0100 Subject: [PATCH 75/81] fix(telegram): redact bot token from transport errors --- .../telegram/telegramAdapter.test.ts | 35 ++++++++++ .../providers/telegram/telegramAdapter.ts | 69 +++++++++++++------ 2 files changed, 83 insertions(+), 21 deletions(-) diff --git a/apps/cli/src/channels/providers/telegram/telegramAdapter.test.ts b/apps/cli/src/channels/providers/telegram/telegramAdapter.test.ts index 08847e2d44..625f5d31e8 100644 --- a/apps/cli/src/channels/providers/telegram/telegramAdapter.test.ts +++ b/apps/cli/src/channels/providers/telegram/telegramAdapter.test.ts @@ -120,6 +120,41 @@ describe('createTelegramChannelAdapter', () => { ]); }); + it('redacts bot token when axios throws a transport error', async () => { + const botToken = 'secret-token-123'; + const axiosPostSpy = vi.spyOn(axios, 'post'); + + axiosPostSpy.mockImplementation(async (urlRaw: unknown) => { + const url = String(urlRaw); + if (url.includes('/getUpdates')) { + return { + status: 200, + data: { ok: true, result: [] }, + } as any; + } + + const error = new Error(`Network failure calling ${url}`); + (error as any).isAxiosError = true; + (error as any).config = { url }; + throw error; + }); + + try { + const adapter = createTelegramChannelAdapter({ + botToken, + }); + + await adapter.pullInboundMessages(); + throw new Error('Expected pullInboundMessages to fail'); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + expect(message).not.toContain(botToken); + expect(message).toContain('botREDACTED'); + } finally { + axiosPostSpy.mockRestore(); + } + }); + it('allows non-private chats when allowAllSharedChats is enabled', async () => { const api = { getMe: vi.fn(async () => ({ id: 777, username: 'happier_bot' })), diff --git a/apps/cli/src/channels/providers/telegram/telegramAdapter.ts b/apps/cli/src/channels/providers/telegram/telegramAdapter.ts index 8d21eaf50d..832fae7512 100644 --- a/apps/cli/src/channels/providers/telegram/telegramAdapter.ts +++ b/apps/cli/src/channels/providers/telegram/telegramAdapter.ts @@ -2,6 +2,7 @@ import axios from 'axios'; import { ChannelBridgePermanentDeliveryError } from '@/channels/core/channelBridgeWorker'; import type { ChannelBridgeAdapter, ChannelBridgeInboundMessage } from '@/channels/core/channelBridgeWorker'; +import { serializeAxiosErrorForLog } from '@/api/client/serializeAxiosErrorForLog'; import { logger } from '@/ui/logger'; import type { @@ -66,13 +67,29 @@ export class TelegramApiError extends Error { } } +function createTelegramTransportError(method: TelegramApiMethod, error: unknown): Error { + const serialized = serializeAxiosErrorForLog(error); + const safeDetails = { + code: typeof serialized.code === 'string' ? serialized.code : undefined, + status: typeof serialized.status === 'number' ? serialized.status : undefined, + method: typeof serialized.method === 'string' ? serialized.method : undefined, + url: typeof serialized.url === 'string' ? serialized.url : undefined, + }; + return new Error(`Telegram ${method} transport error: ${JSON.stringify(safeDetails)}`); +} + function createDefaultTelegramApiClient(botToken: string): TelegramApiClient { return { getMe: async () => { - const response = await axios.post(telegramApiUrl(botToken, 'getMe'), {}, { - timeout: 10_000, - validateStatus: () => true, - }); + let response: any; + try { + response = await axios.post(telegramApiUrl(botToken, 'getMe'), {}, { + timeout: 10_000, + validateStatus: () => true, + }); + } catch (error) { + throw createTelegramTransportError('getMe', error); + } if (response.status !== 200 || !response.data || response.data.ok !== true || !response.data.result) { throw new TelegramApiError({ method: 'getMe', @@ -87,15 +104,20 @@ function createDefaultTelegramApiClient(botToken: string): TelegramApiClient { }; }, getUpdates: async ({ offset, limit }) => { - const response = await axios.post(telegramApiUrl(botToken, 'getUpdates'), { - ...(typeof offset === 'number' ? { offset } : {}), - limit, - timeout: TELEGRAM_GET_UPDATES_LONG_POLL_TIMEOUT_SECONDS, - allowed_updates: ['message'], - }, { - timeout: TELEGRAM_GET_UPDATES_HTTP_TIMEOUT_MS, - validateStatus: () => true, - }); + let response: any; + try { + response = await axios.post(telegramApiUrl(botToken, 'getUpdates'), { + ...(typeof offset === 'number' ? { offset } : {}), + limit, + timeout: TELEGRAM_GET_UPDATES_LONG_POLL_TIMEOUT_SECONDS, + allowed_updates: ['message'], + }, { + timeout: TELEGRAM_GET_UPDATES_HTTP_TIMEOUT_MS, + validateStatus: () => true, + }); + } catch (error) { + throw createTelegramTransportError('getUpdates', error); + } if (response.status !== 200 || !response.data || response.data.ok !== true || !Array.isArray(response.data.result)) { throw new TelegramApiError({ method: 'getUpdates', @@ -110,14 +132,19 @@ function createDefaultTelegramApiClient(botToken: string): TelegramApiClient { const messageThreadId = Number.isSafeInteger(parsedThreadId) && parsedThreadId > 0 ? parsedThreadId : null; - const response = await axios.post(telegramApiUrl(botToken, 'sendMessage'), { - chat_id: chatId, - text, - ...(messageThreadId !== null ? { message_thread_id: messageThreadId } : {}), - }, { - timeout: 10_000, - validateStatus: () => true, - }); + let response: any; + try { + response = await axios.post(telegramApiUrl(botToken, 'sendMessage'), { + chat_id: chatId, + text, + ...(messageThreadId !== null ? { message_thread_id: messageThreadId } : {}), + }, { + timeout: 10_000, + validateStatus: () => true, + }); + } catch (error) { + throw createTelegramTransportError('sendMessage', error); + } if (response.status !== 200 || !response.data || response.data.ok !== true) { const apiError = new TelegramApiError({ method: 'sendMessage', From e5e7de5af1cadb5f1509a7ae5bd3a3b39c60d267 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Sat, 28 Mar 2026 14:08:06 +0100 Subject: [PATCH 76/81] test(channelBridges): add core-e2e contract --- ...geContract.feat.channelBridges.e2e.test.ts | 232 ++++++++++++++++++ packages/tests/vitest.core.config.ts | 8 + packages/tests/vitest.core.fast.config.ts | 8 + packages/tests/vitest.core.slow.config.ts | 8 + 4 files changed, 256 insertions(+) create mode 100644 packages/tests/suites/core-e2e/channelBridges.inMemoryBridgeContract.feat.channelBridges.e2e.test.ts diff --git a/packages/tests/suites/core-e2e/channelBridges.inMemoryBridgeContract.feat.channelBridges.e2e.test.ts b/packages/tests/suites/core-e2e/channelBridges.inMemoryBridgeContract.feat.channelBridges.e2e.test.ts new file mode 100644 index 0000000000..3379905c83 --- /dev/null +++ b/packages/tests/suites/core-e2e/channelBridges.inMemoryBridgeContract.feat.channelBridges.e2e.test.ts @@ -0,0 +1,232 @@ +import { describe, expect, it } from 'vitest'; + +import { + createChannelBridgeInboundDeduper, + createInMemoryChannelBindingStore, + executeChannelBridgeTick, + type ChannelBridgeAdapter, + type ChannelBridgeAgentMessageRow, + type ChannelBridgeDeps, + type ChannelBridgeInboundMessage, + type ChannelBridgeInboundMode, +} from '../../../../apps/cli/src/channels/core/channelBridgeWorker'; + +type OutboundRow = Readonly<{ conversationId: string; threadId: string | null; text: string }>; + +function createInMemoryAdapter(providerId: string) { + const inboundQueue: ChannelBridgeInboundMessage[] = []; + const outbound: OutboundRow[] = []; + + const adapter: ChannelBridgeAdapter = { + providerId, + pullInboundMessages: async () => { + const batch = inboundQueue.splice(0, inboundQueue.length); + return batch; + }, + sendMessage: async (params) => { + outbound.push({ conversationId: params.conversationId, threadId: params.threadId, text: params.text }); + }, + }; + + return { + adapter, + outbound, + pushInbound: (...messages: ChannelBridgeInboundMessage[]) => { + inboundQueue.push(...messages); + }, + } as const; +} + +function createInMemorySessionDeps(params: Readonly<{ + sessionId: string; + label?: string | null; + inboundMode: ChannelBridgeInboundMode; + ownerSenderId: string; + conversationId: string; + threadId: string | null; + providerId: string; +}>) { + const agentRows: ChannelBridgeAgentMessageRow[] = []; + let nextSeq = 0; + const forwardedUserMessages: Array> = []; + + const deps: ChannelBridgeDeps = { + listSessions: async () => [{ sessionId: params.sessionId, label: params.label ?? null }], + resolveSessionIdOrPrefix: async (idOrPrefix) => { + if (idOrPrefix === params.sessionId || params.sessionId.startsWith(idOrPrefix)) { + return { ok: true, sessionId: params.sessionId }; + } + return { ok: false, code: 'session_not_found' }; + }, + sendUserMessageToSession: async (send) => { + forwardedUserMessages.push({ + sessionId: send.sessionId, + text: send.text, + providerId: send.providerId, + sentFrom: send.sentFrom, + messageId: send.messageId, + }); + + nextSeq += 1; + agentRows.push({ seq: nextSeq, text: `echo:${send.text}` }); + }, + resolveLatestSessionSeq: async () => nextSeq, + fetchAgentMessagesAfterSeq: async ({ afterSeq }) => agentRows.filter((row) => row.seq > afterSeq), + }; + + return { + deps, + agentRows, + forwardedUserMessages, + pushAgentMessage: (text: string) => { + nextSeq += 1; + agentRows.push({ seq: nextSeq, text }); + }, + } as const; +} + +describe('core e2e: channel bridge in-memory contract', () => { + it('DM: /attach then forwards inbound message and agent output back to the channel', async () => { + const store = createInMemoryChannelBindingStore(); + const adapter = createInMemoryAdapter('test'); + const session = createInMemorySessionDeps({ + sessionId: 'sess-1', + inboundMode: 'ownerOnly', + ownerSenderId: 'user-1', + providerId: 'test', + conversationId: 'conv-1', + threadId: null, + }); + + adapter.pushInbound( + { + providerId: 'test', + conversationId: 'conv-1', + threadId: null, + senderId: 'user-1', + conversationKind: 'dm', + text: '/attach sess-1', + messageId: 'm-attach', + }, + { + providerId: 'test', + conversationId: 'conv-1', + threadId: null, + senderId: 'user-1', + conversationKind: 'dm', + text: 'hello', + messageId: 'm-hello', + }, + ); + + await executeChannelBridgeTick({ + store, + adapters: [adapter.adapter], + deps: session.deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(session.forwardedUserMessages).toEqual([ + expect.objectContaining({ sessionId: 'sess-1', text: 'hello', providerId: 'test', messageId: 'm-hello' }), + ]); + + const outbound = adapter.outbound.map((row) => row.text).join('\n'); + expect(outbound).toContain('Attached'); + expect(outbound).toContain('echo:hello'); + }); + + it('Group: default owner-only denies non-owner forwarding', async () => { + const store = createInMemoryChannelBindingStore(); + const adapter = createInMemoryAdapter('test'); + const session = createInMemorySessionDeps({ + sessionId: 'sess-1', + inboundMode: 'ownerOnly', + ownerSenderId: 'user-1', + providerId: 'test', + conversationId: 'group-1', + threadId: null, + }); + + adapter.pushInbound( + { + providerId: 'test', + conversationId: 'group-1', + threadId: null, + senderId: 'user-1', + conversationKind: 'group', + text: '/attach sess-1', + messageId: 'm-attach', + }, + { + providerId: 'test', + conversationId: 'group-1', + threadId: null, + senderId: 'user-2', + conversationKind: 'group', + text: 'pls run rm -rf /', + messageId: 'm-hijack', + }, + ); + + await executeChannelBridgeTick({ + store, + adapters: [adapter.adapter], + deps: session.deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + expect(session.forwardedUserMessages).toEqual([]); + expect(adapter.outbound.map((row) => row.text).join('\n')).toContain('not authorized'); + }); + + it('Restart-safe: advancing binding cursor prevents duplicate outbound forwards across ticks', async () => { + const store = createInMemoryChannelBindingStore(); + const adapter = createInMemoryAdapter('test'); + const session = createInMemorySessionDeps({ + sessionId: 'sess-1', + inboundMode: 'ownerOnly', + ownerSenderId: 'user-1', + providerId: 'test', + conversationId: 'conv-1', + threadId: null, + }); + + adapter.pushInbound({ + providerId: 'test', + conversationId: 'conv-1', + threadId: null, + senderId: 'user-1', + conversationKind: 'dm', + text: '/attach sess-1', + messageId: 'm-attach', + }); + + await executeChannelBridgeTick({ + store, + adapters: [adapter.adapter], + deps: session.deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + session.pushAgentMessage('agent:one'); + await executeChannelBridgeTick({ + store, + adapters: [adapter.adapter], + deps: session.deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + const firstOutboundCount = adapter.outbound.filter((row) => row.text.includes('agent:one')).length; + expect(firstOutboundCount).toBe(1); + + await executeChannelBridgeTick({ + store, + adapters: [adapter.adapter], + deps: session.deps, + inboundDeduper: createChannelBridgeInboundDeduper(), + }); + + const secondOutboundCount = adapter.outbound.filter((row) => row.text.includes('agent:one')).length; + expect(secondOutboundCount).toBe(1); + }); +}); diff --git a/packages/tests/vitest.core.config.ts b/packages/tests/vitest.core.config.ts index de7b0c5af2..0c47440346 100644 --- a/packages/tests/vitest.core.config.ts +++ b/packages/tests/vitest.core.config.ts @@ -1,8 +1,16 @@ import { defineConfig } from 'vitest/config'; +import { resolve as resolvePath } from 'node:path'; import { resolveVitestFeatureTestExcludeGlobs } from '../../scripts/testing/featureTestGating'; +const repoRoot = resolvePath(__dirname, '../..'); + export default defineConfig({ + resolve: { + alias: { + '@': resolvePath(repoRoot, 'apps/cli/src'), + }, + }, test: { environment: 'node', include: [ diff --git a/packages/tests/vitest.core.fast.config.ts b/packages/tests/vitest.core.fast.config.ts index e5196b2de3..7e76fec975 100644 --- a/packages/tests/vitest.core.fast.config.ts +++ b/packages/tests/vitest.core.fast.config.ts @@ -1,8 +1,16 @@ import { defineConfig } from 'vitest/config'; +import { resolve as resolvePath } from 'node:path'; import { resolveVitestFeatureTestExcludeGlobs } from '../../scripts/testing/featureTestGating'; +const repoRoot = resolvePath(__dirname, '../..'); + export default defineConfig({ + resolve: { + alias: { + '@': resolvePath(repoRoot, 'apps/cli/src'), + }, + }, test: { environment: 'node', include: [ diff --git a/packages/tests/vitest.core.slow.config.ts b/packages/tests/vitest.core.slow.config.ts index 9bd2d489fc..d2216022b5 100644 --- a/packages/tests/vitest.core.slow.config.ts +++ b/packages/tests/vitest.core.slow.config.ts @@ -1,8 +1,16 @@ import { defineConfig } from 'vitest/config'; +import { resolve as resolvePath } from 'node:path'; import { resolveVitestFeatureTestExcludeGlobs } from '../../scripts/testing/featureTestGating'; +const repoRoot = resolvePath(__dirname, '../..'); + export default defineConfig({ + resolve: { + alias: { + '@': resolvePath(repoRoot, 'apps/cli/src'), + }, + }, test: { environment: 'node', include: ['suites/core-e2e/**/*.slow.e2e.test.ts'], From e32aa6da9deaf3022d7860df53c78207987272d5 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Sat, 28 Mar 2026 14:21:42 +0100 Subject: [PATCH 77/81] fix(ui): hide Channel Bridges until Telegram enabled --- .../SettingsView.serversEntry.test.tsx | 35 +++++++++++++++++++ .../channelBridgesVisibility.ts | 2 +- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/apps/ui/sources/components/settings/SettingsView.serversEntry.test.tsx b/apps/ui/sources/components/settings/SettingsView.serversEntry.test.tsx index 8a7c62a713..c82f72aba1 100644 --- a/apps/ui/sources/components/settings/SettingsView.serversEntry.test.tsx +++ b/apps/ui/sources/components/settings/SettingsView.serversEntry.test.tsx @@ -385,6 +385,41 @@ describe('SettingsView', () => { expect(screen.findRowByTitle('settings.channelBridges')).toBeNull(); }); + it('hides Channel Bridges in Settings when Telegram is not enabled at runtime', async () => { + useFeatureDecisionMock.mockImplementation((featureId: string) => { + if (featureId === 'channelBridges') { + return { + featureId: 'channelBridges', + state: 'enabled', + blockedBy: null, + blockerCode: 'none', + diagnostics: [], + evaluatedAt: 0, + scope: { scopeKind: 'runtime' }, + }; + } + + if (featureId === 'channelBridges.telegram') { + return { + featureId: 'channelBridges.telegram', + state: 'disabled', + blockedBy: 'server', + blockerCode: 'feature_disabled', + diagnostics: [], + evaluatedAt: 0, + scope: { scopeKind: 'runtime' }, + }; + } + + return null; + }); + + const { SettingsView } = await import('./SettingsView'); + const screen = await renderSettingsView(React.createElement(SettingsView)); + + expect(screen.findRowByTitle('settings.channelBridges')).toBeNull(); + }); + it('routes to the in-app bug report composer by default when Report issue is pressed', async () => { const { SettingsView } = await import('./SettingsView'); const screen = await renderSettingsView(React.createElement(SettingsView)); diff --git a/apps/ui/sources/components/settings/channelBridges/channelBridgesVisibility.ts b/apps/ui/sources/components/settings/channelBridges/channelBridgesVisibility.ts index 143321b768..4d33908763 100644 --- a/apps/ui/sources/components/settings/channelBridges/channelBridgesVisibility.ts +++ b/apps/ui/sources/components/settings/channelBridges/channelBridgesVisibility.ts @@ -13,7 +13,7 @@ export function useChannelBridgesRuntimeVisibility() { const supported = channelBridgesDecision?.state !== 'unsupported'; const needsLocalEnablement = channelBridgesDecision?.blockedBy === 'local_policy'; const telegramEnabled = telegramDecision?.state === 'enabled'; - const showSettingsEntry = channelBridgesDecision?.state === 'enabled'; + const showSettingsEntry = channelBridgesDecision?.state === 'enabled' && telegramEnabled; return { channelBridgesDecision, From afc0ada49a6be4198b39f3cc49b799550fc9236b Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Sat, 28 Mar 2026 14:37:32 +0100 Subject: [PATCH 78/81] fix(telegram): accept captions and enforce requireTopics --- .../telegram/telegramAdapter.test.ts | 64 +++++++++++++++++++ .../providers/telegram/telegramAdapter.ts | 6 +- 2 files changed, 68 insertions(+), 2 deletions(-) diff --git a/apps/cli/src/channels/providers/telegram/telegramAdapter.test.ts b/apps/cli/src/channels/providers/telegram/telegramAdapter.test.ts index 625f5d31e8..f565bbb4c1 100644 --- a/apps/cli/src/channels/providers/telegram/telegramAdapter.test.ts +++ b/apps/cli/src/channels/providers/telegram/telegramAdapter.test.ts @@ -75,6 +75,70 @@ describe('createTelegramChannelAdapter', () => { ]); }); + it('parses inbound messages from captions when text is missing', async () => { + const api = { + getMe: vi.fn(async () => ({ id: 777, username: 'happier_bot' })), + getUpdates: vi.fn(async () => ([ + { + update_id: 101, + message: { + message_id: 5001, + caption: 'caption says hello', + chat: { id: 1234, type: 'private' }, + from: { id: 42, is_bot: false, first_name: 'Ada' }, + }, + }, + ])), + sendMessage: vi.fn(async () => undefined), + }; + + const adapter = createTelegramChannelAdapter({ + botToken: 'test-token', + api, + }); + + const inbound = await adapter.pullInboundMessages(); + expect(inbound).toEqual([ + { + providerId: 'telegram', + conversationId: '1234', + threadId: null, + senderId: '42', + conversationKind: 'dm', + text: 'caption says hello', + messageId: '5001', + }, + ]); + }); + + it('rejects plain groups when requireTopics is enabled', async () => { + const api = { + getMe: vi.fn(async () => ({ id: 777, username: 'happier_bot' })), + getUpdates: vi.fn(async () => ([ + { + update_id: 101, + message: { + message_id: 5001, + text: 'hello from plain group', + chat: { id: -555, type: 'group' }, + from: { id: 42, is_bot: false, first_name: 'Ada' }, + }, + }, + ])), + sendMessage: vi.fn(async () => undefined), + }; + + const adapter = createTelegramChannelAdapter({ + botToken: 'test-token', + api, + allowAllSharedChats: true, + requireTopics: true, + }); + + const inbound = await adapter.pullInboundMessages(); + expect(inbound).toEqual([]); + }); + it('ignores non-private chats by default unless allowlisted or allowAllSharedChats is enabled', async () => { const api = { getMe: vi.fn(async () => ({ id: 777, username: 'happier_bot' })), diff --git a/apps/cli/src/channels/providers/telegram/telegramAdapter.ts b/apps/cli/src/channels/providers/telegram/telegramAdapter.ts index 832fae7512..7bdf77472f 100644 --- a/apps/cli/src/channels/providers/telegram/telegramAdapter.ts +++ b/apps/cli/src/channels/providers/telegram/telegramAdapter.ts @@ -188,7 +188,9 @@ function parseInboundFromUpdate(params: Readonly<{ const rawMessage = asRecord(update.message); if (!rawMessage) return null; - const text = typeof rawMessage.text === 'string' ? rawMessage.text.trim() : ''; + const rawText = typeof rawMessage.text === 'string' ? rawMessage.text.trim() : ''; + const rawCaption = typeof rawMessage.caption === 'string' ? rawMessage.caption.trim() : ''; + const text = rawText || rawCaption; if (!text) return null; const chat = asRecord(rawMessage.chat); @@ -225,7 +227,7 @@ function parseInboundFromUpdate(params: Readonly<{ : null; if (params.requireTopics) { - if (chatType === 'supergroup' && threadId === null) { + if (!isPrivateChat && (chatType !== 'supergroup' || threadId === null)) { return null; } } From 5c520eadd97d97fdfff21c5c26d3c4ff3b68e257 Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Sat, 28 Mar 2026 14:39:05 +0100 Subject: [PATCH 79/81] refactor(cli): reuse Telegram webhook secret validator --- apps/cli/src/cli/commands/bridge.ts | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/apps/cli/src/cli/commands/bridge.ts b/apps/cli/src/cli/commands/bridge.ts index ea01aa7639..a166bf2d78 100644 --- a/apps/cli/src/cli/commands/bridge.ts +++ b/apps/cli/src/cli/commands/bridge.ts @@ -16,7 +16,7 @@ import { ensureExperimentalSettingsFeatureToggleEnabled } from '@/features/setti import { readCredentials, readSettings, updateSettings } from '@/persistence'; import { argvValue } from '@/cli/commands/server/commandUtilities'; import { join } from 'node:path'; -import { TELEGRAM_WEBHOOK_SECRET_TOKEN_MAX_LENGTH } from '@/channels/providers/telegram/telegramWebhookSecretToken'; +import { assertTelegramWebhookSecretToken } from '@/channels/providers/telegram/telegramWebhookSecretToken'; function parseBooleanInput(raw: string, flagName: string): boolean { const value = raw.trim().toLowerCase(); @@ -44,15 +44,6 @@ function parseCsvList(raw: string): string[] { .filter((value) => value.length > 0); } -function validateTelegramWebhookSecretToken(raw: string, flagName: string): void { - if (raw.trim().length > TELEGRAM_WEBHOOK_SECRET_TOKEN_MAX_LENGTH) { - throw new Error('Webhook secret token is too long'); - } - if (!/^[A-Za-z0-9_-]+$/.test(raw)) { - throw new Error(`Invalid ${flagName} value: must match [A-Za-z0-9_-] (Telegram webhook token restriction)`); - } -} - function maskSecret(value: string): string { if (!value.trim()) return ''; return `<${value.length} chars>`; @@ -284,8 +275,11 @@ async function cmdTelegramSet(args: string[]): Promise { update.webhookEnabled = parseBooleanInput(webhookEnabledRaw, '--webhook-enabled'); } if (webhookSecret) { - validateTelegramWebhookSecretToken(webhookSecret, '--webhook-secret'); - update.webhookSecret = webhookSecret; + update.webhookSecret = assertTelegramWebhookSecretToken(webhookSecret, { + empty: 'Invalid --webhook-secret value: cannot be empty', + tooLong: 'Webhook secret token is too long', + invalid: 'Invalid --webhook-secret value: must match [A-Za-z0-9_-] (Telegram webhook token restriction)', + }); } if (webhookHost) { if (!isLoopbackHostname(webhookHost)) { From a8f2b08e627ed97587b3d38f8d61bbe4c715d19a Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Sat, 28 Mar 2026 14:40:44 +0100 Subject: [PATCH 80/81] refactor(channelBridges): share filesystem-safe accountId validator --- .../telegram/telegramPollingCursorStore.ts | 19 +----------------- .../telegram/telegramWebhookUpdateStore.ts | 19 +----------------- .../state/assertFilesystemSafeAccountId.ts | 19 ++++++++++++++++++ .../src/channels/state/localBindingStore.ts | 20 +------------------ 4 files changed, 22 insertions(+), 55 deletions(-) create mode 100644 apps/cli/src/channels/state/assertFilesystemSafeAccountId.ts diff --git a/apps/cli/src/channels/providers/telegram/telegramPollingCursorStore.ts b/apps/cli/src/channels/providers/telegram/telegramPollingCursorStore.ts index 1444d02a19..e17733dda3 100644 --- a/apps/cli/src/channels/providers/telegram/telegramPollingCursorStore.ts +++ b/apps/cli/src/channels/providers/telegram/telegramPollingCursorStore.ts @@ -3,6 +3,7 @@ import { chmod, mkdir, readFile } from 'node:fs/promises'; import { join } from 'node:path'; import { configuration } from '@/configuration'; +import { assertFilesystemSafeAccountId } from '@/channels/state/assertFilesystemSafeAccountId'; import { writeJsonAtomic } from '@/utils/fs/writeJsonAtomic'; import { logger } from '@/ui/logger'; @@ -13,30 +14,12 @@ type StoredTelegramPollingCursorDocV1 = Readonly<{ const STORE_SCHEMA_VERSION = 1; const BOT_TOKEN_HASH_LENGTH = 32; -const ACCOUNT_ID_SAFE_RE = /^[A-Za-z0-9._-]{1,128}$/; function asRecord(value: unknown): Record | null { if (!value || typeof value !== 'object' || Array.isArray(value)) return null; return value as Record; } -function assertFilesystemSafeAccountId(raw: string): string { - const value = raw.trim(); - if (!value) { - throw new Error('Invalid accountId: empty'); - } - if (value === '.' || value === '..') { - throw new Error(`Invalid accountId: ${value}`); - } - if (value.includes('/') || value.includes('\\')) { - throw new Error(`Invalid accountId: ${value}`); - } - if (!ACCOUNT_ID_SAFE_RE.test(value)) { - throw new Error(`Invalid accountId: ${value}`); - } - return value; -} - function toNonNegativeInt(value: unknown): number | null { if (typeof value !== 'number' || !Number.isFinite(value)) return null; const truncated = Math.trunc(value); diff --git a/apps/cli/src/channels/providers/telegram/telegramWebhookUpdateStore.ts b/apps/cli/src/channels/providers/telegram/telegramWebhookUpdateStore.ts index 1b72cb59d4..e730ff6345 100644 --- a/apps/cli/src/channels/providers/telegram/telegramWebhookUpdateStore.ts +++ b/apps/cli/src/channels/providers/telegram/telegramWebhookUpdateStore.ts @@ -3,6 +3,7 @@ import { chmod, mkdir, readFile } from 'node:fs/promises'; import { join } from 'node:path'; import { configuration } from '@/configuration'; +import { assertFilesystemSafeAccountId } from '@/channels/state/assertFilesystemSafeAccountId'; import { writeJsonAtomic } from '@/utils/fs/writeJsonAtomic'; import { logger } from '@/ui/logger'; @@ -34,30 +35,12 @@ type StoredTelegramWebhookUpdateDocV1 = Readonly<{ const STORE_SCHEMA_VERSION = 1; const BOT_TOKEN_HASH_LENGTH = 32; -const ACCOUNT_ID_SAFE_RE = /^[A-Za-z0-9._-]{1,128}$/; function asRecord(value: unknown): RecordLike | null { if (!value || typeof value !== 'object' || Array.isArray(value)) return null; return value as RecordLike; } -function assertFilesystemSafeAccountId(raw: string): string { - const value = raw.trim(); - if (!value) { - throw new Error('Invalid accountId: empty'); - } - if (value === '.' || value === '..') { - throw new Error(`Invalid accountId: ${value}`); - } - if (value.includes('/') || value.includes('\\')) { - throw new Error(`Invalid accountId: ${value}`); - } - if (!ACCOUNT_ID_SAFE_RE.test(value)) { - throw new Error(`Invalid accountId: ${value}`); - } - return value; -} - function toNonNegativeInt(value: unknown): number | null { if (typeof value !== 'number' || !Number.isFinite(value)) return null; const truncated = Math.trunc(value); diff --git a/apps/cli/src/channels/state/assertFilesystemSafeAccountId.ts b/apps/cli/src/channels/state/assertFilesystemSafeAccountId.ts new file mode 100644 index 0000000000..c43b116472 --- /dev/null +++ b/apps/cli/src/channels/state/assertFilesystemSafeAccountId.ts @@ -0,0 +1,19 @@ +const ACCOUNT_ID_SAFE_RE = /^[A-Za-z0-9._-]{1,128}$/; + +export function assertFilesystemSafeAccountId(raw: string): string { + const value = raw.trim(); + if (!value) { + throw new Error('Invalid accountId: empty'); + } + if (value === '.' || value === '..') { + throw new Error(`Invalid accountId: ${value}`); + } + if (value.includes('/') || value.includes('\\')) { + throw new Error(`Invalid accountId: ${value}`); + } + if (!ACCOUNT_ID_SAFE_RE.test(value)) { + throw new Error(`Invalid accountId: ${value}`); + } + return value; +} + diff --git a/apps/cli/src/channels/state/localBindingStore.ts b/apps/cli/src/channels/state/localBindingStore.ts index 5562c318bf..85c72aa1c1 100644 --- a/apps/cli/src/channels/state/localBindingStore.ts +++ b/apps/cli/src/channels/state/localBindingStore.ts @@ -3,6 +3,7 @@ import { join } from 'node:path'; import type { ChannelBindingStore, ChannelBridgeConversationRef, ChannelSessionBinding } from '@/channels/core/channelBridgeWorker'; import { configuration } from '@/configuration'; +import { assertFilesystemSafeAccountId } from '@/channels/state/assertFilesystemSafeAccountId'; import { writeJsonAtomic } from '@/utils/fs/writeJsonAtomic'; import { logger } from '@/ui/logger'; @@ -24,25 +25,6 @@ type StoredBindingsDocV1 = Readonly<{ }>>; }>; -const ACCOUNT_ID_SAFE_RE = /^[A-Za-z0-9._-]{1,128}$/; - -function assertFilesystemSafeAccountId(raw: string): string { - const value = raw.trim(); - if (!value) { - throw new Error('Invalid accountId: empty'); - } - if (value === '.' || value === '..') { - throw new Error(`Invalid accountId: ${value}`); - } - if (value.includes('/') || value.includes('\\')) { - throw new Error(`Invalid accountId: ${value}`); - } - if (!ACCOUNT_ID_SAFE_RE.test(value)) { - throw new Error(`Invalid accountId: ${value}`); - } - return value; -} - function asRecord(value: unknown): RecordLike | null { if (!value || typeof value !== 'object' || Array.isArray(value)) return null; return value as RecordLike; From dcaeff1291bdafd128b86a03cec6f23f0cba579f Mon Sep 17 00:00:00 2001 From: Leeroy Brun Date: Sat, 28 Mar 2026 14:43:14 +0100 Subject: [PATCH 81/81] feat(telegram): parse channel posts and sender_chat identity --- .../telegram/telegramAdapter.test.ts | 37 +++++++++++++++++++ .../providers/telegram/telegramAdapter.ts | 23 +++++++++--- 2 files changed, 55 insertions(+), 5 deletions(-) diff --git a/apps/cli/src/channels/providers/telegram/telegramAdapter.test.ts b/apps/cli/src/channels/providers/telegram/telegramAdapter.test.ts index f565bbb4c1..1c6cb1528e 100644 --- a/apps/cli/src/channels/providers/telegram/telegramAdapter.test.ts +++ b/apps/cli/src/channels/providers/telegram/telegramAdapter.test.ts @@ -139,6 +139,43 @@ describe('createTelegramChannelAdapter', () => { expect(inbound).toEqual([]); }); + it('parses channel_post updates and uses sender_chat id as sender identity', async () => { + const api = { + getMe: vi.fn(async () => ({ id: 777, username: 'happier_bot' })), + getUpdates: vi.fn(async () => ([ + { + update_id: 101, + channel_post: { + message_id: 5001, + text: 'hello from channel', + chat: { id: -100999, type: 'channel' }, + sender_chat: { id: -100999, type: 'channel', title: 'News Channel' }, + }, + }, + ])), + sendMessage: vi.fn(async () => undefined), + }; + + const adapter = createTelegramChannelAdapter({ + botToken: 'test-token', + api, + allowAllSharedChats: true, + }); + + const inbound = await adapter.pullInboundMessages(); + expect(inbound).toEqual([ + { + providerId: 'telegram', + conversationId: '-100999', + threadId: null, + senderId: '-100999', + conversationKind: 'channel', + text: 'hello from channel', + messageId: '5001', + }, + ]); + }); + it('ignores non-private chats by default unless allowlisted or allowAllSharedChats is enabled', async () => { const api = { getMe: vi.fn(async () => ({ id: 777, username: 'happier_bot' })), diff --git a/apps/cli/src/channels/providers/telegram/telegramAdapter.ts b/apps/cli/src/channels/providers/telegram/telegramAdapter.ts index 7bdf77472f..87d4dbf8f6 100644 --- a/apps/cli/src/channels/providers/telegram/telegramAdapter.ts +++ b/apps/cli/src/channels/providers/telegram/telegramAdapter.ts @@ -110,7 +110,7 @@ function createDefaultTelegramApiClient(botToken: string): TelegramApiClient { ...(typeof offset === 'number' ? { offset } : {}), limit, timeout: TELEGRAM_GET_UPDATES_LONG_POLL_TIMEOUT_SECONDS, - allowed_updates: ['message'], + allowed_updates: ['message', 'channel_post'], }, { timeout: TELEGRAM_GET_UPDATES_HTTP_TIMEOUT_MS, validateStatus: () => true, @@ -185,7 +185,7 @@ function parseInboundFromUpdate(params: Readonly<{ }>): ChannelBridgeInboundMessage | null { const update = asRecord(params.update); if (!update) return null; - const rawMessage = asRecord(update.message); + const rawMessage = asRecord(update.message) ?? asRecord(update.channel_post); if (!rawMessage) return null; const rawText = typeof rawMessage.text === 'string' ? rawMessage.text.trim() : ''; @@ -233,10 +233,23 @@ function parseInboundFromUpdate(params: Readonly<{ } const sender = asRecord(rawMessage.from); - const senderId = + const senderChat = asRecord(rawMessage.sender_chat); + const senderIdFrom = sender && typeof sender.id === 'number' && Number.isFinite(sender.id) ? Math.trunc(sender.id) : null; + const senderChatId = + senderChat && typeof senderChat.id === 'number' && Number.isFinite(senderChat.id) + ? Math.trunc(senderChat.id) + : senderChat && typeof senderChat.id === 'string' + ? senderChat.id.trim() + : null; + const senderId = + senderIdFrom !== null + ? String(senderIdFrom) + : senderChatId !== null + ? String(senderChatId) + : null; const senderIsBot = sender?.is_bot === true; - if (senderIsBot && senderId !== null && params.selfBotId !== null && senderId === params.selfBotId) { + if (senderIsBot && senderIdFrom !== null && params.selfBotId !== null && senderIdFrom === params.selfBotId) { return null; } @@ -250,7 +263,7 @@ function parseInboundFromUpdate(params: Readonly<{ providerId: 'telegram', conversationId, threadId, - senderId: senderId === null ? null : String(senderId), + senderId, conversationKind, text, messageId,