diff --git a/src/server/infra/process/feature-handlers.ts b/src/server/infra/process/feature-handlers.ts index ea387eb39..bf3f94792 100644 --- a/src/server/infra/process/feature-handlers.ts +++ b/src/server/infra/process/feature-handlers.ts @@ -68,6 +68,7 @@ import {createTokenStore} from '../storage/token-store.js' import {HttpTeamService} from '../team/http-team-service.js' import {FsTemplateLoader} from '../template/fs-template-loader.js' import { + AnalyticsDisclosureHandler, AnalyticsHandler, AnalyticsListHandler, AnalyticsStatusHandler, @@ -300,6 +301,10 @@ export async function setupFeatureHandlers({ // (TUI, oclif, MCP, webui) to the same singleton. new AnalyticsHandler({analyticsClient, transport}).setup() + // Serves the canonical analytics disclosure markdown to the local + // web UI so it renders the same text as the CLI consent prompt. + new AnalyticsDisclosureHandler({transport}).setup() + // Global SettingsHandler (no project context). Deferred from line 180 so // analyticsClient is in scope for M15.4 `setting_changed` / `setting_reset` // emits. M16.3 wires the `analytics.status` readonly-info provider so diff --git a/src/server/infra/transport/handlers/analytics-disclosure-handler.ts b/src/server/infra/transport/handlers/analytics-disclosure-handler.ts new file mode 100644 index 000000000..0f958990c --- /dev/null +++ b/src/server/infra/transport/handlers/analytics-disclosure-handler.ts @@ -0,0 +1,44 @@ +import type {ITransportServer} from '../../../core/interfaces/transport/i-transport-server.js' + +import { + type AnalyticsDisclosureResponse, + type AnalyticsDisclosureSection, + AnalyticsEvents, +} from '../../../../shared/transport/events/analytics-events.js' +import {loadAnalyticsDisclosureText} from '../../../../shared/utils/load-analytics-disclosure.js' +import {parseAnalyticsDisclosure} from '../../../../shared/utils/parse-analytics-disclosure.js' + +export interface AnalyticsDisclosureHandlerDeps { + readonly loadDisclosure?: () => Promise + readonly transport: ITransportServer +} + +export class AnalyticsDisclosureHandler { + private cachedSections: AnalyticsDisclosureSection[] | undefined + private readonly loadDisclosure: () => Promise + private readonly transport: ITransportServer + + public constructor(deps: AnalyticsDisclosureHandlerDeps) { + this.loadDisclosure = deps.loadDisclosure ?? loadAnalyticsDisclosureText + this.transport = deps.transport + } + + public setup(): void { + this.transport.onRequest(AnalyticsEvents.GET_DISCLOSURE, async () => ({ + sections: await this.getSections(), + })) + } + + private async getSections(): Promise { + if (this.cachedSections) return this.cachedSections + + const markdown = await this.loadDisclosure() + const sections = parseAnalyticsDisclosure(markdown) + if (sections.length === 0) { + throw new Error('Analytics disclosure is missing or contains no sections.') + } + + this.cachedSections = sections + return sections + } +} diff --git a/src/server/infra/transport/handlers/index.ts b/src/server/infra/transport/handlers/index.ts index c842b5192..a6103f4fa 100644 --- a/src/server/infra/transport/handlers/index.ts +++ b/src/server/infra/transport/handlers/index.ts @@ -1,3 +1,5 @@ +export {AnalyticsDisclosureHandler} from './analytics-disclosure-handler.js' +export type {AnalyticsDisclosureHandlerDeps} from './analytics-disclosure-handler.js' export {AnalyticsHandler} from './analytics-handler.js' export type {AnalyticsHandlerDeps} from './analytics-handler.js' export {AnalyticsListHandler} from './analytics-list-handler.js' diff --git a/src/shared/assets/analytics-disclosure.md b/src/shared/assets/analytics-disclosure.md index 9698162ed..0dadc35f7 100644 --- a/src/shared/assets/analytics-disclosure.md +++ b/src/shared/assets/analytics-disclosure.md @@ -43,7 +43,7 @@ is permanently linked to your ByteRover account. You can stop sharing at any time by running: -``` +```bash brv settings set analytics.share false ``` @@ -53,4 +53,4 @@ You can also toggle the `analytics.share` setting from the Settings page in the For full details on how ByteRover handles your data, see: -https://byterover.dev/privacy +https://byterover.dev/services/privacy diff --git a/src/shared/constants/privacy.ts b/src/shared/constants/privacy.ts index d070455da..cc939b86c 100644 --- a/src/shared/constants/privacy.ts +++ b/src/shared/constants/privacy.ts @@ -1,6 +1 @@ -/** - * Public privacy policy URL for ByteRover CLI analytics. - * Placeholder until M1.5 lands the canonical docs page; reviewers should - * update this constant when the byterover-docs URL is finalized. - */ -export const PRIVACY_POLICY_URL = 'https://byterover.dev/privacy' +export const PRIVACY_POLICY_URL = 'https://www.byterover.dev/services/privacy' diff --git a/src/shared/transport/events/analytics-events.ts b/src/shared/transport/events/analytics-events.ts index 141e6c413..c516a2cb5 100644 --- a/src/shared/transport/events/analytics-events.ts +++ b/src/shared/transport/events/analytics-events.ts @@ -4,11 +4,30 @@ import {CliRequestBaseSchema} from '../../analytics/cli-metadata-schema.js' import {StoredAnalyticsRecordSchema} from '../../analytics/stored-record.js' export const AnalyticsEvents = { + GET_DISCLOSURE: 'analytics:getDisclosure', LIST: 'analytics:list', STATUS: 'analytics:status', TRACK: 'analytics:track', } as const +/** + * Response schema for `analytics:getDisclosure`. Sections are parsed + * daemon-side from `src/shared/assets/analytics-disclosure.md` (one entry per + * `## H2` heading) so the local web UI can render them in its icon-grid + * layout. Single source of truth stays the markdown file. + */ +export const AnalyticsDisclosureSectionSchema = z.object({ + body: z.string().min(1), + label: z.string().min(1), +}) + +export const AnalyticsDisclosureResponseSchema = z.object({ + sections: z.array(AnalyticsDisclosureSectionSchema).min(1), +}) + +export type AnalyticsDisclosureSection = z.infer +export type AnalyticsDisclosureResponse = z.infer + /** * M4.6 `analytics:status` response. Surfaces operational metrics for * `brv settings get analytics.status`: enabled flag (from GlobalConfig), client diff --git a/src/shared/utils/parse-analytics-disclosure.ts b/src/shared/utils/parse-analytics-disclosure.ts new file mode 100644 index 000000000..6dbce363f --- /dev/null +++ b/src/shared/utils/parse-analytics-disclosure.ts @@ -0,0 +1,33 @@ +export type AnalyticsDisclosureSection = { + body: string + label: string +} + +const H2_PATTERN = /^##\s+(.+?)\s*$/ + +export function parseAnalyticsDisclosure(markdown: string): AnalyticsDisclosureSection[] { + const sections: AnalyticsDisclosureSection[] = [] + let currentLabel: string | undefined + let currentBodyLines: string[] = [] + + function pushCurrent() { + if (currentLabel === undefined) return + const body = currentBodyLines.join('\n').trim() + if (body.length > 0) sections.push({body, label: currentLabel}) + } + + for (const line of markdown.split('\n')) { + const match = H2_PATTERN.exec(line) + if (match) { + pushCurrent() + currentLabel = match[1] + currentBodyLines = [] + continue + } + + if (currentLabel !== undefined) currentBodyLines.push(line) + } + + pushCurrent() + return sections +} diff --git a/src/webui/features/analytics/api/get-analytics-disclosure.ts b/src/webui/features/analytics/api/get-analytics-disclosure.ts new file mode 100644 index 000000000..0acc42f3b --- /dev/null +++ b/src/webui/features/analytics/api/get-analytics-disclosure.ts @@ -0,0 +1,32 @@ +import {queryOptions, useQuery} from '@tanstack/react-query' + +import type {QueryConfig} from '../../../lib/react-query' + +import { + type AnalyticsDisclosureResponse, + AnalyticsEvents, +} from '../../../../shared/transport/events/analytics-events.js' +import {useTransportStore} from '../../../stores/transport-store' + +export const getAnalyticsDisclosure = (): Promise => { + const {apiClient} = useTransportStore.getState() + if (!apiClient) return Promise.reject(new Error('Not connected')) + return apiClient.request(AnalyticsEvents.GET_DISCLOSURE) +} + +export const getAnalyticsDisclosureQueryOptions = () => + queryOptions({ + queryFn: getAnalyticsDisclosure, + queryKey: ['analyticsDisclosure'], + staleTime: Number.POSITIVE_INFINITY, + }) + +type UseGetAnalyticsDisclosureOptions = { + queryConfig?: QueryConfig +} + +export const useGetAnalyticsDisclosure = ({queryConfig}: UseGetAnalyticsDisclosureOptions = {}) => + useQuery({ + ...getAnalyticsDisclosureQueryOptions(), + ...queryConfig, + }) diff --git a/src/webui/features/analytics/components/analytics-panel.tsx b/src/webui/features/analytics/components/analytics-panel.tsx index 16add8131..279f01053 100644 --- a/src/webui/features/analytics/components/analytics-panel.tsx +++ b/src/webui/features/analytics/components/analytics-panel.tsx @@ -5,11 +5,11 @@ import {ChevronDown, ExternalLink, ShieldCheck} from 'lucide-react' import {useState} from 'react' import {toast} from 'sonner' +import {PRIVACY_POLICY_URL} from '../../../../shared/constants/privacy.js' import {formatError} from '../../../lib/error-messages' import {noop} from '../../../lib/noop' import {useGetGlobalConfig} from '../api/get-global-config' import {useSetAnalytics} from '../api/set-analytics' -import {ANALYTICS_PRIVACY_URL} from '../constants' import {DisableConfirmDialog} from './disable-confirm-dialog' import {DisclosureDetails} from './disclosure-details' import {EnableConfirmDialog} from './enable-confirm-dialog' @@ -103,12 +103,12 @@ export function AnalyticsPanel() { - docs.byterover.dev/privacy + {PRIVACY_POLICY_URL.replace(/^https?:\/\/(www\.)?/, '')} )} diff --git a/src/webui/features/analytics/components/disclosure-details.tsx b/src/webui/features/analytics/components/disclosure-details.tsx index 38414d4f4..28da9d9f2 100644 --- a/src/webui/features/analytics/components/disclosure-details.tsx +++ b/src/webui/features/analytics/components/disclosure-details.tsx @@ -1,18 +1,75 @@ -import {ANALYTICS_DISCLOSURE_SECTIONS} from '../constants' +import {Skeleton} from '@campfirein/byterover-packages/components/skeleton' +import {Database, Eye, Info, Link2, type LucideIcon, PowerOff, Server} from 'lucide-react' + +import type {AnalyticsDisclosureSection} from '../../../../shared/transport/events/analytics-events.js' + +import {formatError} from '../../../lib/error-messages' +import {noop} from '../../../lib/noop' +import {MarkdownView} from '../../context/components/markdown-view' +import {useGetAnalyticsDisclosure} from '../api/get-analytics-disclosure' + +const SECTION_ICONS: Record = { + 'cross-device alias': Link2, + 'how to disable': PowerOff, + 'what is collected': Database, + 'where it goes': Server, + 'which surfaces are tracked': Eye, +} + +function iconForLabel(label: string): LucideIcon { + return SECTION_ICONS[label.trim().toLowerCase()] ?? Info +} + +function isVisibleSection(section: AnalyticsDisclosureSection): boolean { + return !section.label.trim().toLowerCase().includes('privacy') +} export function DisclosureDetails() { + const {data, error, isError, isLoading, refetch} = useGetAnalyticsDisclosure() + + if (isLoading) { + return ( +
+ {['a', 'b', 'c', 'd'].map((slot) => ( +
+ + + +
+ ))} +
+ ) + } + + if (isError) { + return ( +

+ ✗ {formatError(error, 'Failed to load disclosure')} + {' · '} + +

+ ) + } + + const sections = (data?.sections ?? []).filter((section) => isVisibleSection(section)) + return (
- {ANALYTICS_DISCLOSURE_SECTIONS.map((section) => { - const Icon = section.icon + {sections.map((section) => { + const Icon = iconForLabel(section.label) return (
- +
- + {section.label} -

{section.body}

+
) diff --git a/src/webui/features/analytics/constants.ts b/src/webui/features/analytics/constants.ts deleted file mode 100644 index 0e5d2af7c..000000000 --- a/src/webui/features/analytics/constants.ts +++ /dev/null @@ -1,39 +0,0 @@ -import type {LucideIcon} from 'lucide-react' - -import {Database, Eye, Link2, PowerOff, Server} from 'lucide-react' - -export type AnalyticsDisclosureSection = { - body: string - icon: LucideIcon - label: string -} - -export const ANALYTICS_PRIVACY_URL = 'https://docs.byterover.dev/privacy' - -export const ANALYTICS_DISCLOSURE_SECTIONS: readonly AnalyticsDisclosureSection[] = [ - { - body: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore.', - icon: Database, - label: 'WHAT IS COLLECTED', - }, - { - body: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore.', - icon: Eye, - label: 'WHICH SURFACES ARE TRACKED', - }, - { - body: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore.', - icon: Server, - label: 'WHERE IT GOES', - }, - { - body: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore.', - icon: Link2, - label: 'CROSS-DEVICE ALIAS', - }, - { - body: 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore.', - icon: PowerOff, - label: 'HOW TO DISABLE', - }, -] as const diff --git a/test/unit/server/infra/transport/handlers/analytics-disclosure-handler.test.ts b/test/unit/server/infra/transport/handlers/analytics-disclosure-handler.test.ts new file mode 100644 index 000000000..071c3e2ff --- /dev/null +++ b/test/unit/server/infra/transport/handlers/analytics-disclosure-handler.test.ts @@ -0,0 +1,81 @@ +import {expect} from 'chai' + +import {AnalyticsDisclosureHandler} from '../../../../../../src/server/infra/transport/handlers/analytics-disclosure-handler.js' +import {AnalyticsEvents} from '../../../../../../src/shared/transport/events/analytics-events.js' +import {createMockTransportServer} from '../../../../../helpers/mock-factories.js' + +type DisclosureHandler = ( + data: unknown, + clientId: string, +) => Promise<{sections: Array<{body: string; label: string}>}> + +const FIXTURE = ['## What is collected', 'Event names.', '', '## How to disable', 'Toggle off.'].join('\n') + +describe('AnalyticsDisclosureHandler', () => { + it('registers a handler for analytics:getDisclosure on setup()', () => { + const transport = createMockTransportServer() + new AnalyticsDisclosureHandler({loadDisclosure: async () => FIXTURE, transport}).setup() + expect(transport._handlers.has(AnalyticsEvents.GET_DISCLOSURE)).to.equal(true) + }) + + it('returns parsed sections from the markdown loaded by the injected loader', async () => { + const transport = createMockTransportServer() + new AnalyticsDisclosureHandler({loadDisclosure: async () => FIXTURE, transport}).setup() + + const handler = transport._handlers.get(AnalyticsEvents.GET_DISCLOSURE) as DisclosureHandler + const result = await handler(undefined, 'client-1') + + expect(result).to.deep.equal({ + sections: [ + {body: 'Event names.', label: 'What is collected'}, + {body: 'Toggle off.', label: 'How to disable'}, + ], + }) + }) + + it('loads and parses the markdown once, then serves the cached sections', async () => { + const transport = createMockTransportServer() + let calls = 0 + const loadDisclosure = async () => { + calls += 1 + return FIXTURE + } + + new AnalyticsDisclosureHandler({loadDisclosure, transport}).setup() + + const handler = transport._handlers.get(AnalyticsEvents.GET_DISCLOSURE) as DisclosureHandler + await handler(undefined, 'client-1') + await handler(undefined, 'client-2') + await handler(undefined, 'client-3') + + expect(calls).to.equal(1) + }) + + it('throws when the markdown has no H2 sections so the webui surfaces an error state', async () => { + const transport = createMockTransportServer() + new AnalyticsDisclosureHandler({loadDisclosure: async () => '# Only H1\n\nIntro.', transport}).setup() + + const handler = transport._handlers.get(AnalyticsEvents.GET_DISCLOSURE) as DisclosureHandler + await handler(undefined, 'client-1').then( + () => expect.fail('expected promise to reject'), + (error: Error) => expect(error.message).to.include('no sections'), + ) + }) + + it('propagates loader errors so the daemon does not silently serve empty disclosure', async () => { + const transport = createMockTransportServer() + const boom = new Error('ENOENT') + new AnalyticsDisclosureHandler({ + async loadDisclosure() { + throw boom + }, + transport, + }).setup() + + const handler = transport._handlers.get(AnalyticsEvents.GET_DISCLOSURE) as DisclosureHandler + await handler(undefined, 'client-1').then( + () => expect.fail('expected promise to reject'), + (error: Error) => expect(error).to.equal(boom), + ) + }) +}) diff --git a/test/unit/shared/utils/parse-analytics-disclosure.test.ts b/test/unit/shared/utils/parse-analytics-disclosure.test.ts new file mode 100644 index 000000000..98f614cf0 --- /dev/null +++ b/test/unit/shared/utils/parse-analytics-disclosure.test.ts @@ -0,0 +1,75 @@ +import {expect} from 'chai' + +import {parseAnalyticsDisclosure} from '../../../../src/shared/utils/parse-analytics-disclosure.js' + +describe('parseAnalyticsDisclosure', () => { + it('extracts each H2 heading as a section label and the following text as its body', () => { + const md = [ + '# Title', + '', + 'Intro paragraph the parser must skip.', + '', + '## First', + 'First body.', + '', + '## Second', + 'Second body.', + ].join('\n') + + const sections = parseAnalyticsDisclosure(md) + + expect(sections).to.deep.equal([ + {body: 'First body.', label: 'First'}, + {body: 'Second body.', label: 'Second'}, + ]) + }) + + it('preserves multi-paragraph and list/code-block bodies verbatim', () => { + const md = [ + '## How to disable', + 'You can stop sharing at any time by running:', + '', + '```', + 'brv settings set analytics.share false', + '```', + '', + 'You can also toggle the setting from the Settings page in the TUI.', + ].join('\n') + + const [section] = parseAnalyticsDisclosure(md) + + expect(section.label).to.equal('How to disable') + expect(section.body).to.include('You can stop sharing at any time') + expect(section.body).to.include('```\nbrv settings set analytics.share false\n```') + expect(section.body).to.include('Settings page in the TUI') + }) + + it('returns an empty array when the input has no H2 headings', () => { + expect(parseAnalyticsDisclosure('# Only H1\n\nBody.')).to.deep.equal([]) + expect(parseAnalyticsDisclosure('')).to.deep.equal([]) + }) + + it('trims leading and trailing whitespace from each body', () => { + const md = '## A\n\n\nBody A.\n\n\n## B\nBody B.' + const sections = parseAnalyticsDisclosure(md) + + expect(sections[0].body).to.equal('Body A.') + expect(sections[1].body).to.equal('Body B.') + }) + + it('drops sections whose body is empty so the webui never renders a blank card', () => { + const md = ['## Empty', '', '## Has body', 'Body text.'].join('\n') + const sections = parseAnalyticsDisclosure(md) + + expect(sections.map((s) => s.label)).to.deep.equal(['Has body']) + }) + + it('ignores H3+ headings inside a section body', () => { + const md = ['## Outer', 'Lead paragraph.', '', '### Nested', 'Nested paragraph.', '', '## Next', 'Next body.'].join('\n') + + const sections = parseAnalyticsDisclosure(md) + + expect(sections.map((s) => s.label)).to.deep.equal(['Outer', 'Next']) + expect(sections[0].body).to.include('### Nested') + }) +}) diff --git a/test/unit/webui/features/analytics/api/get-analytics-disclosure.test.ts b/test/unit/webui/features/analytics/api/get-analytics-disclosure.test.ts new file mode 100644 index 000000000..ac624643b --- /dev/null +++ b/test/unit/webui/features/analytics/api/get-analytics-disclosure.test.ts @@ -0,0 +1,50 @@ +import {expect} from 'chai' +import {createSandbox, type SinonSandbox, type SinonStub} from 'sinon' + +import type {BrvApiClient} from '../../../../../../src/webui/lib/api-client.js' + +import {AnalyticsEvents} from '../../../../../../src/shared/transport/events/analytics-events.js' +import {getAnalyticsDisclosure} from '../../../../../../src/webui/features/analytics/api/get-analytics-disclosure.js' +import {useTransportStore} from '../../../../../../src/webui/stores/transport-store.js' + +describe('getAnalyticsDisclosure', () => { + let sandbox: SinonSandbox + let request: SinonStub + + beforeEach(() => { + sandbox = createSandbox() + request = sandbox.stub() + useTransportStore.setState({ + apiClient: {on: sandbox.stub(), request} as unknown as BrvApiClient, + }) + }) + + afterEach(() => { + sandbox.restore() + useTransportStore.setState({apiClient: null}) + }) + + it('emits analytics:getDisclosure with no payload', async () => { + request.resolves({sections: [{body: 'b', label: 'a'}]}) + await getAnalyticsDisclosure() + expect(request.firstCall.args[0]).to.equal(AnalyticsEvents.GET_DISCLOSURE) + }) + + it('returns the parsed sections from the daemon response', async () => { + const sections = [ + {body: 'Event names.', label: 'What is collected'}, + {body: 'Toggle off.', label: 'How to disable'}, + ] + request.resolves({sections}) + const result = await getAnalyticsDisclosure() + expect(result).to.deep.equal({sections}) + }) + + it('rejects when the transport is not connected', async () => { + useTransportStore.setState({apiClient: null}) + await getAnalyticsDisclosure().then( + () => expect.fail('expected promise to reject'), + (error: Error) => expect(error.message).to.equal('Not connected'), + ) + }) +}) diff --git a/test/unit/webui/features/analytics/constants.test.ts b/test/unit/webui/features/analytics/constants.test.ts deleted file mode 100644 index 2e03bc3a0..000000000 --- a/test/unit/webui/features/analytics/constants.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -import {expect} from 'chai' - -import { - ANALYTICS_DISCLOSURE_SECTIONS, - ANALYTICS_PRIVACY_URL, -} from '../../../../../src/webui/features/analytics/constants.js' - -describe('analytics constants', () => { - describe('ANALYTICS_DISCLOSURE_SECTIONS', () => { - it('contains the five required sections in ticket-spec order', () => { - const labels = ANALYTICS_DISCLOSURE_SECTIONS.map((s) => s.label) - expect(labels).to.deep.equal([ - 'WHAT IS COLLECTED', - 'WHICH SURFACES ARE TRACKED', - 'WHERE IT GOES', - 'CROSS-DEVICE ALIAS', - 'HOW TO DISABLE', - ]) - }) - - it('every section has a non-empty body', () => { - for (const section of ANALYTICS_DISCLOSURE_SECTIONS) { - expect(section.body.length).to.be.greaterThan(0) - } - }) - - it('every section has an icon component reference', () => { - for (const section of ANALYTICS_DISCLOSURE_SECTIONS) { - expect(section.icon).to.exist - expect(['function', 'object']).to.include(typeof section.icon) - } - }) - }) - - describe('ANALYTICS_PRIVACY_URL', () => { - it('is a https URL', () => { - expect(ANALYTICS_PRIVACY_URL).to.match(/^https:\/\//) - }) - - it('points at the byterover privacy docs', () => { - expect(ANALYTICS_PRIVACY_URL).to.include('byterover.dev/privacy') - }) - }) -})