diff --git a/package.json b/package.json index ddf3316196..551de00812 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "desktop:preview:debug": "node scripts/dev.cjs desktop-preview", "desktop:dev:raw": "cross-env-shell CI=true \"cd src/apps/desktop && tauri dev\"", "target:gc": "node scripts/cargo-target-gc.mjs", + "feedback:mock": "node scripts/feedback-mock-server.mjs", "desktop:build": "node scripts/desktop-tauri-build.mjs", "desktop:build:fast": "node scripts/desktop-tauri-build.mjs --debug --no-bundle", "desktop:build:release-fast": "node scripts/desktop-tauri-build.mjs --no-bundle -- --profile release-fast --features devtools", diff --git a/scripts/core-boundaries/rules/feature-rules.mjs b/scripts/core-boundaries/rules/feature-rules.mjs index 6e118b17ca..c0829c3e4e 100644 --- a/scripts/core-boundaries/rules/feature-rules.mjs +++ b/scripts/core-boundaries/rules/feature-rules.mjs @@ -57,26 +57,26 @@ export const optionalDependencyFeatureOwnerRules = [ 'services-integrations optional runtime dependencies must stay owned by explicit integration features', dependencies: [ { depName: 'aes', ownerFeatures: ['remote-connect'] }, - { depName: 'aes-gcm', ownerFeatures: ['mcp', 'remote-connect', 'remote-ssh-concrete'] }, - { depName: 'anyhow', ownerFeatures: ['browser-control', 'debug-log', 'mcp', 'remote-connect', 'remote-ssh', 'remote-ssh-concrete'] }, + { depName: 'aes-gcm', ownerFeatures: ['feedback', 'mcp', 'remote-connect', 'remote-ssh-concrete'] }, + { depName: 'anyhow', ownerFeatures: ['browser-control', 'debug-log', 'feedback', 'mcp', 'remote-connect', 'remote-ssh', 'remote-ssh-concrete'] }, { depName: 'async-trait', - ownerFeatures: ['mcp', 'remote-connect', 'remote-ssh', 'remote-ssh-concrete', 'review-platform', 'script-tool-runtime', 'speech', 'workspace-search'], + ownerFeatures: ['feedback', 'mcp', 'remote-connect', 'remote-ssh', 'remote-ssh-concrete', 'review-platform', 'script-tool-runtime', 'speech', 'workspace-search'], }, { depName: 'base64', - ownerFeatures: ['mcp', 'miniapp-runtime', 'remote-connect', 'remote-ssh-concrete', 'speech'], + ownerFeatures: ['feedback', 'mcp', 'miniapp-runtime', 'remote-connect', 'remote-ssh-concrete', 'speech'], }, { depName: 'bitfun-agent-runtime', ownerFeatures: ['deep-research'] }, { depName: 'bitfun-core-types', ownerFeatures: ['speech'] }, - { depName: 'bitfun-product-domains', ownerFeatures: ['canvas-runtime', 'function-agents', 'miniapp-runtime', 'plugin-source'] }, + { depName: 'bitfun-product-domains', ownerFeatures: ['canvas-runtime', 'feedback', 'function-agents', 'miniapp-runtime', 'plugin-source', 'privacy'] }, { depName: 'bitfun-runtime-ports', ownerFeatures: ['remote-connect', 'remote-ssh', 'remote-ssh-concrete', 'script-tool-runtime'] }, { depName: 'bitfun-services-core', ownerFeatures: ['browser-control', 'git', 'mcp', 'miniapp-runtime', 'process-tree', 'remote-connect', 'review-platform', 'workspace-search'], }, { depName: 'bzip2', ownerFeatures: ['speech'] }, - { depName: 'chrono', ownerFeatures: ['debug-log', 'git', 'remote-connect', 'remote-ssh-concrete', 'review-platform', 'speech'] }, + { depName: 'chrono', ownerFeatures: ['debug-log', 'feedback', 'git', 'privacy', 'remote-connect', 'remote-ssh-concrete', 'review-platform', 'speech'] }, { depName: 'dirs', ownerFeatures: ['browser-control', 'miniapp-runtime', 'remote-connect', 'remote-ssh-concrete'] }, { depName: 'dunce', ownerFeatures: ['plugin-source', 'remote-ssh', 'workspace-search'] }, { depName: 'fs2', ownerFeatures: ['plugin-source'] }, @@ -93,11 +93,11 @@ export const optionalDependencyFeatureOwnerRules = [ { depName: 'notify', ownerFeatures: ['file-watch'] }, { depName: 'oxc', ownerFeatures: ['canvas-runtime'] }, { depName: 'qrcode', ownerFeatures: ['remote-connect'] }, - { depName: 'rand', ownerFeatures: ['mcp', 'remote-connect', 'remote-ssh-concrete'] }, + { depName: 'rand', ownerFeatures: ['feedback', 'mcp', 'remote-connect', 'remote-ssh-concrete'] }, // remote-ssh-concrete: one-click relay deploy fetches the signed release // checksum over HTTPS and verifies it on this device, because the target // server has no minisign and no trust root of its own. - { depName: 'reqwest', ownerFeatures: ['announcement', 'browser-control', 'debug-log', 'mcp', 'miniapp-runtime', 'remote-connect', 'remote-ssh-concrete', 'review-platform', 'speech', 'web-tools'] }, + { depName: 'reqwest', ownerFeatures: ['announcement', 'browser-control', 'debug-log', 'feedback', 'mcp', 'miniapp-runtime', 'remote-connect', 'remote-ssh-concrete', 'review-platform', 'speech', 'web-tools'] }, { depName: 'rmcp', ownerFeatures: ['mcp'] }, { depName: 'russh', ownerFeatures: ['remote-ssh-concrete'] }, { depName: 'russh-keys', ownerFeatures: ['remote-ssh-concrete'] }, @@ -105,7 +105,7 @@ export const optionalDependencyFeatureOwnerRules = [ { depName: 'rustls', ownerFeatures: ['remote-connect'] }, { depName: 'rustls-native-certs', ownerFeatures: ['remote-connect'] }, { depName: 'schannel', ownerFeatures: ['remote-connect'] }, - { depName: 'sha2', ownerFeatures: ['canvas-runtime', 'plugin-source', 'remote-connect', 'remote-ssh', 'review-platform', 'speech'] }, + { depName: 'sha2', ownerFeatures: ['canvas-runtime', 'feedback', 'plugin-source', 'privacy', 'remote-connect', 'remote-ssh', 'review-platform', 'speech'] }, { depName: 'sherpa-onnx', ownerFeatures: ['speech'] }, { depName: 'shellexpand', ownerFeatures: ['remote-ssh-concrete'] }, { depName: 'sse-stream', ownerFeatures: ['mcp'] }, @@ -116,7 +116,7 @@ export const optionalDependencyFeatureOwnerRules = [ { depName: 'tokio-tungstenite', ownerFeatures: ['remote-connect'] }, { depName: 'tokio-util', ownerFeatures: ['remote-ssh', 'speech'] }, { depName: 'urlencoding', ownerFeatures: ['canvas-runtime', 'remote-connect', 'review-platform'] }, - { depName: 'uuid', ownerFeatures: ['canvas-runtime', 'debug-log', 'miniapp-runtime', 'plugin-source', 'remote-connect', 'remote-ssh-concrete', 'speech'] }, + { depName: 'uuid', ownerFeatures: ['canvas-runtime', 'debug-log', 'feedback', 'miniapp-runtime', 'plugin-source', 'remote-connect', 'remote-ssh-concrete', 'speech'] }, { depName: 'which', ownerFeatures: ['miniapp-runtime', 'remote-connect', 'script-tool-runtime', 'workspace-search'] }, { depName: 'windows', ownerFeatures: ['plugin-source', 'review-platform'] }, { depName: 'x25519-dalek', ownerFeatures: ['remote-connect'] }, @@ -212,6 +212,7 @@ export const ownerCrateFeatureAssemblyRules = [ 'debug-log', 'deep-research', 'file-watch', + 'feedback', 'function-agents', 'git', 'miniapp-runtime', diff --git a/scripts/core-boundaries/self-test.mjs b/scripts/core-boundaries/self-test.mjs index 2765725e56..e585a3bdb5 100644 --- a/scripts/core-boundaries/self-test.mjs +++ b/scripts/core-boundaries/self-test.mjs @@ -669,6 +669,34 @@ export function runManifestParserSelfTest({ const servicesOptionalOwnerDeps = new Set( servicesOptionalOwnerRule?.dependencies.map((dependency) => dependency.depName) ?? [], ); + + const expectedFeatureDependencies = { + feedback: [ + 'aes-gcm', + 'anyhow', + 'async-trait', + 'base64', + 'bitfun-product-domains', + 'chrono', + 'rand', + 'reqwest', + 'sha2', + 'uuid', + ], + privacy: ['bitfun-product-domains', 'chrono', 'sha2'], + }; + for (const [featureName, dependencies] of Object.entries(expectedFeatureDependencies)) { + for (const dep of dependencies) { + const owner = servicesOptionalOwnerRule?.dependencies.find( + (dependency) => dependency.depName === dep, + ); + if (!owner?.ownerFeatures.includes(featureName)) { + throw new Error( + `services-integrations ${featureName} must own optional dependency ${dep}`, + ); + } + } + } for (const dep of servicesIntegrationsDefaultProfile?.forbiddenNonOptionalDeps ?? []) { if (!servicesOptionalOwnerDeps.has(dep)) { throw new Error( diff --git a/scripts/feedback-mock-server.mjs b/scripts/feedback-mock-server.mjs new file mode 100644 index 0000000000..ca596f57be --- /dev/null +++ b/scripts/feedback-mock-server.mjs @@ -0,0 +1,424 @@ +import http from 'node:http'; +import { randomUUID } from 'node:crypto'; + +const port = Number.parseInt(process.env.BITFUN_FEEDBACK_MOCK_PORT ?? '38971', 10); +const enrollments = new Map(); +const refreshTokens = new Map(); +const createRequests = new Map(); +const replyRequests = new Map(); +const records = new Map(); +let nextFault = null; + +const server = http.createServer(async (request, response) => { + const requestId = normalizeRequestId(header(request, 'x-request-id')); + const url = new URL(request.url ?? '/', `http://${request.headers.host ?? 'localhost'}`); + logRequestStage(requestStage(request.method, url.pathname), requestId); + + if (request.method === 'GET' && url.pathname === '/health') { + return send(response, 200, { status: 'ok' }, requestId); + } + if (request.method === 'POST' && url.pathname === '/__mock/control') { + const body = await readJson(request); + nextFault = typeof body.fault === 'string' ? body.fault : null; + if (Number.isInteger(body.seed_inbox) && body.seed_inbox > 0) { + seedInbox(Math.min(body.seed_inbox, 120)); + } + if (body.admin_reply && typeof body.admin_reply.feedback_id === 'string') { + addAdminReply(body.admin_reply.feedback_id, body.admin_reply.content ?? 'Mock support reply'); + } + if (body.seed_messages && typeof body.seed_messages.feedback_id === 'string') { + seedMessages(body.seed_messages.feedback_id, Math.min(body.seed_messages.count ?? 0, 250)); + } + return send(response, 200, { next_fault: nextFault, records: records.size }, requestId); + } + + const fault = takeFault(); + if (fault === 'timeout') { + setTimeout(() => sendError(response, 500, 'INTERNAL_ERROR', requestId), 25_000); + return; + } + if (fault === '403') return sendError(response, 403, 'SCOPE_INSUFFICIENT', requestId); + if (fault === '429') return sendError(response, 429, 'RATE_LIMITED', requestId, { 'Retry-After': '30' }); + if (fault === '5xx') return sendError(response, 503, 'INTERNAL_ERROR', requestId); + if (fault === '401') return sendError(response, 401, 'ACCESS_TOKEN_INVALID', requestId); + if (fault === 'cursor_invalid') return sendError(response, 400, 'CURSOR_INVALID', requestId); + if (fault === 'capability_invalid') return sendError(response, 403, 'CAPABILITY_INVALID', requestId); + + if (request.method === 'POST' && url.pathname === '/auth/v1/anonymous/enroll') { + const body = await readJson(request); + const idempotencyKey = requireUuidHeader(request, response, requestId); + if (!idempotencyKey) return; + if (typeof body.key !== 'string' || body.key.length === 0) { + return sendError(response, 400, 'ENROLL_KEY_REQUIRED', requestId); + } + const identity = `${body.key}:${idempotencyKey}`; + const existing = enrollments.get(identity); + if (existing) return send(response, 201, existing, requestId, { 'Idempotency-Replayed': 'true' }); + const created = tokenPair(randomUUID()); + enrollments.set(identity, created); + refreshTokens.set(created.refresh_token, created.anonymous_id); + return send(response, 201, created, requestId, { 'Idempotency-Replayed': 'false' }); + } + + if (request.method === 'POST' && url.pathname === '/auth/v1/anonymous/token') { + const body = await readJson(request); + const anonymousId = refreshTokens.get(body.refresh_token); + if (!anonymousId) return sendError(response, 401, 'REFRESH_TOKEN_INVALID', requestId); + refreshTokens.delete(body.refresh_token); + const refreshed = tokenPair(anonymousId); + refreshTokens.set(refreshed.refresh_token, anonymousId); + return send(response, 200, refreshed, requestId); + } + + if (request.method === 'POST' && url.pathname === '/support/v1/feedback') { + if (!validBearer(request)) return sendError(response, 401, 'ACCESS_TOKEN_INVALID', requestId); + const body = await readJson(request); + const idempotencyKey = requireUuidHeader(request, response, requestId); + if (!idempotencyKey) return; + const fingerprint = JSON.stringify(body); + const existing = createRequests.get(idempotencyKey); + if (existing && existing.fingerprint !== fingerprint) { + return sendError(response, 409, 'FEEDBACK_IDEMPOTENT_CONFLICT', requestId); + } + if (existing) { + return send(response, 201, { ...existing.result, idempotency_replayed: true }, requestId, { + 'Idempotency-Replayed': 'true', + }); + } + if (!['runtime_error', 'feature_request', 'usage_question', 'other'].includes(body.category)) { + return sendError(response, 400, 'CATEGORY_INVALID', requestId); + } + if (typeof body.content !== 'string' || body.content.trim().length === 0) { + return sendError(response, 400, 'CONTENT_EMPTY', requestId); + } + if (Array.from(body.content.trim()).length > 2_000) { + return sendError(response, 400, 'CONTENT_TOO_LONG', requestId); + } + const feedbackId = randomUUID(); + const createdAt = new Date().toISOString(); + const result = { + feedback_id: feedbackId, + capability_token: randomUUID(), + status: 'submitted', + inbox_cursor: new Date().toISOString(), + schema_version: '1.0.0', + }; + if (fault === 'capability_missing') delete result.capability_token; + createRequests.set(idempotencyKey, { fingerprint, result }); + records.set(feedbackId, { + ...body, + ...result, + has_new_reply: false, + created_at: createdAt, + updated_at: createdAt, + read_cursor: createdAt, + messages: [{ + message_id: randomUUID(), + sender_type: 'user', + content: body.content.trim(), + content_deleted: false, + created_at: createdAt, + }], + }); + return send(response, 201, result, requestId, { 'Idempotency-Replayed': 'false' }); + } + + if (request.method === 'GET' && url.pathname === '/support/v1/feedback/inbox') { + if (!validBearer(request)) return sendError(response, 401, 'ACCESS_TOKEN_INVALID', requestId); + const limit = parseLimit(url.searchParams.get('limit'), 20, 100); + if (limit === null) return sendError(response, 400, 'PAGE_SIZE_INVALID', requestId); + const offset = decodeCursor(url.searchParams.get('cursor')); + if (offset === null) return sendError(response, 400, 'CURSOR_INVALID', requestId); + const ordered = [...records.values()].sort((left, right) => + right.created_at.localeCompare(left.created_at) || right.feedback_id.localeCompare(left.feedback_id)); + const page = ordered.slice(offset, offset + limit); + const nextOffset = offset + page.length; + return send(response, 200, { + items: page.map(record => ({ + feedback_id: record.feedback_id, + category: record.category, + status: record.status, + has_new_reply: record.has_new_reply, + created_at: record.created_at, + updated_at: record.updated_at, + })), + cursor: encodeCursor(nextOffset), + has_more: nextOffset < ordered.length, + }, requestId); + } + + const messagesMatch = /^\/support\/v1\/feedback\/([^/]+)\/messages$/.exec(url.pathname); + if (request.method === 'POST' && messagesMatch) { + const record = authorizeRecord(request, response, requestId, messagesMatch[1]); + if (!record) return; + const idempotencyKey = requireUuidHeader(request, response, requestId); + if (!idempotencyKey) return; + const body = await readJson(request); + const fingerprint = `${record.feedback_id}:${JSON.stringify(body)}`; + const existing = replyRequests.get(idempotencyKey); + if (existing && existing.fingerprint !== fingerprint) { + return sendError(response, 409, 'IDEMPOTENCY_CONFLICT', requestId); + } + if (existing) { + return send(response, 201, existing.result, requestId, { + 'Idempotency-Replayed': 'true', + }); + } + if (record.status === 'resolved') { + return sendError(response, 409, 'FEEDBACK_ALREADY_RESOLVED', requestId); + } + if (typeof body.content !== 'string' || body.content.trim().length === 0) { + return sendError(response, 400, 'CONTENT_EMPTY', requestId); + } + if (Array.from(body.content.trim()).length > 2_000) { + return sendError(response, 400, 'CONTENT_TOO_LONG', requestId); + } + const previousTime = Date.parse(record.messages.at(-1)?.created_at ?? record.created_at); + const createdAt = new Date(Math.max(Date.now(), previousTime + 1)).toISOString(); + const message = { + message_id: randomUUID(), + sender_type: 'user', + content: body.content.trim(), + content_deleted: false, + created_at: createdAt, + }; + record.messages.push(message); + record.status = 'in_progress'; + record.has_new_reply = false; + record.updated_at = createdAt; + const result = { + message_id: message.message_id, + sender_type: message.sender_type, + created_at: message.created_at, + feedback_status: record.status, + }; + replyRequests.set(idempotencyKey, { fingerprint, result }); + return send(response, 201, result, requestId, { 'Idempotency-Replayed': 'false' }); + } + if (request.method === 'GET' && messagesMatch) { + const record = authorizeRecord(request, response, requestId, messagesMatch[1]); + if (!record) return; + const limit = parseLimit(url.searchParams.get('limit'), 50, 200); + if (limit === null) return sendError(response, 400, 'PAGE_SIZE_INVALID', requestId); + const offset = decodeCursor(url.searchParams.get('cursor'), 'messages'); + if (offset === null) return sendError(response, 400, 'CURSOR_INVALID', requestId); + const page = record.messages.slice(offset, offset + limit); + const nextOffset = offset + page.length; + return send(response, 200, { + feedback_id: record.feedback_id, + messages: page, + cursor: encodeCursor(nextOffset, 'messages'), + has_more: nextOffset < record.messages.length, + }, requestId); + } + + const ackMatch = /^\/support\/v1\/feedback\/([^/]+)\/ack$/.exec(url.pathname); + if (request.method === 'POST' && ackMatch) { + const record = authorizeRecord(request, response, requestId, ackMatch[1]); + if (!record) return; + const body = await readJson(request); + const requested = Date.parse(body.read_cursor); + if (!Number.isFinite(requested)) return sendError(response, 400, 'READ_CURSOR_INVALID', requestId); + const latest = record.messages.at(-1)?.created_at ?? record.created_at; + const effective = new Date(Math.max( + Date.parse(record.read_cursor ?? record.created_at), + Math.min(requested, Date.parse(latest)), + )).toISOString(); + record.read_cursor = effective; + if (record.status === 'waiting_user' && Date.parse(effective) >= Date.parse(latest)) { + record.status = 'in_progress'; + } + record.has_new_reply = record.messages.some(message => + message.sender_type === 'admin' && Date.parse(message.created_at) > Date.parse(effective)); + return send(response, 200, { + feedback_id: record.feedback_id, + read_cursor: effective, + feedback_status: record.status, + }, requestId); + } + + return sendError(response, 404, 'NOT_FOUND', requestId); +}); + +server.listen(port, '127.0.0.1', () => { + process.stdout.write(`Feedback mock listening at http://127.0.0.1:${port}\n`); +}); + +function tokenPair(anonymousId) { + return { + anonymous_id: anonymousId, + access_token: `access-${randomUUID()}`, + refresh_token: `refresh-${randomUUID()}`, + expires_in: 3_600, + refresh_expires_in: 2_592_000, + scope: 'feedback:write,feedback:read', + schema_version: '1.0.0', + }; +} + +function seedInbox(count) { + for (let index = 0; index < count; index += 1) { + const feedbackId = randomUUID(); + const createdAt = new Date(Date.now() - index * 60_000).toISOString(); + records.set(feedbackId, { + feedback_id: feedbackId, + category: ['runtime_error', 'feature_request', 'usage_question', 'other'][index % 4], + status: ['submitted', 'in_progress', 'waiting_user', 'resolved'][index % 4], + has_new_reply: index % 3 === 0, + created_at: createdAt, + updated_at: createdAt, + }); + } +} + +function parseLimit(value, fallback, maximum) { + if (value === null) return fallback; + const parsed = Number.parseInt(value, 10); + return Number.isInteger(parsed) && parsed >= 1 && parsed <= maximum ? parsed : null; +} + +function encodeCursor(offset, kind = 'inbox') { + return Buffer.from(`${kind}:${offset}`, 'utf8').toString('base64url'); +} + +function decodeCursor(cursor, kind = 'inbox') { + if (cursor === null) return 0; + try { + const decoded = Buffer.from(cursor, 'base64url').toString('utf8'); + const match = new RegExp(`^${kind}:(\\d+)$`).exec(decoded); + return match ? Number.parseInt(match[1], 10) : null; + } catch { + return null; + } +} + +function authorizeRecord(request, response, requestId, feedbackId) { + if (!validBearer(request)) { + sendError(response, 401, 'ACCESS_TOKEN_INVALID', requestId); + return null; + } + const record = records.get(feedbackId); + if (!record) { + sendError(response, 404, 'FEEDBACK_NOT_FOUND', requestId); + return null; + } + if (!record.capability_token + || header(request, 'x-feedback-capability') !== record.capability_token) { + sendError(response, 403, 'CAPABILITY_INVALID', requestId); + return null; + } + return record; +} + +function addAdminReply(feedbackId, content) { + const record = records.get(feedbackId); + if (!record || !Array.isArray(record.messages)) return; + const createdAt = new Date(Date.now() + record.messages.length).toISOString(); + record.messages.push({ + message_id: randomUUID(), + sender_type: 'admin', + content: String(content), + content_deleted: false, + created_at: createdAt, + }); + record.status = 'waiting_user'; + record.has_new_reply = true; + record.updated_at = createdAt; +} + +function seedMessages(feedbackId, count) { + const record = records.get(feedbackId); + if (!record || !Array.isArray(record.messages) || count <= 0) return; + for (let index = 0; index < count; index += 1) { + const createdAt = new Date(Date.parse(record.created_at) + (index + 1) * 1_000).toISOString(); + record.messages.push({ + message_id: randomUUID(), + sender_type: index % 2 === 0 ? 'admin' : 'user', + content: `Mock message ${index + 1}`, + content_deleted: false, + created_at: createdAt, + }); + } + record.status = 'waiting_user'; + record.has_new_reply = true; + record.updated_at = record.messages.at(-1).created_at; +} + +function takeFault() { + const fault = nextFault; + nextFault = null; + return fault; +} + +function validBearer(request) { + return /^Bearer access-/.test(header(request, 'authorization') ?? ''); +} + +function requireUuidHeader(request, response, requestId) { + const value = header(request, 'idempotency-key'); + if (!value || !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(value)) { + sendError(response, 409, 'IDEMPOTENCY_KEY_INVALID', requestId); + return null; + } + return value; +} + +function header(request, name) { + const value = request.headers[name]; + return Array.isArray(value) ? value[0] : value; +} + +function normalizeRequestId(value) { + return typeof value === 'string' && /^[a-zA-Z0-9._:-]{1,128}$/.test(value) + ? value + : randomUUID(); +} + +function requestStage(method, pathname) { + if (method === 'GET' && pathname === '/health') return 'health'; + if (method === 'POST' && pathname === '/__mock/control') return 'control'; + if (method === 'POST' && pathname === '/auth/v1/anonymous/enroll') return 'enroll'; + if (method === 'POST' && pathname === '/auth/v1/anonymous/token') return 'refresh'; + if (method === 'POST' && pathname === '/support/v1/feedback') return 'create'; + if (method === 'GET' && pathname === '/support/v1/feedback/inbox') return 'inbox'; + if (/^\/support\/v1\/feedback\/[^/]+\/messages$/.test(pathname)) { + return method === 'GET' ? 'message_history' : method === 'POST' ? 'reply' : 'unknown'; + } + if (method === 'POST' && /^\/support\/v1\/feedback\/[^/]+\/ack$/.test(pathname)) { + return 'acknowledge'; + } + return 'unknown'; +} + +function logRequestStage(stage, requestId) { + process.stdout.write(`${JSON.stringify({ stage, requestId })}\n`); +} + +async function readJson(request) { + const chunks = []; + for await (const chunk of request) chunks.push(chunk); + if (chunks.length === 0) return {}; + try { + return JSON.parse(Buffer.concat(chunks).toString('utf8')); + } catch { + return {}; + } +} + +function sendError(response, status, code, requestId, headers = {}) { + send(response, status, { + error_code: code, + error_message: 'Mock diagnostic text must never be shown directly by the client.', + request_id: requestId, + }, requestId, headers); +} + +function send(response, status, body, requestId, headers = {}) { + response.writeHead(status, { + 'Cache-Control': 'no-store', + 'Content-Type': 'application/json', + 'X-Request-ID': requestId, + ...headers, + }); + response.end(JSON.stringify(body)); +} diff --git a/src/apps/desktop/Cargo.toml b/src/apps/desktop/Cargo.toml index 305c03055b..d6ed61af7e 100644 --- a/src/apps/desktop/Cargo.toml +++ b/src/apps/desktop/Cargo.toml @@ -24,7 +24,7 @@ bitfun-relay-service = { path = "../../crates/services/relay-service" } bitfun-agent-runtime = { path = "../../crates/execution/agent-runtime" } bitfun-runtime-ports = { path = "../../crates/contracts/runtime-ports" } bitfun-product-domains = { path = "../../crates/contracts/product-domains", default-features = false } -bitfun-services-integrations = { path = "../../crates/services/services-integrations", default-features = false, features = ["canvas-runtime", "speech"] } +bitfun-services-integrations = { path = "../../crates/services/services-integrations", default-features = false, features = ["canvas-runtime", "feedback", "privacy", "speech"] } bitfun-core-types = { path = "../../crates/contracts/core-types" } bitfun-agent-tools = { path = "../../crates/execution/tool-contracts" } bitfun-transport = { path = "../../crates/adapters/transport", features = ["tauri-adapter"] } @@ -35,7 +35,6 @@ bitfun-acp = { path = "../../crates/interfaces/acp" } # Tauri tauri = { workspace = true } tauri-plugin-opener = { workspace = true } -tauri-plugin-dialog = { workspace = true } tauri-plugin-fs = { workspace = true } tauri-plugin-log = { workspace = true } napi-ohos = { workspace = true } diff --git a/src/apps/desktop/src/api/feedback_api.rs b/src/apps/desktop/src/api/feedback_api.rs new file mode 100644 index 0000000000..c895c04ff3 --- /dev/null +++ b/src/apps/desktop/src/api/feedback_api.rs @@ -0,0 +1,179 @@ +use bitfun_product_domains::feedback::{ + AcknowledgeFeedbackRequest, AcknowledgeFeedbackResponse, FeedbackAccessState, + FeedbackConversationPage, FeedbackError, FeedbackInboxPage, ListFeedbackRecordsRequest, + OpenFeedbackConversationRequest, ReplyFeedbackRequest, ReplyFeedbackResponse, + SubmitFeedbackRequest, SubmitFeedbackResponse, +}; +use bitfun_services_integrations::feedback::FeedbackService; +use serde::Deserialize; +use tauri::State; + +use crate::api::privacy_api::PrivacyServiceState; + +pub struct FeedbackServiceState { + service: Option, +} + +impl FeedbackServiceState { + pub fn enabled(service: FeedbackService) -> Self { + Self { + service: Some(service), + } + } + + pub fn disabled() -> Self { + Self { service: None } + } + + fn service(&self) -> Result<&FeedbackService, FeedbackError> { + self.service.as_ref().ok_or_else(|| { + FeedbackError::new( + "FEEDBACK_PLATFORM_UNSUPPORTED", + "In-app feedback is only available on OpenHarmony", + false, + ) + }) + } +} + +#[derive(Debug, Default, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FeedbackAccessStateRequest {} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ListFeedbackCommandRequest { + #[serde(default)] + pub cursor: Option, + pub page_size: u16, + #[serde(default)] + pub user_initiated: bool, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OpenFeedbackCommandRequest { + pub feedback_id: String, + #[serde(default)] + pub cursor: Option, + pub page_size: u16, + #[serde(default)] + pub user_initiated: bool, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AcknowledgeFeedbackCommandRequest { + pub feedback_id: String, + pub last_visible_at: String, + #[serde(default)] + pub foreground_visible: bool, +} + +#[tauri::command] +pub async fn feedback_get_access_state( + feedback_state: State<'_, FeedbackServiceState>, + request: FeedbackAccessStateRequest, +) -> Result { + let _ = request; + feedback_state.service()?.access_state().await +} + +#[tauri::command] +pub async fn list_feedback( + feedback_state: State<'_, FeedbackServiceState>, + privacy_state: State<'_, PrivacyServiceState>, + request: ListFeedbackCommandRequest, +) -> Result { + if !privacy_state.collection_allowed() && !request.user_initiated { + return Err(FeedbackError::new( + "PRIVACY_BACKGROUND_REQUEST_BLOCKED", + "Background feedback requests require full privacy mode", + false, + )); + } + feedback_state + .service()? + .list_feedback(ListFeedbackRecordsRequest { + cursor: request.cursor, + page_size: request.page_size, + }) + .await +} + +#[tauri::command] +pub async fn open_feedback_conversation( + feedback_state: State<'_, FeedbackServiceState>, + privacy_state: State<'_, PrivacyServiceState>, + request: OpenFeedbackCommandRequest, +) -> Result { + if !privacy_state.collection_allowed() && !request.user_initiated { + return Err(FeedbackError::new( + "PRIVACY_BACKGROUND_REQUEST_BLOCKED", + "Background feedback requests require full privacy mode", + false, + )); + } + feedback_state + .service()? + .open_conversation(OpenFeedbackConversationRequest { + feedback_id: request.feedback_id, + cursor: request.cursor, + page_size: request.page_size, + }) + .await +} + +#[tauri::command] +pub async fn acknowledge_feedback( + feedback_state: State<'_, FeedbackServiceState>, + _privacy_state: State<'_, PrivacyServiceState>, + request: AcknowledgeFeedbackCommandRequest, +) -> Result { + if !request.foreground_visible { + return Err(FeedbackError::new( + "FEEDBACK_NOT_VISIBLE", + "Feedback must be visible before it can be marked as read", + false, + )); + } + feedback_state + .service()? + .acknowledge_feedback(AcknowledgeFeedbackRequest { + feedback_id: request.feedback_id, + last_visible_at: request.last_visible_at, + }) + .await +} + +#[tauri::command] +pub async fn reply_feedback( + feedback_state: State<'_, FeedbackServiceState>, + privacy_state: State<'_, PrivacyServiceState>, + request: ReplyFeedbackRequest, +) -> Result { + if !privacy_state.collection_allowed() { + return Err(FeedbackError::new( + "PRIVACY_CONSENT_REQUIRED", + "Feedback replies require full privacy mode", + false, + )); + } + feedback_state.service()?.reply_feedback(request).await +} + +#[tauri::command] +pub async fn submit_feedback( + feedback_state: State<'_, FeedbackServiceState>, + privacy_state: State<'_, PrivacyServiceState>, + request: SubmitFeedbackRequest, +) -> Result { + if !privacy_state.collection_allowed() { + return Err(FeedbackError::new( + "PRIVACY_CONSENT_REQUIRED", + "Feedback submission requires full privacy mode", + false, + )); + } + feedback_state.service()?.submit_feedback(request).await +} diff --git a/src/apps/desktop/src/api/mod.rs b/src/apps/desktop/src/api/mod.rs index 746ba10bf1..7507e548d2 100644 --- a/src/apps/desktop/src/api/mod.rs +++ b/src/apps/desktop/src/api/mod.rs @@ -22,6 +22,7 @@ pub mod dto; pub mod editor_ai_api; pub mod external_hooks_api; pub mod external_sources_api; +pub mod feedback_api; pub mod git_agent_api; pub mod git_api; pub mod i18n_api; @@ -33,8 +34,10 @@ pub mod miniapp_agent_api; pub mod miniapp_api; pub mod miniapp_export_api; pub mod pages_api; +pub mod ohos; pub mod path_target; pub mod peer_host_invoke; +pub mod privacy_api; pub mod relay_deploy_api; pub mod remote_connect_api; pub mod remote_workspace_policy; @@ -53,7 +56,6 @@ pub mod subagent_api; pub mod system_api; pub mod terminal_api; pub mod tool_api; -pub mod ohos; pub mod workspace_activation; pub use app_state::{AppState, AppStatistics, HealthStatus, RemoteWorkspace}; diff --git a/src/apps/desktop/src/api/ohos/feedback_credentials.rs b/src/apps/desktop/src/api/ohos/feedback_credentials.rs new file mode 100644 index 0000000000..c556889da2 --- /dev/null +++ b/src/apps/desktop/src/api/ohos/feedback_credentials.rs @@ -0,0 +1,84 @@ +#![cfg(target_env = "ohos")] + +use anyhow::{anyhow, Context, Result}; +use async_trait::async_trait; +use bitfun_services_integrations::feedback::FeedbackCredentialStore; +use serde::{Deserialize, Serialize}; + +const ARKTS_FUNCTION: &str = "feedback_secure_credentials"; + +#[derive(Debug, Serialize)] +#[serde(rename_all = "snake_case")] +enum CredentialAction { + Load, + Store, +} + +#[derive(Debug, Serialize)] +struct CredentialRequest<'a> { + action: CredentialAction, + #[serde(skip_serializing_if = "Option::is_none")] + value: Option<&'a str>, +} + +#[derive(Debug, Deserialize)] +struct CredentialResponse { + status: String, + value: Option, + code: Option, +} + +pub struct OhosFeedbackCredentialStore; + +impl OhosFeedbackCredentialStore { + pub fn new() -> Self { + Self + } + + async fn call(&self, request: CredentialRequest<'_>) -> Result { + let input = serde_json::to_string(&request).context("encode secure credential request")?; + let output = bitfun_core::util::call_arkts_string_function(ARKTS_FUNCTION, input) + .await + .map_err(|error| anyhow!("call OpenHarmony secure credential store: {error}"))?; + serde_json::from_str(&output).context("decode secure credential response") + } +} + +#[async_trait] +impl FeedbackCredentialStore for OhosFeedbackCredentialStore { + async fn load(&self) -> Result> { + let response = self + .call(CredentialRequest { + action: CredentialAction::Load, + value: None, + }) + .await?; + match response.status.as_str() { + "ok" => response + .value + .map(Some) + .ok_or_else(|| anyhow!("secure credential response omitted its value")), + "not_found" => Ok(None), + _ => Err(anyhow!( + "OpenHarmony secure credential load failed: code={}", + response.code.as_deref().unwrap_or("UNKNOWN") + )), + } + } + + async fn store(&self, value: &str) -> Result<()> { + let response = self + .call(CredentialRequest { + action: CredentialAction::Store, + value: Some(value), + }) + .await?; + if response.status == "ok" { + return Ok(()); + } + Err(anyhow!( + "OpenHarmony secure credential store failed: code={}", + response.code.as_deref().unwrap_or("UNKNOWN") + )) + } +} diff --git a/src/apps/desktop/src/api/ohos/mod.rs b/src/apps/desktop/src/api/ohos/mod.rs index bf91ed2105..4e09b5d408 100644 --- a/src/apps/desktop/src/api/ohos/mod.rs +++ b/src/apps/desktop/src/api/ohos/mod.rs @@ -1,4 +1,6 @@ pub mod ohos_file_system; pub mod window; pub mod browser; -pub mod update; \ No newline at end of file +#[cfg(target_env = "ohos")] +pub mod feedback_credentials; +pub mod update; diff --git a/src/apps/desktop/src/api/privacy_api.rs b/src/apps/desktop/src/api/privacy_api.rs new file mode 100644 index 0000000000..d71f711d8d --- /dev/null +++ b/src/apps/desktop/src/api/privacy_api.rs @@ -0,0 +1,229 @@ +use bitfun_product_domains::privacy::{ + AcceptPrivacyRequest, ApplyPrivacyCollectionPolicyRequest, EnterPrivacyNotAcceptedRequest, + GetPrivacyStatusRequest, InitializePrivacyRequest, MarkPrivacyViewedRequest, + PrivacyEffectiveMode, PrivacyError, PrivacyStatus, +}; +use bitfun_services_integrations::privacy::{PrivacyCollectionPolicy, PrivacyService}; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::OnceLock; +use tauri::State; +use tokio::sync::Mutex; + +pub struct PrivacyServiceState { + service: Option>, + collection_policy: Arc, +} + +static COLLECTION_POLICY: OnceLock> = OnceLock::new(); +fn shared_collection_policy(initially_allowed: bool) -> Arc { + COLLECTION_POLICY + .get_or_init(|| Arc::new(PrivacyCollectionPolicy::new(initially_allowed))) + .clone() +} + +impl PrivacyServiceState { + pub fn enabled(storage_dir: PathBuf, locale: &str) -> Self { + Self { + service: Some(Mutex::new(PrivacyService::new(storage_dir, locale))), + collection_policy: shared_collection_policy(false), + } + } + + pub fn disabled() -> Self { + Self { + service: None, + collection_policy: shared_collection_policy(true), + } + } + + pub fn collection_allowed(&self) -> bool { + self.collection_policy.collection_allowed() + } + + fn enter_full_mode(&self) -> Result<(), PrivacyError> { + self.collection_policy.apply(PrivacyEffectiveMode::Full) + } + + fn enter_not_accepted_mode(&self) -> Result<(), PrivacyError> { + self.collection_policy + .apply(PrivacyEffectiveMode::PrivacyNotAccepted) + } + + async fn with_service( + &self, + operation: impl for<'a> FnOnce( + &'a PrivacyService, + ) -> std::pin::Pin< + Box> + Send + 'a>, + >, + ) -> Result { + let service = self.service.as_ref().ok_or_else(|| { + PrivacyError::new( + "PRIVACY_SERVICE_UNAVAILABLE", + "Privacy service is unavailable", + ) + })?; + let guard = service.lock().await; + operation(&guard).await + } + + async fn status_with_effective_mode( + &self, + mut status: PrivacyStatus, + ) -> Result { + status.effective_mode = self.collection_policy.effective_mode(); + if status.lifecycle_state == bitfun_product_domains::privacy::PrivacyLifecycleState::Full + && status.effective_mode != PrivacyEffectiveMode::Full + { + status.configuration_error = Some("PRIVACY_POLICY_NOT_APPLIED".to_string()); + } + Ok(status) + } + + async fn initialize(&self, app_version: &str) -> Result { + let Some(service) = self.service.as_ref() else { + return Ok(PrivacyStatus::disabled()); + }; + let status = service.lock().await.initialize(app_version).await?; + let application = if status.effective_mode == PrivacyEffectiveMode::Full { + self.enter_full_mode() + } else { + self.enter_not_accepted_mode() + }; + if let Err(error) = application { + let mut failed_status = status; + failed_status.effective_mode = PrivacyEffectiveMode::PrivacyNotAccepted; + failed_status.configuration_error = Some(error.code); + return Ok(failed_status); + } + self.status_with_effective_mode(status).await + } +} + +#[tauri::command] +pub async fn privacy_initialize( + state: State<'_, PrivacyServiceState>, + app: tauri::AppHandle, + _request: InitializePrivacyRequest, +) -> Result { + state + .initialize(&app.package_info().version.to_string()) + .await +} + +#[tauri::command] +pub async fn privacy_get_status( + state: State<'_, PrivacyServiceState>, + app: tauri::AppHandle, + request: GetPrivacyStatusRequest, +) -> Result { + if state.service.is_none() { + return Ok(PrivacyStatus::disabled()); + } + let app_version = app.package_info().version.to_string(); + let status = state + .with_service(|service| { + Box::pin(async move { service.status(&request.locale, &app_version).await }) + }) + .await?; + state.status_with_effective_mode(status).await +} + +#[tauri::command] +pub async fn privacy_accept( + state: State<'_, PrivacyServiceState>, + app: tauri::AppHandle, + request: AcceptPrivacyRequest, +) -> Result { + if state.service.is_none() { + return Ok(PrivacyStatus::disabled()); + } + let app_version = app.package_info().version.to_string(); + let status = state + .with_service(|service| { + Box::pin(async move { service.accept(&request, &app_version).await }) + }) + .await?; + state.enter_full_mode()?; + state.status_with_effective_mode(status).await +} + +#[tauri::command] +pub async fn privacy_enter_not_accepted( + state: State<'_, PrivacyServiceState>, + app: tauri::AppHandle, + request: EnterPrivacyNotAcceptedRequest, +) -> Result { + if state.service.is_none() { + return Ok(PrivacyStatus::disabled()); + } + state.enter_not_accepted_mode()?; + let app_version = app.package_info().version.to_string(); + let status = state + .with_service(|service| { + Box::pin(async move { + service + .enter_not_accepted(&request.locale, &app_version) + .await + }) + }) + .await?; + state.status_with_effective_mode(status).await +} + +#[tauri::command] +pub async fn privacy_mark_viewed( + state: State<'_, PrivacyServiceState>, + app: tauri::AppHandle, + request: MarkPrivacyViewedRequest, +) -> Result { + if state.service.is_none() { + return Ok(PrivacyStatus::disabled()); + } + let app_version = app.package_info().version.to_string(); + let status = state + .with_service(|service| { + Box::pin(async move { + service + .mark_viewed(&request.policy_updated_at, &app_version, &request.locale) + .await + }) + }) + .await?; + state.status_with_effective_mode(status).await +} + +#[tauri::command] +pub async fn privacy_apply_collection_policy( + state: State<'_, PrivacyServiceState>, + app: tauri::AppHandle, + request: ApplyPrivacyCollectionPolicyRequest, +) -> Result { + if state.service.is_none() { + return Ok(PrivacyStatus::disabled()); + } + if request.mode == PrivacyEffectiveMode::Full { + let full_is_valid = state + .with_service(|service| Box::pin(async move { service.can_apply_full_mode().await })) + .await?; + if !full_is_valid { + return Err(PrivacyError::new( + "PRIVACY_CONSENT_REQUIRED", + "Full collection mode requires a valid saved consent", + )); + } + } + if request.mode == PrivacyEffectiveMode::Full { + state.enter_full_mode()?; + } else { + state.enter_not_accepted_mode()?; + } + let app_version = app.package_info().version.to_string(); + let status = state + .with_service(|service| { + Box::pin(async move { service.status(&request.locale, &app_version).await }) + }) + .await?; + state.status_with_effective_mode(status).await +} diff --git a/src/apps/desktop/src/api/remote_connect_api.rs b/src/apps/desktop/src/api/remote_connect_api.rs index 15581871c5..d1115aecde 100644 --- a/src/apps/desktop/src/api/remote_connect_api.rs +++ b/src/apps/desktop/src/api/remote_connect_api.rs @@ -3889,6 +3889,9 @@ static SYNC_TX: OnceLock> = OnceLock::new(); /// Called once at app startup to start the settings sync engine and the /// debounced session sync background task. pub fn init_auto_sync() { + if SYNC_TX.get().is_some() { + return; + } start_settings_sync_engine(); let (tx, rx) = mpsc::unbounded_channel::(); let _ = SYNC_TX.set(tx); diff --git a/src/apps/desktop/src/api/remote_workspace_policy.rs b/src/apps/desktop/src/api/remote_workspace_policy.rs index cdc2ac9cbf..b7d2e14b3a 100644 --- a/src/apps/desktop/src/api/remote_workspace_policy.rs +++ b/src/apps/desktop/src/api/remote_workspace_policy.rs @@ -53,6 +53,42 @@ pub enum RemoteWorkspacePolicy { /// Declared remote-workspace policy for every registered Tauri command. pub const REMOTE_WORKSPACE_COMMAND_POLICIES: &[(&str, RemoteWorkspacePolicy)] = &[ + ( + "acknowledge_feedback", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ( + "feedback_get_access_state", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ("list_feedback", RemoteWorkspacePolicy::WorkspaceAgnostic), + ( + "open_feedback_conversation", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ("reply_feedback", RemoteWorkspacePolicy::WorkspaceAgnostic), + ("submit_feedback", RemoteWorkspacePolicy::WorkspaceAgnostic), + ("privacy_accept", RemoteWorkspacePolicy::WorkspaceAgnostic), + ( + "privacy_apply_collection_policy", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ( + "privacy_enter_not_accepted", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ( + "privacy_get_status", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ( + "privacy_initialize", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), + ( + "privacy_mark_viewed", + RemoteWorkspacePolicy::WorkspaceAgnostic, + ), ("accept_file", RemoteWorkspacePolicy::LegacyUnaudited), ("accept_operation", RemoteWorkspacePolicy::LegacyUnaudited), ("accept_session", RemoteWorkspacePolicy::LegacyUnaudited), diff --git a/src/apps/desktop/src/api/system_api.rs b/src/apps/desktop/src/api/system_api.rs index d5dddd6058..ff8f32d412 100644 --- a/src/apps/desktop/src/api/system_api.rs +++ b/src/apps/desktop/src/api/system_api.rs @@ -197,8 +197,13 @@ pub struct SystemInfoResponse { pub async fn get_system_info() -> Result { let info = system::get_system_info(); + #[cfg(target_env = "ohos")] + let platform = "openharmony".to_string(); + #[cfg(not(target_env = "ohos"))] + let platform = info.platform; + Ok(SystemInfoResponse { - platform: info.platform, + platform, arch: info.arch, os_version: info.os_version, }) diff --git a/src/apps/desktop/src/lib.rs b/src/apps/desktop/src/lib.rs index b325c59c66..16c5d2777c 100644 --- a/src/apps/desktop/src/lib.rs +++ b/src/apps/desktop/src/lib.rs @@ -57,10 +57,9 @@ pub use api::*; use crate::ohos::ohos_file_system::{open_oh_file_dialog, set_theme_mode}; use crate::ohos::window::{ - close_window,handle_max_window,handle_min_window,handle_restore_window,window_is_maximized, - window_is_minimized, window_start_dragging + close_window, handle_max_window, handle_min_window, handle_restore_window, window_is_maximized, + window_is_minimized, window_start_dragging, }; -use std::path::PathBuf; use api::acp_client_api::*; use api::clipboard_file_api::*; use api::commands::*; @@ -93,6 +92,7 @@ use api::subagent_api::*; use api::system_api::*; use api::tool_api::*; use startup_trace::{DesktopStartupTrace, DesktopStartupTraceSnapshot}; +use std::path::PathBuf; /// Agentic Coordinator state #[derive(Clone)] @@ -256,9 +256,9 @@ fn show_main_window_for_secondary_launch( .map_err(|error| format!("failed to focus main window: {}", error))?; log::info!( - "Main window shown from secondary launch: attempt={}", - attempt - ); + "Main window shown from secondary launch: attempt={}", + attempt + ); Ok(()) } @@ -266,7 +266,6 @@ fn show_main_window_for_secondary_launch( { Err("Unable to support the main window".to_string()) } - } #[cfg(any(target_os = "linux", target_os = "macos", target_os = "windows"))] @@ -415,6 +414,47 @@ pub async fn _run() { ); } + #[cfg(target_env = "ohos")] + let privacy_service_state = { + let locale = + if let Some(service) = bitfun_core::service::i18n::get_global_i18n_service().await { + service.get_current_locale().await.as_str().to_string() + } else { + "zh-CN".to_string() + }; + api::privacy_api::PrivacyServiceState::enabled( + PathBuf::from("/data/storage/el2/base/files/bitfun/privacy"), + &locale, + ) + }; + + #[cfg(not(target_env = "ohos"))] + let privacy_service_state = api::privacy_api::PrivacyServiceState::disabled(); + + #[cfg(target_env = "ohos")] + let feedback_service_state = { + use bitfun_services_integrations::feedback::FeedbackService; + use std::sync::Arc; + + let credential_store = + Arc::new(api::ohos::feedback_credentials::OhosFeedbackCredentialStore::new()); + api::feedback_api::FeedbackServiceState::enabled( + FeedbackService::from_environment_with_credential_store( + env!("CARGO_PKG_VERSION"), + credential_store, + ) + .with_cache_dir(PathBuf::from( + "/data/storage/el2/base/cache/bitfun/feedback", + )) + .with_identity_path(PathBuf::from( + "/data/storage/el2/base/files/bitfun/config/identity.json", + )), + ) + }; + + #[cfg(not(target_env = "ohos"))] + let feedback_service_state = api::feedback_api::FeedbackServiceState::disabled(); + let step_started = Instant::now(); let startup_log_level = resolve_runtime_log_level(log_config.level).await; startup_trace.record_elapsed_step( @@ -531,6 +571,8 @@ pub async fn _run() { .manage(coordinator) .manage(scheduler) .manage(terminal_state) + .manage(privacy_service_state) + .manage(feedback_service_state) .manage(startup_trace.clone()) .on_page_load(|webview, payload| { let label = webview.label(); @@ -842,11 +884,9 @@ pub async fn _run() { step_started, ); - // Eagerly initialize the remote connect service so previously - // paired bots start listening immediately on app startup. let step_started = Instant::now(); - api::remote_connect_api::init_on_startup(); api::remote_connect_api::init_auto_sync(); + api::remote_connect_api::init_on_startup(); startup_trace.record_elapsed_step( "native_setup", "remote_connect_init_on_startup", @@ -974,6 +1014,18 @@ pub async fn _run() { .invoke_handler(tauri::generate_handler![ theme::show_main_window, hide_main_window_after_close_request, + api::privacy_api::privacy_initialize, + api::privacy_api::privacy_get_status, + api::privacy_api::privacy_accept, + api::privacy_api::privacy_enter_not_accepted, + api::privacy_api::privacy_mark_viewed, + api::privacy_api::privacy_apply_collection_policy, + api::feedback_api::acknowledge_feedback, + api::feedback_api::feedback_get_access_state, + api::feedback_api::list_feedback, + api::feedback_api::open_feedback_conversation, + api::feedback_api::reply_feedback, + api::feedback_api::submit_feedback, api::agentic_api::create_session, api::agentic_api::update_session_model, api::agentic_api::update_session_title, @@ -1793,13 +1845,13 @@ async fn init_function_agents(ai_client_factory: Arc) -> anyhow Ok(()) } -fn init_mcp_servers(app_handle: tauri::AppHandle) { +pub(crate) fn init_mcp_servers(app_handle: tauri::AppHandle) { tauri::async_runtime::spawn(async move { let _ = app_handle; }); } -fn init_acp_clients(app_handle: tauri::AppHandle) { +pub(crate) fn init_acp_clients(app_handle: tauri::AppHandle) { tauri::async_runtime::spawn(async move { let state: tauri::State<'_, api::AppState> = app_handle.state(); if let Some(service) = state.acp_client_service.as_ref() { diff --git a/src/apps/ohos/entry/src/main/ets/entryability/EntryAbility.ets b/src/apps/ohos/entry/src/main/ets/entryability/EntryAbility.ets index d2c7989a88..f370d7f64d 100644 --- a/src/apps/ohos/entry/src/main/ets/entryability/EntryAbility.ets +++ b/src/apps/ohos/entry/src/main/ets/entryability/EntryAbility.ets @@ -3,7 +3,6 @@ import { common, Configuration, ConfigurationConstant, - Permissions, Want } from '@kit.AbilityKit'; import { hilog } from '@kit.PerformanceAnalysisKit'; @@ -20,6 +19,7 @@ import { AppUpdater, UpdateCheckResult } from '../utils/AppUpdater'; import { CommonUtils } from '../utils/CommonUtils'; import { runDeveco } from '../utils/DevecoStart'; import { VoiceInputService } from '../services/VoiceInputService'; +import { FeedbackCredentialStore } from '../utils/FeedbackCredentialStore'; const DOMAIN = 0x0000; @@ -91,8 +91,11 @@ export default class EntryAbility extends RustAbility { AppGlobal.mContext = this.context; const DOMAIN_NUMBER: number = 0xFF00; const TAG: string = '[BitfunUIAbilityComponents]'; - AppGlobal.calendarMgr = calendarManager.getCalendarManager(this.context); - + const feedbackCredentialStore = new FeedbackCredentialStore(); + RustModule.registerArktsFunction('feedback_secure_credentials', async ( + _err: Error, + arg: string + ): Promise => feedbackCredentialStore.handle(arg)); RustModule.registerArktsFunction('open_dialog_file', async (err: Error, arg: string): Promise => { let res = await CommonUtils.open_file_dialog(arg); return res; @@ -287,11 +290,6 @@ export default class EntryAbility extends RustAbility { RustModule.registerArktsFunction('ohos_speech_cancel', async (err: Error, _arg: string): Promise => { return await this.voiceInputService.cancel(''); }); - const autoUpdateEnabled = RustModule.getAppConfigBool('app.auto_update'); - hilog.info(0x0000, 'vnext', 'autoUpdateEnabled: ' + autoUpdateEnabled); - if (autoUpdateEnabled) { - this.appUpdater.check(this.context); - } return super.onWindowStageCreate(windowStage); } @@ -402,4 +400,4 @@ export async function createMeetingEvent(input: string): Promise { } catch (error) { return "Failed to create calendar or event. Code: " + error; } -} \ No newline at end of file +} diff --git a/src/apps/ohos/entry/src/main/ets/utils/FeedbackCredentialStore.ets b/src/apps/ohos/entry/src/main/ets/utils/FeedbackCredentialStore.ets new file mode 100644 index 0000000000..dc846396ae --- /dev/null +++ b/src/apps/ohos/entry/src/main/ets/utils/FeedbackCredentialStore.ets @@ -0,0 +1,247 @@ +import { util } from '@kit.ArkTS'; +import { BusinessError } from '@kit.BasicServicesKit'; +import { asset } from '@kit.AssetStoreKit'; +import { hilog } from '@kit.PerformanceAnalysisKit'; + +const CORE_ASSET_ALIAS = 'bitfun.feedback.credentials.v1'; +const CAPABILITY_ASSET_PREFIX = 'bitfun.feedback.capability.v1.'; +const CAPABILITY_GROUP = 'bitfun.feedback.capabilities.v1'; +const ASSET_NOT_FOUND = 24000002; +const ASSET_ALREADY_EXISTS = 24000003; +const LOG_DOMAIN = 0x0000; +const LOG_TAG = 'FeedbackCredentialStore'; + +class AssetStoreOperationError extends Error { + readonly code: number; + + constructor(code: number) { + super('AssetStore operation failed'); + this.code = code; + } +} + +interface CredentialRequest { + action: string; + value?: string; +} + +interface CredentialResponse { + status: string; + value?: string; + code?: string; +} + +interface StoredCredentialPayload { + enroll_key?: string; + refresh_token?: string; + capabilities?: Record; +} + +interface CoreCredentialPayload { + enroll_key: string; + refresh_token?: string; +} + +interface StoredCapabilityMetadata { + feedbackId: string; + alias: Uint8Array; +} + +interface StoredCapability extends StoredCapabilityMetadata { + token: string; +} + +export class FeedbackCredentialStore { + private readonly encoder = new util.TextEncoder(); + private readonly decoder = util.TextDecoder.create('utf-8'); + private readonly coreAlias = this.encoder.encodeInto(CORE_ASSET_ALIAS); + private readonly capabilityGroup = this.encoder.encodeInto(CAPABILITY_GROUP); + + async handle(input: string): Promise { + let action = 'parse'; + try { + const request: CredentialRequest = JSON.parse(input); + action = request.action; + if (request.action === 'load') { + return JSON.stringify(await this.load()); + } + if (request.action === 'store' && request.value !== undefined) { + await this.store(request.value); + return JSON.stringify({ status: 'ok' } as CredentialResponse); + } + return JSON.stringify({ status: 'error', code: 'REQUEST_INVALID' } as CredentialResponse); + } catch (error) { + const businessError = error as BusinessError; + const code = businessError.code === undefined ? 'INTERNAL_ERROR' : String(businessError.code); + hilog.error( + LOG_DOMAIN, + LOG_TAG, + 'Secure credential operation failed: action=%{public}s code=%{public}s', + action, + code + ); + return JSON.stringify({ + status: 'error', + code + } as CredentialResponse); + } + } + + private async load(): Promise { + const core = await this.loadCore(); + if (core.status !== 'ok' || core.value === undefined) { + return core; + } + const payload = JSON.parse(core.value) as StoredCredentialPayload; + const capabilities: Record = payload.capabilities ?? {}; + const storedCapabilities = await this.queryCapabilities(); + storedCapabilities.forEach((item: StoredCapability) => { + capabilities[item.feedbackId] = item.token; + }); + payload.capabilities = capabilities; + return { status: 'ok', value: JSON.stringify(payload) }; + } + + private async loadCore(): Promise { + const query: asset.AssetMap = new Map(); + query.set(asset.Tag.ALIAS, this.coreAlias); + query.set(asset.Tag.RETURN_TYPE, asset.ReturnType.ALL); + try { + const results = await asset.query(query); + if (results.length === 0) { + return { status: 'not_found' }; + } + const secret = results[0].get(asset.Tag.SECRET) as Uint8Array; + if (secret === undefined) { + return { status: 'error', code: 'SECRET_MISSING' }; + } + return { status: 'ok', value: this.decoder.decodeToString(secret) }; + } catch (error) { + const businessError = error as BusinessError; + if (businessError.code === ASSET_NOT_FOUND) { + return { status: 'not_found' }; + } + throw new AssetStoreOperationError(businessError.code ?? -1); + } + } + + private async store(value: string): Promise { + const payload = JSON.parse(value) as StoredCredentialPayload; + const capabilities: Record = payload.capabilities ?? {}; + await this.synchronizeCapabilities(capabilities); + + const core: CoreCredentialPayload = { + enroll_key: payload.enroll_key ?? '' + }; + if (payload.refresh_token !== undefined) { + core.refresh_token = payload.refresh_token; + } + await this.upsertAsset(this.coreAlias, JSON.stringify(core)); + } + + private async synchronizeCapabilities(capabilities: Record): Promise { + const existing = await this.queryCapabilityMetadata(); + const desiredIds = Object.keys(capabilities); + for (const feedbackId of desiredIds) { + const alias = this.encoder.encodeInto(`${CAPABILITY_ASSET_PREFIX}${feedbackId}`); + await this.upsertAsset(alias, capabilities[feedbackId], feedbackId); + } + for (const item of existing) { + if (desiredIds.indexOf(item.feedbackId) < 0) { + const query: asset.AssetMap = new Map(); + query.set(asset.Tag.ALIAS, item.alias); + await asset.remove(query); + } + } + } + + private async queryCapabilities(): Promise { + const metadata = await this.queryCapabilityMetadata(); + const capabilities: StoredCapability[] = []; + for (const item of metadata) { + const token = await this.loadCapabilitySecret(item.alias); + capabilities.push({ + feedbackId: item.feedbackId, + token, + alias: item.alias + }); + } + return capabilities; + } + + private async queryCapabilityMetadata(): Promise { + const query: asset.AssetMap = new Map(); + query.set(asset.Tag.DATA_LABEL_NORMAL_1, this.capabilityGroup); + query.set(asset.Tag.RETURN_TYPE, asset.ReturnType.ATTRIBUTES); + query.set(asset.Tag.RETURN_LIMIT, 1000); + try { + const results = await asset.query(query); + const capabilities: StoredCapabilityMetadata[] = []; + results.forEach((item: asset.AssetMap) => { + const alias = item.get(asset.Tag.ALIAS) as Uint8Array; + const feedbackId = item.get(asset.Tag.DATA_LABEL_NORMAL_2) as Uint8Array; + if (alias !== undefined && feedbackId !== undefined) { + capabilities.push({ + feedbackId: this.decoder.decodeToString(feedbackId), + alias + }); + } + }); + return capabilities; + } catch (error) { + const businessError = error as BusinessError; + if (businessError.code === ASSET_NOT_FOUND) { + return []; + } + throw new AssetStoreOperationError(businessError.code ?? -1); + } + } + + private async loadCapabilitySecret(alias: Uint8Array): Promise { + const query: asset.AssetMap = new Map(); + query.set(asset.Tag.ALIAS, alias); + query.set(asset.Tag.RETURN_TYPE, asset.ReturnType.ALL); + const results = await asset.query(query); + const secret = results[0]?.get(asset.Tag.SECRET) as Uint8Array; + if (secret === undefined) { + throw new Error('SECRET_MISSING'); + } + return this.decoder.decodeToString(secret); + } + + private async upsertAsset(alias: Uint8Array, value: string, feedbackId?: string): Promise { + const query: asset.AssetMap = new Map(); + query.set(asset.Tag.ALIAS, alias); + const update: asset.AssetMap = new Map(); + update.set(asset.Tag.SECRET, this.encoder.encodeInto(value)); + try { + await asset.update(query, update); + return; + } catch (error) { + const businessError = error as BusinessError; + if (businessError.code !== ASSET_NOT_FOUND) { + throw new AssetStoreOperationError(businessError.code ?? -1); + } + } + + const attributes: asset.AssetMap = new Map(); + attributes.set(asset.Tag.ALIAS, alias); + attributes.set(asset.Tag.SECRET, this.encoder.encodeInto(value)); + attributes.set(asset.Tag.ACCESSIBILITY, asset.Accessibility.DEVICE_FIRST_UNLOCKED); + attributes.set(asset.Tag.AUTH_TYPE, asset.AuthType.NONE); + attributes.set(asset.Tag.SYNC_TYPE, asset.SyncType.NEVER); + if (feedbackId !== undefined) { + attributes.set(asset.Tag.DATA_LABEL_NORMAL_1, this.capabilityGroup); + attributes.set(asset.Tag.DATA_LABEL_NORMAL_2, this.encoder.encodeInto(feedbackId)); + } + try { + await asset.add(attributes); + } catch (error) { + const businessError = error as BusinessError; + if (businessError.code !== ASSET_ALREADY_EXISTS) { + throw new AssetStoreOperationError(businessError.code ?? -1); + } + await asset.update(query, update); + } + } +} diff --git a/src/crates/assembly/core/src/util/register_arkts_function.rs b/src/crates/assembly/core/src/util/register_arkts_function.rs index bb79402325..b67b44db6f 100644 --- a/src/crates/assembly/core/src/util/register_arkts_function.rs +++ b/src/crates/assembly/core/src/util/register_arkts_function.rs @@ -75,6 +75,26 @@ pub fn notify_system_color_mode(mode: String) { }); } +pub async fn call_arkts_string_function( + function_name: &str, + input: String, +) -> Result { + let function = { + let lock = JS_THREADSAFE_FUNCTION.read(); + lock.get(function_name).cloned() + }; + + let Some(function) = function else { + return Err(format!("{function_name} has not registered")); + }; + + let promise = function + .call_async(Ok(input)) + .await + .map_err(|error| error.to_string())?; + promise.await.map_err(|error| error.to_string()) +} + pub async fn open_dialog_file(options: &str) -> Result { let function = { let lock = JS_THREADSAFE_FUNCTION.read(); diff --git a/src/crates/contracts/product-domains/src/feedback.rs b/src/crates/contracts/product-domains/src/feedback.rs new file mode 100644 index 0000000000..9601c1af1d --- /dev/null +++ b/src/crates/contracts/product-domains/src/feedback.rs @@ -0,0 +1,251 @@ +use serde::{Deserialize, Serialize}; + +pub const FEEDBACK_CONTENT_MAX_CHARS: usize = 2_000; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FeedbackCategory { + RuntimeError, + FeatureRequest, + UsageQuestion, + Other, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FeedbackStatus { + Submitted, + InProgress, + WaitingUser, + Resolved, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum FeedbackSender { + User, + Admin, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SubmitFeedbackRequest { + pub category: FeedbackCategory, + pub content: String, + #[serde(default)] + pub trace_id: Option, + #[serde(default)] + pub session_id_hash: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SubmitFeedbackResponse { + pub feedback_id: String, + pub status: FeedbackStatus, + pub inbox_cursor: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ListFeedbackRecordsRequest { + #[serde(default)] + pub cursor: Option, + pub page_size: u16, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FeedbackRecordSummary { + pub feedback_id: String, + pub category: FeedbackCategory, + pub status: FeedbackStatus, + pub has_new_reply: bool, + pub created_at: String, + pub updated_at: String, + pub can_open: bool, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FeedbackInboxPage { + pub items: Vec, + #[serde(default)] + pub next_cursor: Option, + pub has_more: bool, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FeedbackAccessState { + pub has_history: bool, + pub can_reuse_access: bool, + #[serde(default)] + pub cached_inbox: FeedbackInboxPage, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct OpenFeedbackConversationRequest { + pub feedback_id: String, + #[serde(default)] + pub cursor: Option, + pub page_size: u16, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FeedbackMessage { + pub message_id: String, + pub sender: FeedbackSender, + pub content: String, + #[serde(default)] + pub content_deleted: bool, + pub created_at: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FeedbackConversationPage { + pub messages: Vec, + #[serde(default)] + pub next_cursor: Option, + pub has_more: bool, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub sync_error: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AcknowledgeFeedbackRequest { + pub feedback_id: String, + pub last_visible_at: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AcknowledgeFeedbackResponse { + pub read_through: String, + pub feedback_status: FeedbackStatus, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReplyFeedbackRequest { + pub feedback_id: String, + pub content: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ReplyFeedbackResponse { + pub message: FeedbackMessage, + pub feedback_status: FeedbackStatus, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct FeedbackError { + pub code: String, + pub message: String, + pub retryable: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub request_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub retry_after_seconds: Option, +} + +impl FeedbackError { + pub fn new(code: &str, message: &str, retryable: bool) -> Self { + Self { + code: code.to_string(), + message: message.to_string(), + retryable, + request_id: None, + retry_after_seconds: None, + } + } + + pub fn validation(code: &str, message: &str) -> Self { + Self::new(code, message, false) + } +} + +pub fn validate_content(content: &str) -> Result<(), FeedbackError> { + let length = content.trim().chars().count(); + if length == 0 { + return Err(FeedbackError::validation( + "CONTENT_EMPTY", + "Feedback content is required", + )); + } + if length > FEEDBACK_CONTENT_MAX_CHARS { + return Err(FeedbackError::validation( + "CONTENT_TOO_LONG", + "Feedback content exceeds 2000 Unicode characters", + )); + } + Ok(()) +} + +pub fn validate_inbox_page_size(page_size: u16) -> Result<(), FeedbackError> { + if !(1..=100).contains(&page_size) { + return Err(FeedbackError::validation( + "PAGE_SIZE_INVALID", + "Inbox page size must be between 1 and 100", + )); + } + Ok(()) +} + +pub fn validate_message_page_size(page_size: u16) -> Result<(), FeedbackError> { + if !(1..=200).contains(&page_size) { + return Err(FeedbackError::validation( + "PAGE_SIZE_INVALID", + "Message page size must be between 1 and 200", + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::{ + validate_content, validate_inbox_page_size, validate_message_page_size, + FEEDBACK_CONTENT_MAX_CHARS, + }; + + #[test] + fn validates_feedback_content_by_unicode_scalar_count() { + assert_eq!(validate_content(" \n ").unwrap_err().code, "CONTENT_EMPTY"); + assert!(validate_content(&"中".repeat(FEEDBACK_CONTENT_MAX_CHARS)).is_ok()); + assert_eq!( + validate_content(&"中".repeat(FEEDBACK_CONTENT_MAX_CHARS + 1)) + .unwrap_err() + .code, + "CONTENT_TOO_LONG" + ); + } + + #[test] + fn validates_inbox_page_size() { + assert!(validate_inbox_page_size(20).is_ok()); + assert_eq!( + validate_inbox_page_size(0).unwrap_err().code, + "PAGE_SIZE_INVALID" + ); + assert_eq!( + validate_inbox_page_size(101).unwrap_err().code, + "PAGE_SIZE_INVALID" + ); + } + + #[test] + fn validates_message_page_size() { + assert!(validate_message_page_size(50).is_ok()); + assert_eq!( + validate_message_page_size(201).unwrap_err().code, + "PAGE_SIZE_INVALID" + ); + } +} diff --git a/src/crates/contracts/product-domains/src/lib.rs b/src/crates/contracts/product-domains/src/lib.rs index b63296d564..7f62685d06 100644 --- a/src/crates/contracts/product-domains/src/lib.rs +++ b/src/crates/contracts/product-domains/src/lib.rs @@ -4,6 +4,8 @@ //! the full BitFun core runtime assembly. pub mod canvas; +pub mod feedback; +pub mod privacy; pub mod tool_permissions; #[cfg(feature = "external-sources")] diff --git a/src/crates/contracts/product-domains/src/privacy.rs b/src/crates/contracts/product-domains/src/privacy.rs new file mode 100644 index 0000000000..360933a5a0 --- /dev/null +++ b/src/crates/contracts/product-domains/src/privacy.rs @@ -0,0 +1,137 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PrivacyLifecycleState { + ChoiceRequired, + Full, + PrivacyNotAccepted, + ResourceError, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum PrivacyEffectiveMode { + Full, + PrivacyNotAccepted, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum PrivacyChangeType { + Material, + Editorial, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PrivacyConsentRecord { + pub consent_version: String, + pub accepted_policy_updated_at: String, + pub accepted_document_sha256: String, + pub accepted_at: String, + pub locale: String, + pub app_version: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PrivacyPolicyView { + pub consent_version: String, + pub change_type: PrivacyChangeType, + pub effective_at: String, + pub updated_at: String, + pub locale: String, + pub document_sha256: String, + pub content: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PrivacyStatus { + pub enabled: bool, + pub lifecycle_state: PrivacyLifecycleState, + pub effective_mode: PrivacyEffectiveMode, + pub release_ready: bool, + pub has_unread_update: bool, + #[serde(skip_serializing_if = "Option::is_none")] + pub policy: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub consent: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub configuration_error: Option, +} + +impl PrivacyStatus { + pub fn disabled() -> Self { + Self { + enabled: false, + lifecycle_state: PrivacyLifecycleState::Full, + effective_mode: PrivacyEffectiveMode::Full, + release_ready: true, + has_unread_update: false, + policy: None, + consent: None, + configuration_error: None, + } + } + + pub fn collection_allowed(&self) -> bool { + !self.enabled || self.effective_mode == PrivacyEffectiveMode::Full + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct InitializePrivacyRequest {} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct GetPrivacyStatusRequest { + pub locale: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct AcceptPrivacyRequest { + pub policy_updated_at: String, + pub consent_version: String, + pub document_sha256: String, + pub locale: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct EnterPrivacyNotAcceptedRequest { + pub locale: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct MarkPrivacyViewedRequest { + pub policy_updated_at: String, + pub locale: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ApplyPrivacyCollectionPolicyRequest { + pub mode: PrivacyEffectiveMode, + pub locale: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PrivacyError { + pub code: String, + pub message: String, +} + +impl PrivacyError { + pub fn new(code: &str, message: impl Into) -> Self { + Self { + code: code.to_string(), + message: message.into(), + } + } +} diff --git a/src/crates/services/services-integrations/Cargo.toml b/src/crates/services/services-integrations/Cargo.toml index a31485669c..74d935ba9a 100644 --- a/src/crates/services/services-integrations/Cargo.toml +++ b/src/crates/services/services-integrations/Cargo.toml @@ -101,6 +101,23 @@ debug-log = ["anyhow", "chrono", "reqwest", "uuid"] deep-research = ["bitfun-agent-runtime"] git = ["bitfun-services-core", "chrono", "git2", "thiserror"] file-watch = ["notify"] +privacy = [ + "chrono", + "dep:bitfun-product-domains", + "sha2", +] +feedback = [ + "aes-gcm", + "anyhow", + "async-trait", + "base64", + "chrono", + "dep:bitfun-product-domains", + "rand", + "reqwest", + "sha2", + "uuid", +] function-agents = [ "bitfun-product-domains/function-agents", "dep:bitfun-product-domains", @@ -248,6 +265,7 @@ product-full = [ "debug-log", "deep-research", "file-watch", + "feedback", "function-agents", "git", "miniapp-runtime", diff --git a/src/crates/services/services-integrations/src/feedback/identity.rs b/src/crates/services/services-integrations/src/feedback/identity.rs new file mode 100644 index 0000000000..2dda6fc0cc --- /dev/null +++ b/src/crates/services/services-integrations/src/feedback/identity.rs @@ -0,0 +1,85 @@ +use anyhow::{Context, Result}; +use serde::Serialize; +use std::path::{Path, PathBuf}; + +const IDENTITY_SCHEMA_VERSION: u8 = 1; + +#[derive(Serialize)] +#[serde(rename_all = "camelCase")] +struct FeedbackIdentity<'a> { + schema_version: u8, + anonymous_id: &'a str, +} + +pub(super) struct FeedbackIdentityStore { + path: PathBuf, +} + +impl FeedbackIdentityStore { + pub(super) fn new(path: PathBuf) -> Self { + Self { path } + } + + pub(super) async fn store(&self, anonymous_id: &str) -> Result<()> { + uuid::Uuid::parse_str(anonymous_id).context("validate feedback anonymous identity")?; + if let Some(parent) = self.path.parent() { + tokio::fs::create_dir_all(parent) + .await + .context("create feedback identity directory")?; + } + let value = serde_json::to_vec_pretty(&FeedbackIdentity { + schema_version: IDENTITY_SCHEMA_VERSION, + anonymous_id, + }) + .context("encode feedback identity")?; + let temporary_path = self.path.with_extension("json.tmp"); + tokio::fs::write(&temporary_path, value) + .await + .context("write feedback identity")?; + set_owner_only(&temporary_path); + tokio::fs::rename(&temporary_path, &self.path) + .await + .context("replace feedback identity")?; + set_owner_only(&self.path); + Ok(()) + } +} + +#[cfg(unix)] +fn set_owner_only(path: &Path) { + use std::os::unix::fs::PermissionsExt; + if let Err(error) = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) { + log::warn!("Failed to restrict feedback identity file permissions: {error}"); + } +} + +#[cfg(not(unix))] +fn set_owner_only(_path: &Path) {} + +#[cfg(test)] +mod tests { + use super::FeedbackIdentityStore; + + #[tokio::test] + async fn stores_only_the_plain_anonymous_identity() { + let directory = std::env::temp_dir().join(format!( + "bitfun-feedback-identity-{}-{}", + std::process::id(), + uuid::Uuid::new_v4() + )); + let path = directory.join("identity.json"); + let store = FeedbackIdentityStore::new(path.clone()); + + store + .store("11111111-1111-4111-8111-111111111111") + .await + .unwrap(); + + let value: serde_json::Value = + serde_json::from_slice(&tokio::fs::read(&path).await.unwrap()).unwrap(); + assert_eq!(value["schemaVersion"], 1); + assert_eq!(value["anonymousId"], "11111111-1111-4111-8111-111111111111"); + assert_eq!(value.as_object().unwrap().len(), 2); + let _ = tokio::fs::remove_dir_all(directory).await; + } +} diff --git a/src/crates/services/services-integrations/src/feedback/message_cache.rs b/src/crates/services/services-integrations/src/feedback/message_cache.rs new file mode 100644 index 0000000000..b170d6538d --- /dev/null +++ b/src/crates/services/services-integrations/src/feedback/message_cache.rs @@ -0,0 +1,186 @@ +use aes_gcm::aead::{Aead, KeyInit, Payload}; +use aes_gcm::{Aes256Gcm, Nonce}; +use anyhow::{Context, Result}; +use bitfun_product_domains::feedback::FeedbackMessage; +use rand::RngCore; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::path::{Path, PathBuf}; + +const CACHE_VERSION: u8 = 1; +const NONCE_LEN: usize = 12; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub(super) struct MessageCacheData { + version: u8, + pub(super) feedback_id: String, + pub(super) messages: Vec, + #[serde(default)] + pub(super) sync_cursor: Option, + #[serde(default)] + pub(super) sync_complete: bool, +} + +impl MessageCacheData { + pub(super) fn empty(feedback_id: &str) -> Self { + Self { + version: CACHE_VERSION, + feedback_id: feedback_id.to_string(), + messages: Vec::new(), + sync_cursor: None, + sync_complete: false, + } + } +} + +pub(super) struct MessageCache { + directory: PathBuf, + key: [u8; 32], +} + +impl MessageCache { + pub(super) fn new(directory: PathBuf, key: [u8; 32]) -> Self { + Self { directory, key } + } + + pub(super) async fn load(&self, feedback_id: &str) -> Result> { + let path = self.path(feedback_id); + if !path.exists() { + return Ok(None); + } + let blob = tokio::fs::read(&path) + .await + .context("read feedback message cache")?; + if blob.len() <= NONCE_LEN { + anyhow::bail!("feedback message cache is truncated"); + } + let (nonce, ciphertext) = blob.split_at(NONCE_LEN); + let cipher = Aes256Gcm::new_from_slice(&self.key) + .map_err(|error| anyhow::anyhow!("initialize feedback cache cipher: {error}"))?; + let plaintext = cipher + .decrypt( + Nonce::from_slice(nonce), + Payload { + msg: ciphertext, + aad: feedback_id.as_bytes(), + }, + ) + .map_err(|error| anyhow::anyhow!("decrypt feedback message cache: {error}"))?; + let data: MessageCacheData = + serde_json::from_slice(&plaintext).context("decode feedback message cache")?; + if data.version != CACHE_VERSION || data.feedback_id != feedback_id { + anyhow::bail!("feedback message cache identity is invalid"); + } + Ok(Some(data)) + } + + pub(super) async fn store(&self, data: &MessageCacheData) -> Result<()> { + tokio::fs::create_dir_all(&self.directory) + .await + .context("create feedback message cache directory")?; + let plaintext = serde_json::to_vec(data).context("encode feedback message cache")?; + let cipher = Aes256Gcm::new_from_slice(&self.key) + .map_err(|error| anyhow::anyhow!("initialize feedback cache cipher: {error}"))?; + let mut nonce = [0u8; NONCE_LEN]; + rand::rngs::OsRng.fill_bytes(&mut nonce); + let ciphertext = cipher + .encrypt( + Nonce::from_slice(&nonce), + Payload { + msg: &plaintext, + aad: data.feedback_id.as_bytes(), + }, + ) + .map_err(|error| anyhow::anyhow!("encrypt feedback message cache: {error}"))?; + let mut blob = Vec::with_capacity(NONCE_LEN + ciphertext.len()); + blob.extend_from_slice(&nonce); + blob.extend_from_slice(&ciphertext); + + let path = self.path(&data.feedback_id); + let temporary_path = path.with_extension("cache.tmp"); + tokio::fs::write(&temporary_path, blob) + .await + .context("write feedback message cache")?; + set_owner_only(&temporary_path); + tokio::fs::rename(&temporary_path, &path) + .await + .context("replace feedback message cache")?; + set_owner_only(&path); + Ok(()) + } + + pub(super) async fn remove(&self, feedback_id: &str) -> Result<()> { + let path = self.path(feedback_id); + match tokio::fs::remove_file(path).await { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error).context("remove feedback message cache"), + } + } + + fn path(&self, feedback_id: &str) -> PathBuf { + let digest = Sha256::digest(feedback_id.as_bytes()); + self.directory.join(format!("{:x}.cache", digest)) + } +} + +#[cfg(unix)] +fn set_owner_only(path: &Path) { + use std::os::unix::fs::PermissionsExt; + if let Err(error) = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) { + log::warn!("Failed to restrict feedback message cache permissions: {error}"); + } +} + +#[cfg(not(unix))] +fn set_owner_only(_path: &Path) {} + +#[cfg(test)] +mod tests { + use super::{MessageCache, MessageCacheData}; + use bitfun_product_domains::feedback::{FeedbackMessage, FeedbackSender}; + + #[tokio::test] + async fn encrypts_message_content_and_rejects_corruption() { + let directory = std::env::temp_dir().join(format!( + "bitfun-feedback-message-cache-{}-{}", + std::process::id(), + uuid::Uuid::new_v4() + )); + let cache = MessageCache::new(directory.clone(), [7u8; 32]); + let mut data = MessageCacheData::empty("feedback-1"); + data.messages.push(FeedbackMessage { + message_id: "message-1".to_string(), + sender: FeedbackSender::Admin, + content: "private message body".to_string(), + content_deleted: true, + created_at: "2026-07-28T01:00:00Z".to_string(), + }); + data.sync_cursor = Some("cursor-1".to_string()); + data.sync_complete = true; + + cache.store(&data).await.unwrap(); + let path = cache.path("feedback-1"); + let encrypted = tokio::fs::read(&path).await.unwrap(); + assert!(!String::from_utf8_lossy(&encrypted).contains("private message body")); + let restored = cache.load("feedback-1").await.unwrap().unwrap(); + assert_eq!(restored.messages, data.messages); + + tokio::fs::write(&path, b"corrupt").await.unwrap(); + assert!(cache.load("feedback-1").await.is_err()); + let _ = tokio::fs::remove_dir_all(directory).await; + } + + #[test] + fn defaults_deleted_marker_for_legacy_cached_messages() { + let message: FeedbackMessage = serde_json::from_value(serde_json::json!({ + "messageId": "message-legacy", + "sender": "admin", + "content": "legacy content", + "createdAt": "2026-07-28T01:00:00Z" + })) + .unwrap(); + + assert!(!message.content_deleted); + } +} diff --git a/src/crates/services/services-integrations/src/feedback/mod.rs b/src/crates/services/services-integrations/src/feedback/mod.rs new file mode 100644 index 0000000000..0e47ddfec7 --- /dev/null +++ b/src/crates/services/services-integrations/src/feedback/mod.rs @@ -0,0 +1,8 @@ +mod identity; +mod message_cache; +mod service; +mod state_cache; +mod vault; + +pub use service::FeedbackService; +pub use vault::{FeedbackCredentialStore, FileFeedbackCredentialStore}; diff --git a/src/crates/services/services-integrations/src/feedback/service.rs b/src/crates/services/services-integrations/src/feedback/service.rs new file mode 100644 index 0000000000..79ab2ef195 --- /dev/null +++ b/src/crates/services/services-integrations/src/feedback/service.rs @@ -0,0 +1,2528 @@ +use super::identity::FeedbackIdentityStore; +use super::message_cache::{MessageCache, MessageCacheData}; +use super::state_cache::{FeedbackStateCache, FeedbackStateCacheData}; +use super::vault::{FeedbackCredentialStore, FileFeedbackCredentialStore}; +use bitfun_product_domains::feedback::{ + validate_content, validate_inbox_page_size, validate_message_page_size, + AcknowledgeFeedbackRequest, AcknowledgeFeedbackResponse, FeedbackAccessState, + FeedbackConversationPage, FeedbackError, FeedbackInboxPage, FeedbackMessage, + FeedbackRecordSummary, FeedbackSender, FeedbackStatus, ListFeedbackRecordsRequest, + OpenFeedbackConversationRequest, ReplyFeedbackRequest, ReplyFeedbackResponse, + SubmitFeedbackRequest, SubmitFeedbackResponse, +}; +use chrono::{DateTime, Duration as ChronoDuration, Utc}; +use reqwest::{Response, StatusCode}; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::collections::{HashMap, HashSet}; +use std::path::PathBuf; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::Mutex; +use uuid::Uuid; + +const REQUEST_TIMEOUT: Duration = Duration::from_secs(20); +const ACCESS_TOKEN_REFRESH_MARGIN_SECONDS: i64 = 600; + +const DEBUG_FEEDBACK_API_BASE_URL: &str = "http://api-test.infra-bitfun.com"; +const RELEASE_FEEDBACK_API_BASE_URL: &str = "https://api.infra-bitfun.com"; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +struct StoredCredentials { + enroll_key: String, + #[serde(default, skip_serializing)] + enroll_idempotency_key: Option, + #[serde(default, skip_serializing)] + refresh_idempotency_key: Option, + #[serde(default, skip_serializing)] + anonymous_id: Option, + #[serde(default)] + refresh_token: Option, + #[serde(default)] + capabilities: HashMap, + #[serde(default, skip_serializing)] + pending_create_fingerprint: Option, + #[serde(default, skip_serializing)] + pending_create_idempotency_key: Option, + #[serde(default, skip_serializing)] + inbox_items: Vec, + #[serde(default, skip_serializing)] + inbox_next_cursor: Option, + #[serde(default, skip_serializing)] + inbox_has_more: bool, + #[serde(default, skip_serializing)] + read_cursors: HashMap, + #[serde(default, skip_serializing)] + pending_reply_fingerprints: HashMap, + #[serde(default, skip_serializing)] + pending_reply_idempotency_keys: HashMap, +} + +impl StoredCredentials { + fn cached_state(&self) -> FeedbackStateCacheData { + FeedbackStateCacheData { + enroll_idempotency_key: self.enroll_idempotency_key.clone(), + refresh_idempotency_key: self.refresh_idempotency_key.clone(), + anonymous_id: self.anonymous_id.clone(), + pending_create_fingerprint: self.pending_create_fingerprint.clone(), + pending_create_idempotency_key: self.pending_create_idempotency_key.clone(), + inbox_items: self.inbox_items.clone(), + inbox_next_cursor: self.inbox_next_cursor.clone(), + inbox_has_more: self.inbox_has_more, + read_cursors: self.read_cursors.clone(), + pending_reply_fingerprints: self.pending_reply_fingerprints.clone(), + pending_reply_idempotency_keys: self.pending_reply_idempotency_keys.clone(), + ..FeedbackStateCacheData::default() + } + .with_current_version() + } + + fn apply_cached_state(&mut self, cached: FeedbackStateCacheData) { + self.enroll_idempotency_key = cached.enroll_idempotency_key; + self.refresh_idempotency_key = cached.refresh_idempotency_key; + self.anonymous_id = cached.anonymous_id; + self.pending_create_fingerprint = cached.pending_create_fingerprint; + self.pending_create_idempotency_key = cached.pending_create_idempotency_key; + self.inbox_items = cached.inbox_items; + self.inbox_next_cursor = cached.inbox_next_cursor; + self.inbox_has_more = cached.inbox_has_more; + self.read_cursors = cached.read_cursors; + self.pending_reply_fingerprints = cached.pending_reply_fingerprints; + self.pending_reply_idempotency_keys = cached.pending_reply_idempotency_keys; + } + + fn has_legacy_cached_state(&self) -> bool { + self.enroll_idempotency_key.is_some() + || self.refresh_idempotency_key.is_some() + || self.anonymous_id.is_some() + || self.pending_create_fingerprint.is_some() + || self.pending_create_idempotency_key.is_some() + || !self.inbox_items.is_empty() + || self.inbox_next_cursor.is_some() + || self.inbox_has_more + || !self.read_cursors.is_empty() + || !self.pending_reply_fingerprints.is_empty() + || !self.pending_reply_idempotency_keys.is_empty() + } +} + +#[derive(Debug, Clone)] +struct AccessToken { + value: String, + expires_at: DateTime, + scopes: Vec, +} + +#[derive(Debug, Default)] +struct RuntimeState { + loaded: bool, + stored: StoredCredentials, + access_token: Option, +} + +#[derive(Debug, Deserialize)] +struct TokenResponse { + anonymous_id: String, + access_token: String, + refresh_token: String, + expires_in: i64, + scope: String, +} + +#[derive(Debug, Deserialize)] +struct CreateResponse { + feedback_id: String, + capability_token: String, + status: FeedbackStatus, + inbox_cursor: String, +} + +#[derive(Debug, Serialize)] +struct CreateRequestBody<'a> { + category: bitfun_product_domains::feedback::FeedbackCategory, + content: &'a str, + #[serde(skip_serializing_if = "Option::is_none")] + trace_id: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + session_id_hash: Option<&'a str>, + #[serde(skip_serializing_if = "Option::is_none")] + client_version: Option<&'a str>, +} + +#[derive(Debug, Deserialize)] +struct InboxResponse { + items: Vec, + cursor: String, + has_more: bool, +} + +#[derive(Debug, Deserialize)] +struct InboxItem { + feedback_id: String, + category: bitfun_product_domains::feedback::FeedbackCategory, + status: FeedbackStatus, + has_new_reply: bool, + created_at: String, + updated_at: String, +} + +#[derive(Debug, Deserialize)] +struct MessagesResponse { + feedback_id: String, + messages: Vec, + cursor: String, + has_more: bool, +} + +#[derive(Debug, Deserialize)] +struct MessageItem { + message_id: String, + sender_type: FeedbackSender, + content: String, + content_deleted: bool, + created_at: String, +} + +#[derive(Debug, Serialize)] +struct AcknowledgeRequestBody<'a> { + read_cursor: &'a str, +} + +#[derive(Debug, Deserialize)] +struct AcknowledgeResponseBody { + feedback_id: String, + read_cursor: String, + feedback_status: FeedbackStatus, +} + +#[derive(Debug, Serialize)] +struct ReplyRequestBody<'a> { + content: &'a str, +} + +#[derive(Debug, Deserialize)] +struct ReplyResponseBody { + message_id: String, + sender_type: FeedbackSender, + created_at: String, + feedback_status: FeedbackStatus, +} + +#[derive(Debug, Default, Deserialize)] +struct ServerErrorBody { + error_code: Option, + request_id: Option, +} + +pub struct FeedbackService { + client: reqwest::Client, + base_url: Option, + client_version: String, + credential_store: Arc, + cache_dir: Option, + identity_store: Option, + state: Mutex, +} + +impl FeedbackService { + pub fn from_environment(data_dir: PathBuf, client_version: impl Into) -> Self { + let cache_dir = data_dir.join("feedback-cache"); + Self::from_environment_with_credential_store( + client_version, + Arc::new(FileFeedbackCredentialStore::new(data_dir)), + ) + .with_cache_dir(cache_dir) + } + + pub fn from_environment_with_credential_store( + client_version: impl Into, + credential_store: Arc, + ) -> Self { + Self::new( + configured_base_url(), + client_version.into(), + credential_store, + REQUEST_TIMEOUT, + ) + } + + fn new( + base_url: Option, + client_version: String, + credential_store: Arc, + timeout: Duration, + ) -> Self { + Self { + client: reqwest::Client::builder() + .timeout(timeout) + .build() + .expect("feedback HTTP client must initialize"), + base_url, + client_version, + credential_store, + cache_dir: None, + identity_store: None, + state: Mutex::new(RuntimeState::default()), + } + } + + pub fn with_message_cache_dir(mut self, message_cache_dir: PathBuf) -> Self { + self.cache_dir = Some(message_cache_dir); + self + } + + pub fn with_cache_dir(mut self, cache_dir: PathBuf) -> Self { + self.cache_dir = Some(cache_dir); + self + } + + pub fn with_identity_path(mut self, identity_path: PathBuf) -> Self { + self.identity_store = Some(FeedbackIdentityStore::new(identity_path)); + self + } + + pub async fn submit_feedback( + &self, + request: SubmitFeedbackRequest, + ) -> Result { + let request = normalize_request(request); + validate_content(&request.content)?; + let idempotency_key = self.create_idempotency_key(&request).await?; + let body = CreateRequestBody { + category: request.category, + content: request.content.trim(), + trace_id: request.trace_id.as_deref(), + session_id_hash: request.session_id_hash.as_deref(), + client_version: valid_optional_value(&self.client_version, 20), + }; + let response = self + .send_authenticated("feedback:write", |token| { + self.client + .post(self.url("/support/v1/feedback")?) + .bearer_auth(token) + .header("X-Request-ID", Uuid::new_v4().to_string()) + .header("Idempotency-Key", &idempotency_key) + .json(&body) + .build() + .map_err(network_error) + }) + .await?; + let created: CreateResponse = decode_success(response, StatusCode::CREATED).await?; + if created.status != FeedbackStatus::Submitted { + return Err(FeedbackError::new( + "RESPONSE_INVALID", + "Feedback service returned an invalid initial status", + true, + )); + } + + let mut state = self.state.lock().await; + self.ensure_loaded(&mut state).await?; + let mut next = state.stored.clone(); + next.capabilities + .insert(created.feedback_id.clone(), created.capability_token); + next.pending_create_fingerprint = None; + next.pending_create_idempotency_key = None; + if let Err(error) = self.persist_credentials(&next).await { + return Err(FeedbackError { + code: "CAPABILITY_SAVE_FAILED".to_string(), + message: "Feedback access could not be saved securely".to_string(), + retryable: true, + request_id: error.request_id, + retry_after_seconds: None, + }); + } + state.stored = next; + self.persist_cached_state_best_effort(&state.stored).await; + Ok(SubmitFeedbackResponse { + feedback_id: created.feedback_id, + status: created.status, + inbox_cursor: created.inbox_cursor, + }) + } + + pub async fn access_state(&self) -> Result { + let mut state = self.state.lock().await; + self.ensure_existing_loaded(&mut state).await?; + let can_reuse_access = state.stored.refresh_token.is_some(); + let has_history = + !state.stored.capabilities.is_empty() || !state.stored.inbox_items.is_empty(); + Ok(FeedbackAccessState { + has_history, + can_reuse_access, + cached_inbox: cached_inbox(&state.stored, can_reuse_access), + }) + } + + pub async fn list_feedback( + &self, + request: ListFeedbackRecordsRequest, + ) -> Result { + validate_inbox_page_size(request.page_size)?; + let cursor = request.cursor.clone(); + let page_size = request.page_size.to_string(); + let response = self + .send_authenticated_existing("feedback:read", |token| { + let mut request = self + .client + .get(self.url("/support/v1/feedback/inbox")?) + .bearer_auth(token) + .header("X-Request-ID", Uuid::new_v4().to_string()) + .query(&[("limit", &page_size)]); + if let Some(cursor) = cursor.as_deref() { + request = request.query(&[("cursor", cursor)]); + } + request.build().map_err(network_error) + }) + .await?; + let received: InboxResponse = decode_success(response, StatusCode::OK).await?; + + let mut state = self.state.lock().await; + self.ensure_existing_loaded(&mut state).await?; + let can_reuse_access = state.stored.refresh_token.is_some(); + let items = received + .items + .into_iter() + .map(|item| FeedbackRecordSummary { + can_open: can_reuse_access + && state.stored.capabilities.contains_key(&item.feedback_id), + feedback_id: item.feedback_id, + category: item.category, + status: item.status, + has_new_reply: item.has_new_reply, + created_at: item.created_at, + updated_at: item.updated_at, + }) + .collect::>(); + let next_cursor = (!received.cursor.is_empty()).then_some(received.cursor); + + let mut next = state.stored.clone(); + if request.cursor.is_none() { + next.inbox_items = items.clone(); + } else { + for item in &items { + if let Some(existing) = next + .inbox_items + .iter_mut() + .find(|existing| existing.feedback_id == item.feedback_id) + { + *existing = item.clone(); + } else { + next.inbox_items.push(item.clone()); + } + } + } + next.inbox_next_cursor = next_cursor.clone(); + next.inbox_has_more = received.has_more; + state.stored = next; + self.persist_cached_state_best_effort(&state.stored).await; + + Ok(FeedbackInboxPage { + items, + next_cursor, + has_more: received.has_more, + }) + } + + pub async fn open_conversation( + &self, + request: OpenFeedbackConversationRequest, + ) -> Result { + validate_message_page_size(request.page_size)?; + validate_feedback_id(&request.feedback_id)?; + let cache = self.message_cache().await?; + let loaded = cache.load(&request.feedback_id).await; + let mut data = match loaded { + Ok(Some(data)) => data, + Ok(None) => MessageCacheData::empty(&request.feedback_id), + Err(_) => { + cache.remove(&request.feedback_id).await.map_err(|_| { + credential_error( + "CACHE_RESET_FAILED", + "Feedback message cache could not be reset", + ) + })?; + MessageCacheData::empty(&request.feedback_id) + } + }; + + let mut sync_error = None; + if request.cursor.is_none() { + if let Err(error) = self + .synchronize_messages(&request.feedback_id, &cache, &mut data) + .await + { + if is_terminal_capability_error(&error.code) { + self.invalidate_conversation_access(&request.feedback_id, &cache) + .await; + return Err(error); + } + sync_error = Some(error); + } + } + + if !data.sync_complete { + return Ok(FeedbackConversationPage { + messages: Vec::new(), + next_cursor: None, + has_more: false, + sync_error, + }); + } + if request.cursor.is_none() && sync_error.is_none() { + self.reconcile_user_message_unread(&request.feedback_id, &data, true) + .await; + } + page_from_cache( + &data, + request.cursor.as_deref(), + request.page_size, + sync_error, + ) + } + + pub async fn acknowledge_feedback( + &self, + request: AcknowledgeFeedbackRequest, + ) -> Result { + validate_feedback_id(&request.feedback_id)?; + validate_timestamp(&request.last_visible_at)?; + let capability = self.capability_for(&request.feedback_id).await?; + let feedback_id = request.feedback_id.clone(); + let body = AcknowledgeRequestBody { + read_cursor: &request.last_visible_at, + }; + let response = match self + .send_authenticated_existing("feedback:read", |token| { + self.client + .post(self.url(&format!("/support/v1/feedback/{feedback_id}/ack"))?) + .bearer_auth(token) + .header("X-Feedback-Capability", &capability) + .header("X-Request-ID", Uuid::new_v4().to_string()) + .json(&body) + .build() + .map_err(network_error) + }) + .await + { + Ok(response) => response, + Err(error) => { + if is_terminal_capability_error(&error.code) { + let cache = self.message_cache().await?; + self.invalidate_conversation_access(&feedback_id, &cache) + .await; + } + return Err(error); + } + }; + let acknowledged: AcknowledgeResponseBody = + match decode_success(response, StatusCode::OK).await { + Ok(value) => value, + Err(error) => { + if is_terminal_capability_error(&error.code) { + let cache = self.message_cache().await?; + self.invalidate_conversation_access(&feedback_id, &cache) + .await; + } + return Err(error); + } + }; + if acknowledged.feedback_id != feedback_id { + return Err(FeedbackError::new( + "RESPONSE_INVALID", + "Feedback service returned a mismatched conversation", + true, + )); + } + validate_timestamp(&acknowledged.read_cursor).map_err(|_| { + FeedbackError::new( + "RESPONSE_INVALID", + "Feedback service returned an invalid read cursor", + true, + ) + })?; + + let mut state = self.state.lock().await; + self.ensure_existing_loaded(&mut state).await?; + let mut next = state.stored.clone(); + next.read_cursors + .insert(feedback_id.clone(), acknowledged.read_cursor.clone()); + if let Some(record) = next + .inbox_items + .iter_mut() + .find(|record| record.feedback_id == feedback_id) + { + record.status = acknowledged.feedback_status; + record.has_new_reply = false; + } + state.stored = next; + self.persist_cached_state_best_effort(&state.stored).await; + + Ok(AcknowledgeFeedbackResponse { + read_through: acknowledged.read_cursor, + feedback_status: acknowledged.feedback_status, + }) + } + + pub async fn reply_feedback( + &self, + request: ReplyFeedbackRequest, + ) -> Result { + validate_feedback_id(&request.feedback_id)?; + validate_content(&request.content)?; + let feedback_id = request.feedback_id.clone(); + self.ensure_reply_allowed(&feedback_id).await?; + let content = request.content.trim().to_string(); + let idempotency_key = self.reply_idempotency_key(&feedback_id, &content).await?; + let capability = self.capability_for(&feedback_id).await?; + let body = ReplyRequestBody { content: &content }; + let response = match self + .send_authenticated_existing("feedback:write", |token| { + self.client + .post(self.url(&format!("/support/v1/feedback/{feedback_id}/messages"))?) + .bearer_auth(token) + .header("X-Feedback-Capability", &capability) + .header("X-Request-ID", Uuid::new_v4().to_string()) + .header("Idempotency-Key", &idempotency_key) + .json(&body) + .build() + .map_err(network_error) + }) + .await + { + Ok(response) => response, + Err(error) => { + if is_terminal_capability_error(&error.code) { + let cache = self.message_cache().await?; + self.invalidate_conversation_access(&feedback_id, &cache) + .await; + } + return Err(error); + } + }; + let replied: ReplyResponseBody = match decode_success(response, StatusCode::CREATED).await { + Ok(value) => value, + Err(error) => { + if is_terminal_capability_error(&error.code) { + let cache = self.message_cache().await?; + self.invalidate_conversation_access(&feedback_id, &cache) + .await; + } + return Err(error); + } + }; + // The message fields prove that the write committed. Treat the returned + // status as server-authoritative so a lagging status transition cannot + // turn a successful idempotent write into a retryable client failure. + if replied.sender_type != FeedbackSender::User + || Uuid::parse_str(&replied.message_id).is_err() + || validate_timestamp(&replied.created_at).is_err() + { + return Err(FeedbackError::new( + "RESPONSE_INVALID", + "Feedback service returned an invalid reply", + true, + )); + } + + let message = FeedbackMessage { + message_id: replied.message_id, + sender: replied.sender_type, + content, + content_deleted: false, + created_at: replied.created_at, + }; + let mut cached_after_reply = None; + if let Ok(cache) = self.message_cache().await { + let cached = match cache.load(&feedback_id).await { + Ok(cached) => cached, + Err(_) => { + let _ = cache.remove(&feedback_id).await; + None + } + }; + if let Some(mut cached) = cached { + if !cached + .messages + .iter() + .any(|existing| existing.message_id == message.message_id) + { + cached.messages.push(message.clone()); + cached.messages.sort_by(|left, right| { + left.created_at + .cmp(&right.created_at) + .then_with(|| left.message_id.cmp(&right.message_id)) + }); + if cache.store(&cached).await.is_err() { + log::warn!( + "Failed to save feedback message cache after a successful reply" + ); + } + } + cached_after_reply = Some(cached); + } + } + + let mut state = self.state.lock().await; + self.ensure_existing_loaded(&mut state).await?; + let mut next = state.stored.clone(); + next.pending_reply_fingerprints.remove(&feedback_id); + next.pending_reply_idempotency_keys.remove(&feedback_id); + if let Some(record) = next + .inbox_items + .iter_mut() + .find(|record| record.feedback_id == feedback_id) + { + record.status = replied.feedback_status; + record.updated_at = message.created_at.clone(); + record.has_new_reply = false; + } + state.stored = next; + self.persist_cached_state_best_effort(&state.stored).await; + drop(state); + + let feedback_status = match cached_after_reply.as_ref() { + Some(cached) => self + .reconcile_user_message_unread(&feedback_id, cached, false) + .await + .unwrap_or(replied.feedback_status), + None => replied.feedback_status, + }; + + Ok(ReplyFeedbackResponse { + message, + feedback_status, + }) + } + + async fn reconcile_user_message_unread( + &self, + feedback_id: &str, + data: &MessageCacheData, + require_reported_unread: bool, + ) -> Option { + let mut state = self.state.lock().await; + if self.ensure_existing_loaded(&mut state).await.is_err() { + return None; + } + if require_reported_unread + && !state + .stored + .inbox_items + .iter() + .any(|record| record.feedback_id == feedback_id && record.has_new_reply) + { + return None; + } + let cursor = + user_message_reconciliation_cursor(data, state.stored.read_cursors.get(feedback_id))?; + drop(state); + + match tokio::time::timeout( + Duration::from_secs(3), + self.acknowledge_feedback(AcknowledgeFeedbackRequest { + feedback_id: feedback_id.to_string(), + last_visible_at: cursor, + }), + ) + .await + { + Ok(Ok(acknowledged)) => Some(acknowledged.feedback_status), + Ok(Err(error)) => { + log::warn!( + "Failed to reconcile feedback unread state: code={}, request_id={}", + error.code, + error.request_id.as_deref().unwrap_or("unavailable") + ); + None + } + Err(_) => { + log::warn!("Timed out while reconciling feedback unread state"); + None + } + } + } + + async fn synchronize_messages( + &self, + feedback_id: &str, + cache: &MessageCache, + data: &mut MessageCacheData, + ) -> Result<(), FeedbackError> { + let capability = self.capability_for(feedback_id).await?; + let mut reset_after_invalid_cursor = data.sync_cursor.is_some(); + loop { + let cursor = data.sync_cursor.clone(); + let response = self + .send_authenticated_existing("feedback:read", |token| { + let mut request = self + .client + .get(self.url(&format!("/support/v1/feedback/{feedback_id}/messages"))?) + .bearer_auth(token) + .header("X-Feedback-Capability", &capability) + .header("X-Request-ID", Uuid::new_v4().to_string()) + .query(&[("limit", "50")]); + if let Some(cursor) = cursor.as_deref() { + request = request.query(&[("cursor", cursor)]); + } + request.build().map_err(network_error) + }) + .await?; + let received: MessagesResponse = match decode_success(response, StatusCode::OK).await { + Ok(value) => value, + Err(error) if error.code == "CURSOR_INVALID" && reset_after_invalid_cursor => { + *data = MessageCacheData::empty(feedback_id); + cache.store(data).await.map_err(|_| { + credential_error( + "CACHE_SAVE_FAILED", + "Feedback message cache could not be saved", + ) + })?; + reset_after_invalid_cursor = false; + continue; + } + Err(error) => return Err(error), + }; + if received.feedback_id != feedback_id { + return Err(FeedbackError::new( + "RESPONSE_INVALID", + "Feedback service returned a mismatched conversation", + true, + )); + } + let previous_cursor = data.sync_cursor.clone(); + merge_messages(data, received.messages)?; + data.sync_cursor = (!received.cursor.is_empty()).then_some(received.cursor); + if received.has_more && data.sync_cursor == previous_cursor { + return Err(FeedbackError::new( + "RESPONSE_INVALID", + "Feedback service did not advance the message cursor", + true, + )); + } + if !received.has_more { + data.sync_complete = true; + } + cache.store(data).await.map_err(|_| { + credential_error( + "CACHE_SAVE_FAILED", + "Feedback message cache could not be saved", + ) + })?; + if !received.has_more { + return Ok(()); + } + } + } + + async fn message_cache(&self) -> Result { + let directory = self.cache_dir.clone().ok_or_else(|| { + FeedbackError::new( + "CACHE_UNAVAILABLE", + "Feedback message cache is unavailable", + false, + ) + })?; + let mut state = self.state.lock().await; + self.ensure_existing_loaded(&mut state).await?; + let key = cache_encryption_key(&state.stored.enroll_key).ok_or_else(|| { + FeedbackError::new( + "CACHE_UNAVAILABLE", + "Feedback message cache key is unavailable", + false, + ) + })?; + Ok(MessageCache::new(directory, key)) + } + + async fn capability_for(&self, feedback_id: &str) -> Result { + let mut state = self.state.lock().await; + self.ensure_existing_loaded(&mut state).await?; + state + .stored + .capabilities + .get(feedback_id) + .cloned() + .ok_or_else(|| { + FeedbackError::new( + "CAPABILITY_UNAVAILABLE", + "Feedback conversation access is unavailable", + false, + ) + }) + } + + async fn ensure_reply_allowed(&self, feedback_id: &str) -> Result<(), FeedbackError> { + let mut state = self.state.lock().await; + self.ensure_existing_loaded(&mut state).await?; + if state.stored.inbox_items.iter().any(|record| { + record.feedback_id == feedback_id && record.status == FeedbackStatus::Resolved + }) { + return Err(FeedbackError::new( + "FEEDBACK_ALREADY_RESOLVED", + "Resolved feedback cannot receive replies", + false, + )); + } + Ok(()) + } + + async fn invalidate_conversation_access(&self, feedback_id: &str, cache: &MessageCache) { + let _ = cache.remove(feedback_id).await; + let mut state = self.state.lock().await; + if self.ensure_existing_loaded(&mut state).await.is_err() { + return; + } + let mut next = state.stored.clone(); + next.capabilities.remove(feedback_id); + next.read_cursors.remove(feedback_id); + next.pending_reply_fingerprints.remove(feedback_id); + next.pending_reply_idempotency_keys.remove(feedback_id); + if let Some(record) = next + .inbox_items + .iter_mut() + .find(|record| record.feedback_id == feedback_id) + { + record.can_open = false; + } + if self.persist_credentials(&next).await.is_ok() { + state.stored = next; + self.persist_cached_state_best_effort(&state.stored).await; + } + } + + fn url(&self, path: &str) -> Result { + self.base_url + .as_ref() + .map(|base| format!("{base}{path}")) + .ok_or_else(|| { + FeedbackError::new( + "FEEDBACK_NOT_CONFIGURED", + "Feedback API base URL is not configured", + false, + ) + }) + } + + async fn create_idempotency_key( + &self, + request: &SubmitFeedbackRequest, + ) -> Result { + let fingerprint = request_fingerprint(request)?; + let mut state = self.state.lock().await; + self.ensure_loaded(&mut state).await?; + if state.stored.pending_create_fingerprint.as_deref() == Some(&fingerprint) { + if let Some(key) = state.stored.pending_create_idempotency_key.as_ref() { + return Ok(key.clone()); + } + } + + let key = Uuid::new_v4().to_string(); + let mut next = state.stored.clone(); + next.pending_create_fingerprint = Some(fingerprint); + next.pending_create_idempotency_key = Some(key.clone()); + self.persist_cached_state(&next).await?; + state.stored = next; + Ok(key) + } + + async fn reply_idempotency_key( + &self, + feedback_id: &str, + content: &str, + ) -> Result { + let fingerprint = format!("{:x}", Sha256::digest(content.as_bytes())); + let mut state = self.state.lock().await; + self.ensure_existing_loaded(&mut state).await?; + if state + .stored + .pending_reply_fingerprints + .get(feedback_id) + .map(String::as_str) + == Some(fingerprint.as_str()) + { + if let Some(key) = state.stored.pending_reply_idempotency_keys.get(feedback_id) { + return Ok(key.clone()); + } + } + + let key = Uuid::new_v4().to_string(); + let mut next = state.stored.clone(); + next.pending_reply_fingerprints + .insert(feedback_id.to_string(), fingerprint); + next.pending_reply_idempotency_keys + .insert(feedback_id.to_string(), key.clone()); + self.persist_cached_state(&next).await?; + state.stored = next; + Ok(key) + } + + async fn send_authenticated( + &self, + scope: &str, + build_request: F, + ) -> Result + where + F: Fn(&str) -> Result, + { + let token = self.access_token(scope, false).await?; + let response = self + .client + .execute(build_request(&token)?) + .await + .map_err(network_error)?; + if response.status() != StatusCode::UNAUTHORIZED { + return Ok(response); + } + + let token = self.access_token(scope, true).await?; + self.client + .execute(build_request(&token)?) + .await + .map_err(network_error) + } + + async fn send_authenticated_existing( + &self, + scope: &str, + build_request: F, + ) -> Result + where + F: Fn(&str) -> Result, + { + let token = self.existing_access_token(scope, false).await?; + let response = self + .client + .execute(build_request(&token)?) + .await + .map_err(network_error)?; + if response.status() != StatusCode::UNAUTHORIZED { + return Ok(response); + } + + let token = self.existing_access_token(scope, true).await?; + self.client + .execute(build_request(&token)?) + .await + .map_err(network_error) + } + + async fn existing_access_token( + &self, + scope: &str, + force_refresh: bool, + ) -> Result { + let mut state = self.state.lock().await; + self.ensure_existing_loaded(&mut state).await?; + if !force_refresh { + if let Some(token) = state.access_token.as_ref() { + if token.expires_at + > Utc::now() + ChronoDuration::seconds(ACCESS_TOKEN_REFRESH_MARGIN_SECONDS) + && token.scopes.iter().any(|item| item == scope) + { + return Ok(token.value.clone()); + } + } + } + if state.stored.refresh_token.is_none() { + return Err(FeedbackError::new( + "FEEDBACK_ACCESS_UNAVAILABLE", + "Saved feedback access is unavailable", + false, + )); + } + let token = match self.refresh(&mut state).await { + Ok(token) => token, + Err(error) if refresh_requires_enroll(&error.code) => { + let mut next = state.stored.clone(); + next.anonymous_id = None; + next.refresh_token = None; + next.refresh_idempotency_key = None; + self.persist_credentials(&next).await?; + state.stored = next; + self.persist_cached_state_best_effort(&state.stored).await; + state.access_token = None; + return Err(FeedbackError::new( + "FEEDBACK_ACCESS_EXPIRED", + "Saved feedback access has expired", + false, + )); + } + Err(error) => return Err(error), + }; + if !token.scopes.iter().any(|item| item == scope) { + return Err(FeedbackError::new( + "SCOPE_INSUFFICIENT", + "The feedback token does not include the required scope", + false, + )); + } + let value = token.value.clone(); + state.access_token = Some(token); + Ok(value) + } + + async fn access_token( + &self, + scope: &str, + force_refresh: bool, + ) -> Result { + let mut state = self.state.lock().await; + self.ensure_loaded(&mut state).await?; + if !force_refresh { + if let Some(token) = state.access_token.as_ref() { + if token.expires_at + > Utc::now() + ChronoDuration::seconds(ACCESS_TOKEN_REFRESH_MARGIN_SECONDS) + && token.scopes.iter().any(|item| item == scope) + { + return Ok(token.value.clone()); + } + } + } + + let token = if state.stored.refresh_token.is_some() { + match self.refresh(&mut state).await { + Ok(token) => token, + Err(error) if refresh_requires_enroll(&error.code) => { + let mut next = state.stored.clone(); + next.anonymous_id = None; + next.refresh_token = None; + next.refresh_idempotency_key = None; + next.capabilities.clear(); + self.persist_credentials(&next).await?; + state.stored = next; + self.persist_cached_state_best_effort(&state.stored).await; + state.access_token = None; + self.enroll(&mut state).await? + } + Err(error) => return Err(error), + } + } else { + self.enroll(&mut state).await? + }; + if !token.scopes.iter().any(|item| item == scope) { + return Err(FeedbackError::new( + "SCOPE_INSUFFICIENT", + "The feedback token does not include the required scope", + false, + )); + } + let value = token.value.clone(); + state.access_token = Some(token); + Ok(value) + } + + async fn enroll(&self, state: &mut RuntimeState) -> Result { + let idempotency_key = match state.stored.enroll_idempotency_key.as_ref() { + Some(key) => key.clone(), + None => { + let mut next = state.stored.clone(); + let key = Uuid::new_v4().to_string(); + next.enroll_idempotency_key = Some(key.clone()); + self.persist_cached_state(&next).await?; + state.stored = next; + key + } + }; + let response = self + .client + .post(self.url("/auth/v1/anonymous/enroll")?) + .header("X-Request-ID", Uuid::new_v4().to_string()) + .header("Idempotency-Key", idempotency_key) + .json(&serde_json::json!({ "key": state.stored.enroll_key })) + .send() + .await + .map_err(network_error)?; + let token: TokenResponse = decode_success(response, StatusCode::CREATED).await?; + self.commit_token(state, token, true).await + } + + async fn refresh(&self, state: &mut RuntimeState) -> Result { + let refresh_token = state.stored.refresh_token.clone().ok_or_else(|| { + FeedbackError::new( + "REFRESH_TOKEN_MISSING", + "Feedback refresh token is unavailable", + false, + ) + })?; + let idempotency_key = match state.stored.refresh_idempotency_key.as_ref() { + Some(key) => key.clone(), + None => { + let mut next = state.stored.clone(); + let key = Uuid::new_v4().to_string(); + next.refresh_idempotency_key = Some(key.clone()); + self.persist_cached_state(&next).await?; + state.stored = next; + key + } + }; + let response = self + .client + .post(self.url("/auth/v1/anonymous/token")?) + .header("X-Request-ID", Uuid::new_v4().to_string()) + .header("Idempotency-Key", idempotency_key) + .json(&serde_json::json!({ "refresh_token": refresh_token })) + .send() + .await + .map_err(network_error)?; + let token: TokenResponse = decode_success(response, StatusCode::OK).await?; + self.commit_token(state, token, false).await + } + + async fn commit_token( + &self, + state: &mut RuntimeState, + response: TokenResponse, + enrolled: bool, + ) -> Result { + let token = AccessToken { + value: response.access_token, + expires_at: Utc::now() + ChronoDuration::seconds(response.expires_in.max(0)), + scopes: parse_scopes(&response.scope), + }; + let anonymous_id = response.anonymous_id; + let mut next = state.stored.clone(); + next.anonymous_id = Some(anonymous_id.clone()); + next.refresh_token = Some(response.refresh_token); + next.refresh_idempotency_key = None; + if enrolled { + next.enroll_idempotency_key = None; + } + self.persist_credentials(&next).await?; + if let Some(identity_store) = &self.identity_store { + if identity_store.store(&anonymous_id).await.is_err() { + log::warn!("Failed to save the local feedback identity copy"); + } + } + state.stored = next; + self.persist_cached_state_best_effort(&state.stored).await; + Ok(token) + } + + async fn ensure_loaded(&self, state: &mut RuntimeState) -> Result<(), FeedbackError> { + self.ensure_existing_loaded(state).await?; + if state.stored.enroll_key.is_empty() { + state.stored.enroll_key = Uuid::new_v4().to_string(); + self.persist_credentials(&state.stored).await?; + } + Ok(()) + } + + async fn ensure_existing_loaded(&self, state: &mut RuntimeState) -> Result<(), FeedbackError> { + if state.loaded { + return Ok(()); + } + let stored = self.credential_store.load().await.map_err(|_| { + credential_error( + "CREDENTIAL_LOAD_FAILED", + "Feedback access could not be loaded", + ) + })?; + state.stored = match stored { + Some(value) => serde_json::from_str(&value).map_err(|_| { + credential_error( + "CREDENTIALS_INVALID", + "Saved feedback access data is invalid", + ) + })?, + None => StoredCredentials::default(), + }; + let had_legacy_cached_state = state.stored.has_legacy_cached_state(); + if let Some(cache) = self.state_cache(&state.stored) { + match cache.load().await { + Ok(Some(cached)) => state.stored.apply_cached_state(cached), + Ok(None) => { + if had_legacy_cached_state { + self.store_cached_state_best_effort(&cache, &state.stored) + .await; + } + } + Err(_) => { + let _ = cache.remove().await; + log::warn!("Feedback state cache was invalid and has been reset"); + if had_legacy_cached_state { + self.store_cached_state_best_effort(&cache, &state.stored) + .await; + } + } + } + } + if had_legacy_cached_state { + if let Err(error) = self.persist_credentials(&state.stored).await { + log::warn!( + "Failed to migrate feedback credentials away from legacy cache fields: code={}", + error.code + ); + } + } + if let (Some(identity_store), Some(anonymous_id)) = + (&self.identity_store, state.stored.anonymous_id.as_deref()) + { + if identity_store.store(anonymous_id).await.is_err() { + log::warn!("Failed to save the local feedback identity copy"); + } + } + state.loaded = true; + Ok(()) + } + + async fn persist_credentials(&self, stored: &StoredCredentials) -> Result<(), FeedbackError> { + let value = serde_json::to_string(stored).map_err(|_| { + credential_error( + "CREDENTIAL_SAVE_FAILED", + "Feedback access could not be encoded", + ) + })?; + self.credential_store.store(&value).await.map_err(|_| { + credential_error( + "CREDENTIAL_SAVE_FAILED", + "Feedback access could not be saved securely", + ) + }) + } + + async fn persist_cached_state(&self, stored: &StoredCredentials) -> Result<(), FeedbackError> { + let cache = self.state_cache(stored).ok_or_else(|| { + credential_error("CACHE_UNAVAILABLE", "Feedback state cache is unavailable") + })?; + cache.store(&stored.cached_state()).await.map_err(|_| { + credential_error( + "CACHE_SAVE_FAILED", + "Feedback state cache could not be saved", + ) + }) + } + + async fn persist_cached_state_best_effort(&self, stored: &StoredCredentials) { + let Some(cache) = self.state_cache(stored) else { + return; + }; + self.store_cached_state_best_effort(&cache, stored).await; + } + + async fn store_cached_state_best_effort( + &self, + cache: &FeedbackStateCache, + stored: &StoredCredentials, + ) { + if cache.store(&stored.cached_state()).await.is_err() { + log::warn!("Failed to save feedback state cache; server data remains authoritative"); + } + } + + fn state_cache(&self, stored: &StoredCredentials) -> Option { + let directory = self.cache_dir.clone()?; + let key = cache_encryption_key(&stored.enroll_key)?; + Some(FeedbackStateCache::new(directory, key)) + } +} + +fn cached_inbox(stored: &StoredCredentials, can_reuse_access: bool) -> FeedbackInboxPage { + FeedbackInboxPage { + items: stored + .inbox_items + .iter() + .cloned() + .map(|mut item| { + item.can_open = + can_reuse_access && stored.capabilities.contains_key(&item.feedback_id); + item + }) + .collect(), + next_cursor: stored.inbox_next_cursor.clone(), + has_more: stored.inbox_has_more, + } +} + +fn merge_messages( + data: &mut MessageCacheData, + received: Vec, +) -> Result<(), FeedbackError> { + let mut known = data + .messages + .iter() + .map(|message| message.message_id.clone()) + .collect::>(); + for message in received { + validate_content(&message.content).map_err(|_| { + FeedbackError::new( + "RESPONSE_INVALID", + "Feedback service returned invalid message content", + true, + ) + })?; + validate_timestamp(&message.created_at).map_err(|_| { + FeedbackError::new( + "RESPONSE_INVALID", + "Feedback service returned an invalid message timestamp", + true, + ) + })?; + if known.insert(message.message_id.clone()) { + data.messages.push(FeedbackMessage { + message_id: message.message_id, + sender: message.sender_type, + content: message.content, + content_deleted: message.content_deleted, + created_at: message.created_at, + }); + } + } + data.messages.sort_by(|left, right| { + left.created_at + .cmp(&right.created_at) + .then_with(|| left.message_id.cmp(&right.message_id)) + }); + Ok(()) +} + +fn page_from_cache( + data: &MessageCacheData, + cursor: Option<&str>, + page_size: u16, + sync_error: Option, +) -> Result { + let end = match cursor { + Some(cursor) => cursor + .strip_prefix("cache:") + .and_then(|value| value.parse::().ok()) + .filter(|value| *value <= data.messages.len()) + .ok_or_else(|| { + FeedbackError::validation( + "CACHE_CURSOR_INVALID", + "Feedback message cache cursor is invalid", + ) + })?, + None => data.messages.len(), + }; + let start = end.saturating_sub(usize::from(page_size)); + Ok(FeedbackConversationPage { + messages: data.messages[start..end].to_vec(), + next_cursor: (start > 0).then(|| format!("cache:{start}")), + has_more: start > 0, + sync_error, + }) +} + +fn cached_unread_admin_reply( + data: &MessageCacheData, + read_cursor: Option<&String>, +) -> Option { + if !data.sync_complete { + return None; + } + let latest_admin = data + .messages + .iter() + .filter(|message| message.sender == FeedbackSender::Admin) + .filter_map(|message| { + DateTime::parse_from_rfc3339(&message.created_at) + .ok() + .map(|timestamp| (timestamp, message.created_at.as_str())) + }) + .max_by_key(|(timestamp, _)| *timestamp); + let Some((latest_admin, _)) = latest_admin else { + return Some(false); + }; + let Some(read_cursor) = read_cursor else { + return Some(true); + }; + let Ok(read_cursor) = DateTime::parse_from_rfc3339(read_cursor) else { + return None; + }; + Some(latest_admin > read_cursor) +} + +fn user_message_reconciliation_cursor( + data: &MessageCacheData, + read_cursor: Option<&String>, +) -> Option { + if cached_unread_admin_reply(data, read_cursor)? { + return None; + } + data.messages + .iter() + .filter_map(|message| { + DateTime::parse_from_rfc3339(&message.created_at) + .ok() + .map(|timestamp| (timestamp, message)) + }) + .max_by_key(|(timestamp, _)| *timestamp) + .filter(|(_, message)| message.sender == FeedbackSender::User) + .map(|(_, message)| message.created_at.clone()) +} + +fn cache_encryption_key(enroll_key: &str) -> Option<[u8; 32]> { + if enroll_key.is_empty() { + return None; + } + let mut digest = Sha256::new(); + digest.update(b"bitfun-feedback-cache-v2\0"); + digest.update(enroll_key.as_bytes()); + Some(digest.finalize().into()) +} + +fn validate_feedback_id(feedback_id: &str) -> Result<(), FeedbackError> { + Uuid::parse_str(feedback_id) + .map(|_| ()) + .map_err(|_| FeedbackError::validation("FEEDBACK_ID_INVALID", "Feedback ID is invalid")) +} + +fn validate_timestamp(timestamp: &str) -> Result<(), FeedbackError> { + DateTime::parse_from_rfc3339(timestamp) + .map(|_| ()) + .map_err(|_| FeedbackError::validation("TIMESTAMP_INVALID", "Timestamp is invalid")) +} + +fn is_terminal_capability_error(code: &str) -> bool { + matches!( + code, + "CAPABILITY_INVALID" | "CAPABILITY_EXPIRED" | "CAPABILITY_REVOKED" + ) +} + +fn configured_base_url() -> Option { + Some(feedback_api_base_url(cfg!(debug_assertions)).to_string()) +} + +fn feedback_api_base_url(debug: bool) -> &'static str { + if debug { + DEBUG_FEEDBACK_API_BASE_URL + } else { + RELEASE_FEEDBACK_API_BASE_URL + } +} + +fn request_fingerprint(request: &SubmitFeedbackRequest) -> Result { + let encoded = serde_json::to_vec(request).map_err(|_| { + FeedbackError::new( + "REQUEST_ENCODING_FAILED", + "Feedback request could not be encoded", + false, + ) + })?; + Ok(format!("{:x}", Sha256::digest(encoded))) +} + +fn normalize_request(mut request: SubmitFeedbackRequest) -> SubmitFeedbackRequest { + request.trace_id = valid_optional_value_owned(request.trace_id, 64); + request.session_id_hash = valid_optional_value_owned(request.session_id_hash, 64); + request +} + +fn valid_optional_value(value: &str, max_chars: usize) -> Option<&str> { + (!value.is_empty() && value.chars().count() <= max_chars).then_some(value) +} + +fn valid_optional_value_owned(value: Option, max_chars: usize) -> Option { + value.filter(|item| !item.is_empty() && item.chars().count() <= max_chars) +} + +fn parse_scopes(value: &str) -> Vec { + value + .split(|character: char| character == ',' || character.is_whitespace()) + .map(str::trim) + .filter(|scope| !scope.is_empty()) + .map(ToString::to_string) + .collect() +} + +fn refresh_requires_enroll(code: &str) -> bool { + matches!( + code, + "REFRESH_TOKEN_INVALID" | "REFRESH_TOKEN_REUSED" | "TOKEN_FAMILY_REVOKED" + ) +} + +async fn decode_success( + response: Response, + expected: StatusCode, +) -> Result { + if response.status() != expected { + return Err(decode_error(response).await); + } + response.json::().await.map_err(|_| { + FeedbackError::new( + "RESPONSE_INVALID", + "Feedback service returned an invalid response", + true, + ) + }) +} + +async fn decode_error(response: Response) -> FeedbackError { + let status = response.status(); + let request_id_header = response + .headers() + .get("X-Request-ID") + .and_then(|value| value.to_str().ok()) + .map(ToString::to_string); + let retry_after_seconds = response + .headers() + .get("Retry-After") + .and_then(|value| value.to_str().ok()) + .and_then(|value| value.parse::().ok()); + let body = response.json::().await.unwrap_or_default(); + let code = body.error_code.unwrap_or_else(|| match status { + StatusCode::UNAUTHORIZED => "ACCESS_TOKEN_INVALID".to_string(), + StatusCode::FORBIDDEN => "ACCESS_FORBIDDEN".to_string(), + StatusCode::TOO_MANY_REQUESTS => "RATE_LIMITED".to_string(), + status if status.is_server_error() => "SERVICE_UNAVAILABLE".to_string(), + _ => "REQUEST_REJECTED".to_string(), + }); + let retryable = status == StatusCode::TOO_MANY_REQUESTS || status.is_server_error(); + FeedbackError { + message: safe_error_message(&code).to_string(), + code, + retryable, + request_id: body.request_id.or(request_id_header), + retry_after_seconds, + } +} + +fn safe_error_message(code: &str) -> &'static str { + match code { + "CONTENT_EMPTY" | "CONTENT_TOO_LONG" | "CONTENT_UNSAFE" | "CATEGORY_INVALID" => { + "Feedback content was rejected" + } + "SCOPE_INSUFFICIENT" | "INSTANCE_BANNED" | "ACCESS_FORBIDDEN" => { + "Feedback access is not permitted" + } + "RATE_LIMITED" | "FEEDBACK_QUOTA_EXCEEDED" | "QUOTA_EXCEEDED" => { + "Feedback requests are temporarily limited" + } + _ => "Feedback request could not be completed", + } +} + +fn network_error(error: reqwest::Error) -> FeedbackError { + if error.is_timeout() { + FeedbackError::new("REQUEST_TIMEOUT", "Feedback request timed out", true) + } else { + FeedbackError::new("NETWORK_ERROR", "Feedback service is unavailable", true) + } +} + +fn credential_error(code: &str, message: &str) -> FeedbackError { + FeedbackError::new(code, message, true) +} + +#[cfg(test)] +mod tests { + use super::super::message_cache::{MessageCache, MessageCacheData}; + use super::{ + cache_encryption_key, cached_unread_admin_reply, configured_base_url, + feedback_api_base_url, is_terminal_capability_error, normalize_request, parse_scopes, + user_message_reconciliation_cursor, FeedbackService, StoredCredentials, + DEBUG_FEEDBACK_API_BASE_URL, RELEASE_FEEDBACK_API_BASE_URL, + }; + use crate::feedback::FeedbackCredentialStore; + use anyhow::{anyhow, Result}; + use async_trait::async_trait; + use bitfun_product_domains::feedback::{ + AcknowledgeFeedbackRequest, FeedbackCategory, FeedbackMessage, FeedbackRecordSummary, + FeedbackSender, FeedbackStatus, ListFeedbackRecordsRequest, + OpenFeedbackConversationRequest, ReplyFeedbackRequest, SubmitFeedbackRequest, + }; + use std::collections::HashMap; + use std::sync::atomic::{AtomicUsize, Ordering}; + use std::sync::{Arc, Mutex as StdMutex}; + use std::time::Duration; + + #[derive(Default)] + struct MemoryStore { + value: StdMutex>, + stores: AtomicUsize, + fail_at: AtomicUsize, + } + + #[async_trait] + impl FeedbackCredentialStore for MemoryStore { + async fn load(&self) -> Result> { + Ok(self.value.lock().unwrap().clone()) + } + + async fn store(&self, value: &str) -> Result<()> { + let call = self.stores.fetch_add(1, Ordering::SeqCst) + 1; + if self.fail_at.load(Ordering::SeqCst) == call { + return Err(anyhow!("injected store failure")); + } + *self.value.lock().unwrap() = Some(value.to_string()); + Ok(()) + } + } + + #[test] + fn accepts_backend_scope_delimiters() { + assert_eq!( + parse_scopes("config:read,feedback:write feedback:read"), + vec!["config:read", "feedback:write", "feedback:read"] + ); + } + + #[test] + fn selects_a_fixed_feedback_endpoint_for_each_build_profile() { + assert_eq!( + DEBUG_FEEDBACK_API_BASE_URL, + "http://api-test.infra-bitfun.com" + ); + assert_eq!( + RELEASE_FEEDBACK_API_BASE_URL, + "https://api.infra-bitfun.com" + ); + assert_eq!(feedback_api_base_url(true), DEBUG_FEEDBACK_API_BASE_URL); + assert_eq!(feedback_api_base_url(false), RELEASE_FEEDBACK_API_BASE_URL); + assert_eq!( + configured_base_url().as_deref(), + Some(feedback_api_base_url(cfg!(debug_assertions))) + ); + } + + #[test] + fn only_explicit_terminal_capability_errors_invalidate_conversation_access() { + for code in [ + "CAPABILITY_INVALID", + "CAPABILITY_EXPIRED", + "CAPABILITY_REVOKED", + ] { + assert!(is_terminal_capability_error(code), "{code}"); + } + for code in [ + "CAPABILITY_UNAVAILABLE", + "CAPABILITY_REQUIRED", + "FEEDBACK_ACCESS_DENIED", + "FEEDBACK_NOT_FOUND", + "FEEDBACK_ACCESS_UNAVAILABLE", + "FEEDBACK_ACCESS_EXPIRED", + "SCOPE_INSUFFICIENT", + "INSTANCE_BANNED", + "ACCESS_TOKEN_INVALID", + ] { + assert!(!is_terminal_capability_error(code), "{code}"); + } + } + + #[test] + fn omits_invalid_optional_correlation_values() { + let normalized = normalize_request(SubmitFeedbackRequest { + category: FeedbackCategory::Other, + content: "feedback".to_string(), + trace_id: Some(String::new()), + session_id_hash: Some("x".repeat(65)), + }); + assert_eq!(normalized.trace_id, None); + assert_eq!(normalized.session_id_hash, None); + } + + #[test] + fn distinguishes_user_messages_from_unread_admin_replies_in_complete_cache() { + let mut cached = MessageCacheData::empty("feedback-1"); + cached.sync_complete = true; + cached.messages.push(FeedbackMessage { + message_id: "message-user".to_string(), + sender: FeedbackSender::User, + content: "own reply".to_string(), + content_deleted: false, + created_at: "2026-07-28T02:00:00Z".to_string(), + }); + assert_eq!(cached_unread_admin_reply(&cached, None), Some(false)); + + cached.messages.push(FeedbackMessage { + message_id: "message-admin".to_string(), + sender: FeedbackSender::Admin, + content: "support reply".to_string(), + content_deleted: false, + created_at: "2026-07-28T03:00:00Z".to_string(), + }); + assert_eq!(cached_unread_admin_reply(&cached, None), Some(true)); + assert_eq!(user_message_reconciliation_cursor(&cached, None), None); + assert_eq!( + cached_unread_admin_reply(&cached, Some(&"2026-07-28T03:00:00Z".to_string()),), + Some(false) + ); + + cached.messages.push(FeedbackMessage { + message_id: "message-user-latest".to_string(), + sender: FeedbackSender::User, + content: "follow-up".to_string(), + content_deleted: false, + created_at: "2026-07-28T04:00:00Z".to_string(), + }); + assert_eq!( + user_message_reconciliation_cursor(&cached, Some(&"2026-07-28T03:00:00Z".to_string()),) + .as_deref(), + Some("2026-07-28T04:00:00Z") + ); + + cached.sync_complete = false; + assert_eq!(cached_unread_admin_reply(&cached, None), None); + } + + #[test] + fn secure_credentials_exclude_rebuildable_feedback_state() { + let stored = StoredCredentials { + enroll_key: "enroll".to_string(), + refresh_token: Some("refresh".to_string()), + capabilities: HashMap::from([("feedback-1".to_string(), "capability".to_string())]), + anonymous_id: Some("anonymous".to_string()), + inbox_items: vec![FeedbackRecordSummary { + feedback_id: "feedback-1".to_string(), + category: FeedbackCategory::Other, + status: FeedbackStatus::Submitted, + has_new_reply: false, + created_at: "2026-07-30T01:00:00Z".to_string(), + updated_at: "2026-07-30T01:00:00Z".to_string(), + can_open: true, + }], + pending_create_idempotency_key: Some("idempotency".to_string()), + ..StoredCredentials::default() + }; + + let serialized = serde_json::to_value(&stored).unwrap(); + + assert_eq!(serialized["enroll_key"], "enroll"); + assert_eq!(serialized["refresh_token"], "refresh"); + assert_eq!(serialized["capabilities"]["feedback-1"], "capability"); + assert!(serialized.get("anonymous_id").is_none()); + assert!(serialized.get("inbox_items").is_none()); + assert!(serialized.get("pending_create_idempotency_key").is_none()); + } + + #[tokio::test] + async fn access_state_does_not_create_an_identity() { + let store = Arc::new(MemoryStore::default()); + let identity_dir = test_cache_dir("identity-not-created"); + let identity_path = identity_dir.join("identity.json"); + let service = FeedbackService::new( + None, + "1.0.0".to_string(), + store.clone(), + Duration::from_secs(2), + ) + .with_identity_path(identity_path.clone()); + + let state = service.access_state().await.unwrap(); + + assert!(!state.has_history); + assert!(!state.can_reuse_access); + assert_eq!(store.stores.load(Ordering::SeqCst), 0); + assert!(!identity_path.exists()); + let _ = tokio::fs::remove_dir_all(identity_dir).await; + } + + #[tokio::test] + async fn successful_enrollment_saves_the_plain_anonymous_identity_copy() { + let responses = vec![json_response( + 201, + r#"{"anonymous_id":"11111111-1111-4111-8111-111111111111","access_token":"access","refresh_token":"refresh","expires_in":3600,"refresh_expires_in":2592000,"scope":"feedback:write,feedback:read","schema_version":"1"}"#, + )]; + let (base_url, _) = spawn_server(responses).await; + let cache_dir = test_cache_dir("identity-copy"); + let identity_path = cache_dir.join("config").join("identity.json"); + let service = FeedbackService::new( + Some(base_url), + "1.0.0".to_string(), + Arc::new(MemoryStore::default()), + Duration::from_secs(2), + ) + .with_cache_dir(cache_dir.join("cache")) + .with_identity_path(identity_path.clone()); + + service.access_token("feedback:write", false).await.unwrap(); + + let value: serde_json::Value = + serde_json::from_slice(&tokio::fs::read(identity_path).await.unwrap()).unwrap(); + assert_eq!(value["schemaVersion"], 1); + assert_eq!(value["anonymousId"], "11111111-1111-4111-8111-111111111111"); + assert_eq!(value.as_object().unwrap().len(), 2); + let _ = tokio::fs::remove_dir_all(cache_dir).await; + } + + #[tokio::test] + async fn existing_cached_identity_is_copied_without_enrolling() { + let cache_dir = test_cache_dir("existing-identity-copy"); + let identity_path = cache_dir.join("config").join("identity.json"); + let stored = StoredCredentials { + enroll_key: "enroll".to_string(), + anonymous_id: Some("22222222-2222-4222-8222-222222222222".to_string()), + refresh_token: Some("refresh".to_string()), + ..StoredCredentials::default() + }; + let store = Arc::new(MemoryStore { + value: StdMutex::new(Some( + serde_json::json!({ + "enroll_key": stored.enroll_key, + "anonymous_id": stored.anonymous_id, + "refresh_token": stored.refresh_token, + }) + .to_string(), + )), + ..MemoryStore::default() + }); + let service = FeedbackService::new( + None, + "1.0.0".to_string(), + store.clone(), + Duration::from_secs(2), + ) + .with_identity_path(identity_path.clone()); + + let state = service.access_state().await.unwrap(); + + assert!(state.can_reuse_access); + let value: serde_json::Value = + serde_json::from_slice(&tokio::fs::read(identity_path).await.unwrap()).unwrap(); + assert_eq!(value["anonymousId"], "22222222-2222-4222-8222-222222222222"); + let _ = tokio::fs::remove_dir_all(cache_dir).await; + } + + #[tokio::test] + async fn lists_inbox_with_backend_cursor_mapping_and_cached_accessibility() { + let responses = vec![ + json_response( + 200, + r#"{"anonymous_id":"anon","access_token":"fresh","refresh_token":"refresh-2","expires_in":3600,"refresh_expires_in":2592000,"scope":"feedback:write,feedback:read","schema_version":"1"}"#, + ), + json_response( + 200, + r#"{"items":[{"feedback_id":"feedback-1","category":"other","status":"waiting_user","has_new_reply":true,"created_at":"2026-07-28T01:00:00Z","updated_at":"2026-07-28T02:00:00Z"}],"cursor":"cursor-2","has_more":true}"#, + ), + ]; + let (base_url, requests) = spawn_server(responses).await; + let stored = StoredCredentials { + enroll_key: "enroll".to_string(), + anonymous_id: Some("anon".to_string()), + refresh_token: Some("refresh-1".to_string()), + capabilities: HashMap::from([("feedback-1".to_string(), "capability".to_string())]), + ..StoredCredentials::default() + }; + let store = Arc::new(MemoryStore { + value: StdMutex::new(Some(serde_json::to_string(&stored).unwrap())), + ..MemoryStore::default() + }); + let cache_dir = test_cache_dir("inbox"); + let service = FeedbackService::new( + Some(base_url), + "1.0.0".to_string(), + store.clone(), + Duration::from_secs(2), + ) + .with_cache_dir(cache_dir.clone()); + + let page = service + .list_feedback(ListFeedbackRecordsRequest { + cursor: Some("cursor-1".to_string()), + page_size: 20, + }) + .await + .unwrap(); + + assert_eq!(page.next_cursor.as_deref(), Some("cursor-2")); + assert!(page.has_more); + assert!(page.items[0].can_open); + let captured = requests.lock().unwrap(); + assert!(captured[1].starts_with("GET /support/v1/feedback/inbox?limit=20&cursor=cursor-1 ")); + drop(captured); + let restored = service.access_state().await.unwrap(); + assert!(restored.cached_inbox.items[0].has_new_reply); + let secure_value = store.value.lock().unwrap().clone().unwrap(); + assert!(!secure_value.contains("inbox_items")); + + let restarted = + FeedbackService::new(None, "1.0.0".to_string(), store, Duration::from_secs(2)) + .with_cache_dir(cache_dir.clone()); + let restored_after_restart = restarted.access_state().await.unwrap(); + assert_eq!(restored_after_restart.cached_inbox.items.len(), 1); + assert!(restored_after_restart.cached_inbox.items[0].has_new_reply); + let _ = tokio::fs::remove_dir_all(cache_dir).await; + } + + #[tokio::test] + async fn returns_refreshed_inbox_when_local_cache_write_fails() { + let responses = vec![ + json_response( + 200, + r#"{"anonymous_id":"anon","access_token":"fresh","refresh_token":"refresh-2","expires_in":3600,"refresh_expires_in":2592000,"scope":"feedback:write,feedback:read","schema_version":"1"}"#, + ), + json_response( + 200, + r#"{"items":[{"feedback_id":"feedback-1","category":"other","status":"submitted","has_new_reply":false,"created_at":"2026-07-30T01:00:00Z","updated_at":"2026-07-30T01:00:00Z"}],"cursor":"","has_more":false}"#, + ), + json_response( + 200, + r#"{"items":[{"feedback_id":"feedback-1","category":"other","status":"submitted","has_new_reply":false,"created_at":"2026-07-30T01:00:00Z","updated_at":"2026-07-30T01:00:00Z"},{"feedback_id":"feedback-2","category":"runtime_error","status":"submitted","has_new_reply":false,"created_at":"2026-07-30T01:05:00Z","updated_at":"2026-07-30T01:05:00Z"}],"cursor":"","has_more":false}"#, + ), + ]; + let (base_url, _) = spawn_server(responses).await; + let stored = StoredCredentials { + enroll_key: "enroll".to_string(), + refresh_token: Some("refresh-1".to_string()), + ..StoredCredentials::default() + }; + let store = Arc::new(MemoryStore { + value: StdMutex::new(Some(serde_json::to_string(&stored).unwrap())), + ..MemoryStore::default() + }); + let cache_dir = test_cache_dir("unwritable-cache"); + let service = FeedbackService::new( + Some(base_url), + "1.0.0".to_string(), + store, + Duration::from_secs(2), + ) + .with_cache_dir(cache_dir.clone()); + + let first = service + .list_feedback(ListFeedbackRecordsRequest { + cursor: None, + page_size: 20, + }) + .await + .unwrap(); + assert_eq!(first.items.len(), 1); + + tokio::fs::remove_dir_all(&cache_dir).await.unwrap(); + tokio::fs::write(&cache_dir, b"not a directory") + .await + .unwrap(); + let second = service + .list_feedback(ListFeedbackRecordsRequest { + cursor: None, + page_size: 20, + }) + .await + .unwrap(); + + assert_eq!(second.items.len(), 2); + let _ = tokio::fs::remove_file(cache_dir).await; + } + + #[tokio::test] + async fn rebuilds_message_cache_then_pages_latest_to_earliest_and_acks_server_state() { + let feedback_id = "11111111-1111-4111-8111-111111111111"; + let responses = vec![ + json_response( + 200, + r#"{"anonymous_id":"anon","access_token":"fresh","refresh_token":"refresh-2","expires_in":3600,"refresh_expires_in":2592000,"scope":"feedback:write,feedback:read","schema_version":"1"}"#, + ), + json_response( + 200, + r#"{"feedback_id":"11111111-1111-4111-8111-111111111111","messages":[{"message_id":"message-1","sender_type":"user","content":"first","content_deleted":false,"created_at":"2026-07-28T01:00:00Z"}],"cursor":"cursor-1","has_more":true}"#, + ), + json_response( + 200, + r#"{"feedback_id":"11111111-1111-4111-8111-111111111111","messages":[{"message_id":"message-2","sender_type":"admin","content":"second","content_deleted":true,"created_at":"2026-07-28T02:00:00Z"}],"cursor":"cursor-2","has_more":false}"#, + ), + json_response( + 200, + r#"{"feedback_id":"11111111-1111-4111-8111-111111111111","read_cursor":"2026-07-28T02:00:00Z","feedback_status":"in_progress"}"#, + ), + ]; + let (base_url, requests) = spawn_server(responses).await; + let stored = StoredCredentials { + enroll_key: "enroll".to_string(), + anonymous_id: Some("anon".to_string()), + refresh_token: Some("refresh-1".to_string()), + capabilities: HashMap::from([( + feedback_id.to_string(), + "capability-secret".to_string(), + )]), + ..StoredCredentials::default() + }; + let store = Arc::new(MemoryStore { + value: StdMutex::new(Some(serde_json::to_string(&stored).unwrap())), + ..MemoryStore::default() + }); + let cache_dir = std::env::temp_dir().join(format!( + "bitfun-feedback-service-cache-{}-{}", + std::process::id(), + uuid::Uuid::new_v4() + )); + let service = FeedbackService::new( + Some(base_url), + "1.0.0".to_string(), + store, + Duration::from_secs(2), + ) + .with_message_cache_dir(cache_dir.clone()); + + let latest = service + .open_conversation(OpenFeedbackConversationRequest { + feedback_id: feedback_id.to_string(), + cursor: None, + page_size: 1, + }) + .await + .unwrap(); + assert_eq!(latest.messages[0].content, "second"); + assert!(latest.messages[0].content_deleted); + assert_eq!(latest.next_cursor.as_deref(), Some("cache:1")); + assert!(latest.has_more); + + let earlier = service + .open_conversation(OpenFeedbackConversationRequest { + feedback_id: feedback_id.to_string(), + cursor: latest.next_cursor, + page_size: 1, + }) + .await + .unwrap(); + assert_eq!(earlier.messages[0].content, "first"); + assert!(!earlier.messages[0].content_deleted); + assert!(!earlier.has_more); + + let acknowledged = service + .acknowledge_feedback(AcknowledgeFeedbackRequest { + feedback_id: feedback_id.to_string(), + last_visible_at: "2026-07-28T02:00:00Z".to_string(), + }) + .await + .unwrap(); + assert_eq!(acknowledged.read_through, "2026-07-28T02:00:00Z"); + assert_eq!(acknowledged.feedback_status, FeedbackStatus::InProgress); + + let captured = requests.lock().unwrap(); + assert!(captured[1].starts_with( + "GET /support/v1/feedback/11111111-1111-4111-8111-111111111111/messages?limit=50 " + )); + assert!(captured[2].contains("cursor=cursor-1")); + assert_eq!( + header(&captured[1], "x-feedback-capability").as_deref(), + Some("capability-secret") + ); + assert!(captured[3] + .starts_with("POST /support/v1/feedback/11111111-1111-4111-8111-111111111111/ack ")); + drop(captured); + let _ = tokio::fs::remove_dir_all(cache_dir).await; + } + + #[tokio::test] + async fn clears_false_unread_marker_when_synced_history_contains_only_user_messages() { + let feedback_id = "11111111-1111-4111-8111-111111111111"; + let responses = vec![ + json_response( + 200, + r#"{"anonymous_id":"anon","access_token":"fresh","refresh_token":"refresh-2","expires_in":3600,"refresh_expires_in":2592000,"scope":"feedback:write,feedback:read","schema_version":"1"}"#, + ), + json_response( + 200, + r#"{"items":[{"feedback_id":"11111111-1111-4111-8111-111111111111","category":"other","status":"submitted","has_new_reply":true,"created_at":"2026-07-28T01:00:00Z","updated_at":"2026-07-28T02:00:00Z"}],"cursor":"","has_more":false}"#, + ), + json_response( + 200, + r#"{"feedback_id":"11111111-1111-4111-8111-111111111111","messages":[{"message_id":"22222222-2222-4222-8222-222222222222","sender_type":"user","content":"initial report","content_deleted":false,"created_at":"2026-07-28T01:00:00Z"},{"message_id":"33333333-3333-4333-8333-333333333333","sender_type":"user","content":"own follow-up","content_deleted":false,"created_at":"2026-07-28T02:00:00Z"}],"cursor":"cursor-2","has_more":false}"#, + ), + json_response( + 200, + r#"{"feedback_id":"11111111-1111-4111-8111-111111111111","read_cursor":"2026-07-28T02:00:00Z","feedback_status":"submitted"}"#, + ), + ]; + let (base_url, requests) = spawn_server(responses).await; + let stored = StoredCredentials { + enroll_key: "enroll".to_string(), + anonymous_id: Some("anon".to_string()), + refresh_token: Some("refresh-1".to_string()), + capabilities: HashMap::from([( + feedback_id.to_string(), + "capability-secret".to_string(), + )]), + ..StoredCredentials::default() + }; + let store = Arc::new(MemoryStore { + value: StdMutex::new(Some(serde_json::to_string(&stored).unwrap())), + ..MemoryStore::default() + }); + let cache_dir = test_cache_dir("own-reply-unread"); + let service = FeedbackService::new( + Some(base_url), + "1.0.0".to_string(), + store, + Duration::from_secs(2), + ) + .with_message_cache_dir(cache_dir.clone()); + + let inbox = service + .list_feedback(ListFeedbackRecordsRequest { + cursor: None, + page_size: 20, + }) + .await + .unwrap(); + assert!(inbox.items[0].has_new_reply); + + service + .open_conversation(OpenFeedbackConversationRequest { + feedback_id: feedback_id.to_string(), + cursor: None, + page_size: 20, + }) + .await + .unwrap(); + + let restored = service.access_state().await.unwrap(); + assert!(!restored.cached_inbox.items[0].has_new_reply); + let captured = requests.lock().unwrap(); + assert!(captured[3] + .starts_with("POST /support/v1/feedback/11111111-1111-4111-8111-111111111111/ack ")); + drop(captured); + let _ = tokio::fs::remove_dir_all(cache_dir).await; + } + + #[tokio::test] + async fn preserves_capability_when_conversation_sync_is_denied_by_scope() { + let feedback_id = "11111111-1111-4111-8111-111111111111"; + let responses = vec![ + json_response( + 200, + r#"{"anonymous_id":"anon","access_token":"fresh","refresh_token":"refresh-2","expires_in":3600,"refresh_expires_in":2592000,"scope":"feedback:write,feedback:read","schema_version":"1"}"#, + ), + json_response( + 403, + r#"{"error_code":"SCOPE_INSUFFICIENT","request_id":"request-scope"}"#, + ), + ]; + let (base_url, _) = spawn_server(responses).await; + let stored = StoredCredentials { + enroll_key: "enroll".to_string(), + anonymous_id: Some("anon".to_string()), + refresh_token: Some("refresh-1".to_string()), + capabilities: HashMap::from([( + feedback_id.to_string(), + "capability-secret".to_string(), + )]), + ..StoredCredentials::default() + }; + let store = Arc::new(MemoryStore { + value: StdMutex::new(Some(serde_json::to_string(&stored).unwrap())), + ..MemoryStore::default() + }); + let cache_dir = test_cache_dir("scope-preserves-capability"); + let service = FeedbackService::new( + Some(base_url), + "1.0.0".to_string(), + store.clone(), + Duration::from_secs(2), + ) + .with_message_cache_dir(cache_dir.clone()); + + let page = service + .open_conversation(OpenFeedbackConversationRequest { + feedback_id: feedback_id.to_string(), + cursor: None, + page_size: 20, + }) + .await + .unwrap(); + + assert_eq!(page.sync_error.as_ref().unwrap().code, "SCOPE_INSUFFICIENT"); + let secure_value = store.value.lock().unwrap().clone().unwrap(); + let persisted: StoredCredentials = serde_json::from_str(&secure_value).unwrap(); + assert_eq!( + persisted.capabilities.get(feedback_id).map(String::as_str), + Some("capability-secret") + ); + let _ = tokio::fs::remove_dir_all(cache_dir).await; + } + + #[tokio::test] + async fn reuses_reply_idempotency_key_and_accepts_committed_server_status() { + let feedback_id = "11111111-1111-4111-8111-111111111111"; + let message_id = "22222222-2222-4222-8222-222222222222"; + let responses = vec![ + json_response( + 200, + r#"{"anonymous_id":"anon","access_token":"fresh","refresh_token":"refresh-2","expires_in":3600,"refresh_expires_in":2592000,"scope":"feedback:write,feedback:read","schema_version":"1"}"#, + ), + json_response( + 503, + r#"{"error_code":"INTERNAL_ERROR","request_id":"request-failed"}"#, + ), + json_response( + 201, + &format!( + r#"{{"message_id":"{message_id}","sender_type":"user","created_at":"2026-07-28T03:00:00Z","feedback_status":"submitted"}}"#, + ), + ), + json_response( + 200, + r#"{"feedback_id":"11111111-1111-4111-8111-111111111111","read_cursor":"2026-07-28T03:00:00Z","feedback_status":"submitted"}"#, + ), + ]; + let (base_url, requests) = spawn_server(responses).await; + let stored = StoredCredentials { + enroll_key: "enroll".to_string(), + anonymous_id: Some("anon".to_string()), + refresh_token: Some("refresh-1".to_string()), + capabilities: HashMap::from([( + feedback_id.to_string(), + "capability-secret".to_string(), + )]), + ..StoredCredentials::default() + }; + let store = Arc::new(MemoryStore { + value: StdMutex::new(Some(serde_json::to_string(&stored).unwrap())), + ..MemoryStore::default() + }); + let cache_dir = std::env::temp_dir().join(format!( + "bitfun-feedback-reply-cache-{}-{}", + std::process::id(), + uuid::Uuid::new_v4() + )); + let cache = MessageCache::new(cache_dir.clone(), cache_encryption_key("enroll").unwrap()); + let mut cached = MessageCacheData::empty(feedback_id); + cached.sync_complete = true; + cache.store(&cached).await.unwrap(); + let service = FeedbackService::new( + Some(base_url), + "1.0.0".to_string(), + store.clone(), + Duration::from_secs(2), + ) + .with_message_cache_dir(cache_dir.clone()); + let request = ReplyFeedbackRequest { + feedback_id: feedback_id.to_string(), + content: " retry safely ".to_string(), + }; + + let first = service.reply_feedback(request.clone()).await.unwrap_err(); + assert_eq!(first.code, "INTERNAL_ERROR"); + let replied = service.reply_feedback(request).await.unwrap(); + assert_eq!(replied.message.message_id, message_id); + assert_eq!(replied.message.content, "retry safely"); + assert_eq!(replied.feedback_status, FeedbackStatus::Submitted); + + let captured = requests.lock().unwrap(); + let replies = captured + .iter() + .filter(|request| { + request.starts_with(&format!( + "POST /support/v1/feedback/{feedback_id}/messages " + )) + }) + .collect::>(); + assert_eq!(replies.len(), 2); + assert_eq!( + header(replies[0], "idempotency-key"), + header(replies[1], "idempotency-key") + ); + assert_eq!( + header(replies[1], "x-feedback-capability").as_deref(), + Some("capability-secret") + ); + assert!(captured[3] + .starts_with("POST /support/v1/feedback/11111111-1111-4111-8111-111111111111/ack ")); + drop(captured); + let persisted: StoredCredentials = + serde_json::from_str(store.value.lock().unwrap().as_deref().unwrap()).unwrap(); + assert!(persisted.pending_reply_fingerprints.is_empty()); + assert!(persisted.pending_reply_idempotency_keys.is_empty()); + let _ = tokio::fs::remove_dir_all(cache_dir).await; + } + + #[tokio::test] + async fn rejects_replies_to_locally_known_resolved_feedback() { + let feedback_id = "11111111-1111-4111-8111-111111111111"; + let store = Arc::new(MemoryStore { + value: StdMutex::new(Some( + serde_json::json!({ + "enroll_key": "enroll", + "inbox_items": [{ + "feedbackId": feedback_id, + "category": "other", + "status": "resolved", + "hasNewReply": false, + "createdAt": "2026-07-28T01:00:00Z", + "updatedAt": "2026-07-28T02:00:00Z", + "canOpen": true + }] + }) + .to_string(), + )), + ..MemoryStore::default() + }); + let service = + FeedbackService::new(None, "1.0.0".to_string(), store, Duration::from_secs(2)); + + let error = service + .reply_feedback(ReplyFeedbackRequest { + feedback_id: feedback_id.to_string(), + content: "should not send".to_string(), + }) + .await + .unwrap_err(); + + assert_eq!(error.code, "FEEDBACK_ALREADY_RESOLVED"); + } + + #[tokio::test] + async fn capability_persistence_is_part_of_submission_success() { + let responses = vec![ + json_response( + 201, + r#"{"anonymous_id":"anon","access_token":"access","refresh_token":"refresh","expires_in":3600,"refresh_expires_in":2592000,"scope":"feedback:write,feedback:read","schema_version":"1"}"#, + ), + json_response( + 201, + r#"{"feedback_id":"feedback-1","capability_token":"capability","status":"submitted","inbox_cursor":"cursor-1","schema_version":"1"}"#, + ), + json_response( + 201, + r#"{"feedback_id":"feedback-1","capability_token":"capability","status":"submitted","inbox_cursor":"cursor-1","schema_version":"1","idempotency_replayed":true}"#, + ), + ]; + let (base_url, requests) = spawn_server(responses).await; + let store = Arc::new(MemoryStore::default()); + store.fail_at.store(3, Ordering::SeqCst); + let cache_dir = test_cache_dir("capability-save"); + let service = FeedbackService::new( + Some(base_url), + "1.0.0".to_string(), + store, + Duration::from_secs(2), + ) + .with_cache_dir(cache_dir.clone()); + let request = SubmitFeedbackRequest { + category: FeedbackCategory::Other, + content: "privacy-safe feedback".to_string(), + trace_id: None, + session_id_hash: None, + }; + + let first = service.submit_feedback(request.clone()).await.unwrap_err(); + assert_eq!(first.code, "CAPABILITY_SAVE_FAILED"); + let second = service.submit_feedback(request).await.unwrap(); + assert_eq!(second.feedback_id, "feedback-1"); + + let captured = requests.lock().unwrap(); + let create_keys: Vec<_> = captured + .iter() + .filter(|request| request.starts_with("POST /support/v1/feedback ")) + .filter_map(|request| header(request, "idempotency-key")) + .collect(); + assert_eq!(create_keys.len(), 2); + assert_eq!(create_keys[0], create_keys[1]); + let _ = tokio::fs::remove_dir_all(cache_dir).await; + } + + #[tokio::test] + async fn recovers_from_one_unauthorized_response_and_replays_once() { + let responses = vec![ + json_response( + 201, + r#"{"anonymous_id":"anon","access_token":"expired","refresh_token":"refresh-1","expires_in":3600,"refresh_expires_in":2592000,"scope":"feedback:write,feedback:read","schema_version":"1"}"#, + ), + json_response( + 401, + r#"{"error_code":"ACCESS_TOKEN_INVALID","error_message":"expired","request_id":"request-401"}"#, + ), + json_response( + 200, + r#"{"anonymous_id":"anon","access_token":"fresh","refresh_token":"refresh-2","expires_in":3600,"refresh_expires_in":2592000,"scope":"feedback:write,feedback:read","schema_version":"1"}"#, + ), + json_response( + 201, + r#"{"feedback_id":"feedback-1","capability_token":"capability","status":"submitted","inbox_cursor":"cursor-1","schema_version":"1"}"#, + ), + ]; + let (base_url, requests) = spawn_server(responses).await; + let cache_dir = test_cache_dir("unauthorized-recovery"); + let service = FeedbackService::new( + Some(base_url), + "1.0.0".to_string(), + Arc::new(MemoryStore::default()), + Duration::from_secs(2), + ) + .with_cache_dir(cache_dir.clone()); + service + .submit_feedback(SubmitFeedbackRequest { + category: FeedbackCategory::Other, + content: "recover once".to_string(), + trace_id: None, + session_id_hash: None, + }) + .await + .unwrap(); + + let captured = requests.lock().unwrap(); + assert!(captured[0].starts_with("POST /auth/v1/anonymous/enroll ")); + assert!(captured[1].starts_with("POST /support/v1/feedback ")); + assert!(captured[2].starts_with("POST /auth/v1/anonymous/token ")); + assert!(captured[3].starts_with("POST /support/v1/feedback ")); + assert_eq!( + header(&captured[1], "idempotency-key"), + header(&captured[3], "idempotency-key") + ); + drop(captured); + let _ = tokio::fs::remove_dir_all(cache_dir).await; + } + + fn test_cache_dir(name: &str) -> std::path::PathBuf { + std::env::temp_dir().join(format!( + "bitfun-feedback-{name}-{}-{}", + std::process::id(), + uuid::Uuid::new_v4() + )) + } + + fn json_response(status: u16, body: &str) -> String { + format!( + "HTTP/1.1 {status} Test\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + ) + } + + async fn spawn_server(responses: Vec) -> (String, Arc>>) { + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let requests = Arc::new(StdMutex::new(Vec::new())); + let captured = requests.clone(); + tokio::spawn(async move { + for response in responses { + let (mut stream, _) = listener.accept().await.unwrap(); + let mut bytes = Vec::new(); + let mut buffer = [0u8; 4096]; + loop { + let read = stream.read(&mut buffer).await.unwrap(); + if read == 0 { + break; + } + bytes.extend_from_slice(&buffer[..read]); + if request_is_complete(&bytes) { + break; + } + } + captured + .lock() + .unwrap() + .push(String::from_utf8_lossy(&bytes).into_owned()); + stream.write_all(response.as_bytes()).await.unwrap(); + } + }); + (format!("http://{address}"), requests) + } + + fn request_is_complete(bytes: &[u8]) -> bool { + let request = String::from_utf8_lossy(bytes); + let Some(header_end) = request.find("\r\n\r\n") else { + return false; + }; + let content_length = request[..header_end] + .lines() + .find_map(|line| { + line.to_ascii_lowercase() + .strip_prefix("content-length:") + .and_then(|value| value.trim().parse::().ok()) + }) + .unwrap_or(0); + bytes.len() >= header_end + 4 + content_length + } + + fn header(request: &str, name: &str) -> Option { + request.lines().find_map(|line| { + let (key, value) = line.split_once(':')?; + key.eq_ignore_ascii_case(name) + .then(|| value.trim().to_string()) + }) + } +} diff --git a/src/crates/services/services-integrations/src/feedback/state_cache.rs b/src/crates/services/services-integrations/src/feedback/state_cache.rs new file mode 100644 index 0000000000..82a56daf51 --- /dev/null +++ b/src/crates/services/services-integrations/src/feedback/state_cache.rs @@ -0,0 +1,187 @@ +use aes_gcm::aead::{Aead, KeyInit, Payload}; +use aes_gcm::{Aes256Gcm, Nonce}; +use anyhow::{Context, Result}; +use bitfun_product_domains::feedback::FeedbackRecordSummary; +use rand::RngCore; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::{Path, PathBuf}; + +const CACHE_VERSION: u8 = 1; +const NONCE_LEN: usize = 12; +const STATE_CACHE_AAD: &[u8] = b"bitfun-feedback-state-cache-v1"; + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub(super) struct FeedbackStateCacheData { + pub(super) version: u8, + #[serde(default)] + pub(super) enroll_idempotency_key: Option, + #[serde(default)] + pub(super) refresh_idempotency_key: Option, + #[serde(default)] + pub(super) anonymous_id: Option, + #[serde(default)] + pub(super) pending_create_fingerprint: Option, + #[serde(default)] + pub(super) pending_create_idempotency_key: Option, + #[serde(default)] + pub(super) inbox_items: Vec, + #[serde(default)] + pub(super) inbox_next_cursor: Option, + #[serde(default)] + pub(super) inbox_has_more: bool, + #[serde(default)] + pub(super) read_cursors: HashMap, + #[serde(default)] + pub(super) pending_reply_fingerprints: HashMap, + #[serde(default)] + pub(super) pending_reply_idempotency_keys: HashMap, +} + +impl FeedbackStateCacheData { + pub(super) fn with_current_version(mut self) -> Self { + self.version = CACHE_VERSION; + self + } +} + +pub(super) struct FeedbackStateCache { + directory: PathBuf, + key: [u8; 32], +} + +impl FeedbackStateCache { + pub(super) fn new(directory: PathBuf, key: [u8; 32]) -> Self { + Self { directory, key } + } + + pub(super) async fn load(&self) -> Result> { + let path = self.path(); + if !path.exists() { + return Ok(None); + } + let blob = tokio::fs::read(&path) + .await + .context("read feedback state cache")?; + if blob.len() <= NONCE_LEN { + anyhow::bail!("feedback state cache is truncated"); + } + let (nonce, ciphertext) = blob.split_at(NONCE_LEN); + let cipher = Aes256Gcm::new_from_slice(&self.key) + .map_err(|error| anyhow::anyhow!("initialize feedback state cache cipher: {error}"))?; + let plaintext = cipher + .decrypt( + Nonce::from_slice(nonce), + Payload { + msg: ciphertext, + aad: STATE_CACHE_AAD, + }, + ) + .map_err(|error| anyhow::anyhow!("decrypt feedback state cache: {error}"))?; + let data: FeedbackStateCacheData = + serde_json::from_slice(&plaintext).context("decode feedback state cache")?; + if data.version != CACHE_VERSION { + anyhow::bail!("feedback state cache version is invalid"); + } + Ok(Some(data)) + } + + pub(super) async fn store(&self, data: &FeedbackStateCacheData) -> Result<()> { + tokio::fs::create_dir_all(&self.directory) + .await + .context("create feedback state cache directory")?; + let plaintext = serde_json::to_vec(data).context("encode feedback state cache")?; + let cipher = Aes256Gcm::new_from_slice(&self.key) + .map_err(|error| anyhow::anyhow!("initialize feedback state cache cipher: {error}"))?; + let mut nonce = [0u8; NONCE_LEN]; + rand::rngs::OsRng.fill_bytes(&mut nonce); + let ciphertext = cipher + .encrypt( + Nonce::from_slice(&nonce), + Payload { + msg: &plaintext, + aad: STATE_CACHE_AAD, + }, + ) + .map_err(|error| anyhow::anyhow!("encrypt feedback state cache: {error}"))?; + let mut blob = Vec::with_capacity(NONCE_LEN + ciphertext.len()); + blob.extend_from_slice(&nonce); + blob.extend_from_slice(&ciphertext); + + let path = self.path(); + let temporary_path = path.with_extension("cache.tmp"); + tokio::fs::write(&temporary_path, blob) + .await + .context("write feedback state cache")?; + set_owner_only(&temporary_path); + tokio::fs::rename(&temporary_path, &path) + .await + .context("replace feedback state cache")?; + set_owner_only(&path); + Ok(()) + } + + pub(super) async fn remove(&self) -> Result<()> { + match tokio::fs::remove_file(self.path()).await { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error).context("remove feedback state cache"), + } + } + + fn path(&self) -> PathBuf { + self.directory.join("state.cache") + } +} + +#[cfg(unix)] +fn set_owner_only(path: &Path) { + use std::os::unix::fs::PermissionsExt; + if let Err(error) = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) { + log::warn!("Failed to restrict feedback state cache permissions: {error}"); + } +} + +#[cfg(not(unix))] +fn set_owner_only(_path: &Path) {} + +#[cfg(test)] +mod tests { + use super::{FeedbackStateCache, FeedbackStateCacheData}; + use bitfun_product_domains::feedback::{ + FeedbackCategory, FeedbackRecordSummary, FeedbackStatus, + }; + + #[tokio::test] + async fn encrypts_state_and_treats_corruption_as_invalid_cache() { + let directory = std::env::temp_dir().join(format!( + "bitfun-feedback-state-cache-{}-{}", + std::process::id(), + uuid::Uuid::new_v4() + )); + let cache = FeedbackStateCache::new(directory.clone(), [9u8; 32]); + let data = FeedbackStateCacheData { + inbox_items: vec![FeedbackRecordSummary { + feedback_id: "feedback-1".to_string(), + category: FeedbackCategory::Other, + status: FeedbackStatus::Submitted, + has_new_reply: false, + created_at: "2026-07-30T01:00:00Z".to_string(), + updated_at: "2026-07-30T01:00:00Z".to_string(), + can_open: true, + }], + ..FeedbackStateCacheData::default() + } + .with_current_version(); + + cache.store(&data).await.unwrap(); + let encrypted = tokio::fs::read(cache.path()).await.unwrap(); + assert!(!String::from_utf8_lossy(&encrypted).contains("feedback-1")); + let restored = cache.load().await.unwrap().unwrap(); + assert_eq!(restored.inbox_items, data.inbox_items); + + tokio::fs::write(cache.path(), b"corrupt").await.unwrap(); + assert!(cache.load().await.is_err()); + let _ = tokio::fs::remove_dir_all(directory).await; + } +} diff --git a/src/crates/services/services-integrations/src/feedback/vault.rs b/src/crates/services/services-integrations/src/feedback/vault.rs new file mode 100644 index 0000000000..be6696419c --- /dev/null +++ b/src/crates/services/services-integrations/src/feedback/vault.rs @@ -0,0 +1,156 @@ +use aes_gcm::aead::{Aead, KeyInit}; +use aes_gcm::{Aes256Gcm, Nonce}; +use anyhow::{Context, Result}; +use async_trait::async_trait; +use rand::RngCore; +use std::path::{Path, PathBuf}; +use tokio::sync::Mutex; + +const NONCE_LEN: usize = 12; + +#[async_trait] +pub trait FeedbackCredentialStore: Send + Sync { + async fn load(&self) -> Result>; + async fn store(&self, value: &str) -> Result<()>; +} + +pub struct FileFeedbackCredentialStore { + key_path: PathBuf, + data_path: PathBuf, + lock: Mutex<()>, +} + +impl FileFeedbackCredentialStore { + pub fn new(data_dir: PathBuf) -> Self { + let directory = data_dir.join("feedback"); + Self { + key_path: directory.join(".credentials.key"), + data_path: directory.join("credentials.vault"), + lock: Mutex::new(()), + } + } + + async fn ensure_key(&self) -> Result<[u8; 32]> { + if self.key_path.exists() { + return self.read_key().await; + } + if let Some(parent) = self.key_path.parent() { + tokio::fs::create_dir_all(parent) + .await + .context("create feedback credential directory")?; + } + let mut key = [0u8; 32]; + rand::rngs::OsRng.fill_bytes(&mut key); + let temporary_path = self.key_path.with_extension("key.tmp"); + tokio::fs::write(&temporary_path, key) + .await + .context("write feedback credential key")?; + set_owner_only(&temporary_path); + tokio::fs::rename(&temporary_path, &self.key_path) + .await + .context("replace feedback credential key")?; + set_owner_only(&self.key_path); + Ok(key) + } + + async fn read_key(&self) -> Result<[u8; 32]> { + let bytes = tokio::fs::read(&self.key_path) + .await + .context("read feedback credential key")?; + if bytes.len() != 32 { + anyhow::bail!("feedback credential key has invalid length"); + } + let mut key = [0u8; 32]; + key.copy_from_slice(&bytes); + Ok(key) + } +} + +#[async_trait] +impl FeedbackCredentialStore for FileFeedbackCredentialStore { + async fn load(&self) -> Result> { + let _guard = self.lock.lock().await; + if !self.key_path.exists() || !self.data_path.exists() { + return Ok(None); + } + let key = self.read_key().await?; + let blob = tokio::fs::read(&self.data_path) + .await + .context("read feedback credential vault")?; + if blob.len() <= NONCE_LEN { + anyhow::bail!("feedback credential vault is truncated"); + } + let (nonce, ciphertext) = blob.split_at(NONCE_LEN); + let cipher = Aes256Gcm::new_from_slice(&key) + .map_err(|error| anyhow::anyhow!("initialize feedback vault cipher: {error}"))?; + let plaintext = cipher + .decrypt(Nonce::from_slice(nonce), ciphertext) + .map_err(|error| anyhow::anyhow!("decrypt feedback credential vault: {error}"))?; + String::from_utf8(plaintext) + .context("decode feedback credential vault") + .map(Some) + } + + async fn store(&self, value: &str) -> Result<()> { + let _guard = self.lock.lock().await; + let key = self.ensure_key().await?; + let cipher = Aes256Gcm::new_from_slice(&key) + .map_err(|error| anyhow::anyhow!("initialize feedback vault cipher: {error}"))?; + let mut nonce = [0u8; NONCE_LEN]; + rand::rngs::OsRng.fill_bytes(&mut nonce); + let ciphertext = cipher + .encrypt(Nonce::from_slice(&nonce), value.as_bytes()) + .map_err(|error| anyhow::anyhow!("encrypt feedback credential vault: {error}"))?; + let mut blob = Vec::with_capacity(NONCE_LEN + ciphertext.len()); + blob.extend_from_slice(&nonce); + blob.extend_from_slice(&ciphertext); + + if let Some(parent) = self.data_path.parent() { + tokio::fs::create_dir_all(parent) + .await + .context("create feedback credential directory")?; + } + let temporary_path = self.data_path.with_extension("vault.tmp"); + tokio::fs::write(&temporary_path, blob) + .await + .context("write feedback credential vault")?; + set_owner_only(&temporary_path); + tokio::fs::rename(&temporary_path, &self.data_path) + .await + .context("replace feedback credential vault")?; + set_owner_only(&self.data_path); + Ok(()) + } +} + +#[cfg(unix)] +fn set_owner_only(path: &Path) { + use std::os::unix::fs::PermissionsExt; + if let Err(error) = std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)) { + log::warn!("Failed to restrict feedback credential file permissions: {error}"); + } +} + +#[cfg(not(unix))] +fn set_owner_only(_path: &Path) {} + +#[cfg(test)] +mod tests { + use super::{FeedbackCredentialStore, FileFeedbackCredentialStore}; + + #[tokio::test] + async fn round_trips_encrypted_credentials() { + let directory = std::env::temp_dir().join(format!( + "bitfun-feedback-vault-{}-{}", + std::process::id(), + uuid::Uuid::new_v4() + )); + let vault = FileFeedbackCredentialStore::new(directory.clone()); + vault.store("capability-secret").await.unwrap(); + assert_eq!( + vault.load().await.unwrap().as_deref(), + Some("capability-secret") + ); + let _ = tokio::fs::remove_dir_all(directory).await; + } +} diff --git a/src/crates/services/services-integrations/src/lib.rs b/src/crates/services/services-integrations/src/lib.rs index 1af57d16ca..3f6a4b0321 100644 --- a/src/crates/services/services-integrations/src/lib.rs +++ b/src/crates/services/services-integrations/src/lib.rs @@ -21,6 +21,9 @@ pub mod deep_research; #[cfg(feature = "file-watch")] pub mod file_watch; +#[cfg(feature = "feedback")] +pub mod feedback; + #[cfg(feature = "function-agents")] pub mod function_agents; @@ -36,6 +39,9 @@ pub mod miniapp; #[cfg(feature = "plugin-source")] pub mod plugin_source; +#[cfg(feature = "privacy")] +pub mod privacy; + #[cfg(feature = "remote-connect")] pub mod remote_connect; diff --git a/src/crates/services/services-integrations/src/privacy/assets/zh-CN.md b/src/crates/services/services-integrations/src/privacy/assets/zh-CN.md new file mode 100644 index 0000000000..a48e2d60ff --- /dev/null +++ b/src/crates/services/services-integrations/src/privacy/assets/zh-CN.md @@ -0,0 +1,114 @@ +# 关于HUAWEI BitFun的隐私协议 + +更新日期:2026年7月30日 + +HUAWEI BitFun ADE 是由华为技术有限公司开发的 AI 智能体开发环境。该产品基于BitFun Agent Kernel,融合鸿蒙 Harness、格物市场精品 Skills、鸿蒙开发工具链及知识库,提供覆盖代码编写、编译构建、设备运行与调试的一站式鸿蒙应用开发体验。同时,支持研究、文档处理、桌面操作及可扩展工作流等场景,为用户提供覆盖日常办公与鸿蒙开发的AI 生产力服务。 + +## 1. 我们如何收集和使用您的个人信息 + +我们将通过HUAWEI BitFun为您提供下述业务功能或者服务,在您使用相关业务功能或者服务的过程中,我们会收集并处理下列提供功能或者服务所必需的信息,以便履行我们的合同义务。若您不提供相关信息,会影响到您使用本应用的相关功能或者服务。 + +### 1.1 用户体验改进与数据分析 + +为持续优化HUAWEI BitFun的功能体验与服务质量,当您首次打开应用时,我们会弹出"用户体验改进计划"声明(附带本隐私协议),在您同意后,我们会收集以下信息用于业务功能优化: + +- **IP地址**; +- **应用使用信息**:包括功能使用频次、性能指标、运行环境信息; + +如果您不同意用户体验改进声明(附带隐私协议),则我们不收集上述用户数据,您仍可正常使用应用的全部功能。您的同意或不同意选择将存储在您本地配置文件中。 + +您可随时通过**帮助菜单**中的"取消同意用户体验改进计划"选项,撤回您对数据收集的同意。撤回同意后,我们将停止对您个人信息的收集与使用,但不影响此前基于同意已进行的处理活动的效力。 + +### 1.2 用户反馈与问题提交 + +为接收并处理您在使用HUAWEI BitFun过程中遇到的问题与建议,当您通过应用内反馈入口提交反馈时,我们会收集以下信息: + +- **IP地址**; +- **应用使用信息**:包括应用版本、运行环境信息; +- **用户反馈内容**; + +在您提交反馈时,您需要点击同意本隐私协议后方可提交。若您不同意隐私协议,我们可能无法接收并处理您的反馈请求。 + +## 2. 设备权限调用 + +为支持BitFun的桌面执行层功能,本应用可能需要使用以下设备权限: + +- **文件夹权限**:本应用可能需要使用下载文件夹、文档文件夹和桌面文件夹权限,以便Agent在编码、文档生成等场景中读取和写入文件。基于系统功能考虑,您可以在设置中关闭权限。 +- **剪切板权限**:本应用需要使用剪切板权限,以便您在开发过程中获得更好的体验。 +- **终端/命令行权限**:本应用可能需要使用终端权限,以便Agent执行构建、调试、Git操作等命令行任务。 + +## 3. 对未成年人的保护 + +本应用不向未成人和儿童提供服务。如您是未成年人或儿童,请务必退出使用本应用。如您是未成年或儿童的父母或其他监护人,应采取适当的预防措施来确保未成年人或儿童不会使用本应用。 + +## 4. 管理您的个人信息 + +华为非常尊重您对个人信息的关注,并为您提供了以下数据主体权利及选择。 + +在您使用HUAWEI BitFun期间,为了您可以更加便捷地访问、更正、删除您的个人信息,同时保障您撤回对个人信息使用的同意的权利,我们为您提供了相应的操作设置,您可以参考下面的指引进行操作: + +### 4.1 数据主体权利响应 + +如果您希望行使数据主体权利(包括访问、更正、删除等),您可以在HUAWEI BitFun的配置文件(config)中读取您的唯一标识符(UUID),然后通过本隐私协议中"如何联系我们"章节中的邮件地址与我们联系,注明您的UUID及您希望行使的权利,我们将根据您的请求删除相关数据。 + +### 4.2 撤回同意与停止服务 + +您可以前往**帮助菜单**,选择"取消同意用户体验改进计划",撤回对数据收集的同意。撤回同意后,我们将停止对您个人信息的收集与使用,但不影响此前基于同意已进行的处理活动的效力。您仍可正常使用应用的全部功能。 + +如您对您的数据主体权利有进一步要求或存在任何疑问、意见或建议,可通过本隐私协议中"如何联系我们"章节中所述方式与我们取得联系,并行使您的相关权利。 + +## 5. 信息存储地点及期限 + +### 5.1 存储地点 + +上述信息将会传输并保存至中华人民共和国境内的服务器。本产品不涉及个人数据跨境转移。 + +### 5.2 存储期限 + +我们仅在实现本隐私协议所述目的所必需的时间内保留您的个人信息,并在超出下述保留时间后删除或匿名化处理您的个人信息,除非法律法规另有要求。 + +- 用户体验改进与数据分析服务收集的IP地址及应用使用信息上传到服务器后,保存不超过两(2)年。 +- 用于用户反馈的相关信息上传到服务器后,保存不超过两(2)年。 + +此外,当我们的产品或服务发生停止运营的情形时,我们将以推送通知、公告等形式通知您,并在合理的期限内删除您的个人信息或进行匿名化处理。 + +## 6. 个人信息的共享、转让、公开披露 + +我们不会向华为关联企业外的第三方共享、转让或公开披露您的个人信息,但以下情况除外: + +- **在法定情形下的共享**:我们可能会根据法律法规规定、诉讼争议解决需要,或按行政、司法机关依法提出的要求,共享您的个人信息。 + +## 7. 信息安全 + +我们采用符合业界标准的安全技术措施来保护您的个人信息,防止数据遭到未经授权的访问、公开披露、使用、修改、损坏或丢失。我们采取的数据安全措施包括但不限于: + +- **数据传输加密**:我们采用SSL/TLS等加密技术对传输中的数据进行加密保护; +- **数据存储加密**:我们对存储的个人信息进行加密处理; +- **访问控制**:我们建立了严格的数据访问权限管控和操作审计机制,确保只有授权人员才可访问个人信息。 + +## 8. 隐私保护适用范围 + +本隐私协议适用于HUAWEI BitFun主程序及其内置功能所涉及的个人信息处理活动。用户通过添加(MCP、Skills、自定义Agent、MiniApps)接入的第三方服务,由相应第三方服务提供方负责其个人信息处理活动,我们建议您仔细阅读相关第三方的隐私协议。华为对第三方的隐私保护行为不承担任何责任。 + +## 9. 如何联系我们 + +我们指定了个人信息保护负责人,您可以通过以下方式与我们取得联系: + +- 隐私事务专用邮箱:【bitfun@huawei.com】 + +如果您希望通过邮件方式联系我们就数据主体权利提出请求,请附上您在HUAWEI BitFun配置文件中的UUID,发送至我们的隐私联系邮箱,我们将在验证后尽快处理您的请求。 + +如果您对我们的回复不满意,特别是当您认为我们的个人信息处理行为损害了您的合法权益时,您还可以通过向有管辖权的人民法院提起诉讼、向行业自律协会或政府相关管理机构投诉等外部途径进行解决。您也可以向我们了解可能适用的相关投诉途径的信息。 + +华为将始终遵照我们的隐私政策来收集和使用您的信息。 + +## 10. 本隐私协议的更新 + +我们可能会适时修订本隐私协议。未经您明确同意,我们不会削减您按照本隐私协议所应享有的权利。本隐私协议的重大变更会在您重新打开应用后通知您并请求获取您的同意。 + +对于重大变更,我们还会提供更为显著的通知(包括我们会通过应用内公告的方式进行意见征集)。本隐私协议所指的重大变更包括但不限于: + +- 我们的个人信息处理模式发生重大变化; +- 我们在个人信息共享、转让或公开披露的主要对象发生变化; +- 您参与个人信息处理方面的权利及其行使方式发生重大变化; +- 我们的联络方式及投诉渠道发生变化。 diff --git a/src/crates/services/services-integrations/src/privacy/mod.rs b/src/crates/services/services-integrations/src/privacy/mod.rs new file mode 100644 index 0000000000..d7345155bb --- /dev/null +++ b/src/crates/services/services-integrations/src/privacy/mod.rs @@ -0,0 +1,582 @@ +use bitfun_product_domains::privacy::{ + AcceptPrivacyRequest, PrivacyChangeType, PrivacyConsentRecord, PrivacyEffectiveMode, + PrivacyError, PrivacyLifecycleState, PrivacyPolicyView, PrivacyStatus, +}; +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::path::PathBuf; +use std::sync::atomic::{AtomicBool, Ordering}; + +const CONSENT_VERSION: &str = "4"; +const EFFECTIVE_AT: &str = "2026-07-30T00:00:00Z"; +const POLICY_UPDATED_AT: &str = "2026-07-30T00:00:00Z"; +const CHANGE_TYPE: PrivacyChangeType = PrivacyChangeType::Editorial; +const LEGAL_CONTENT_SENTINEL: &str = "LEGAL_CONTENT_REQUIRED"; + +const ZH_CN_CONTENT: &str = include_str!("assets/zh-CN.md"); +const ZH_CN_SHA256: &str = "af8e3bff76330d3910ab2343a083922f0895d04f3e497c9cd02472cc2a5d9987"; +const ACCEPTED_POLICY_DOCUMENTS: &[(&str, &str, &str)] = + &[(POLICY_UPDATED_AT, "zh-CN", ZH_CN_SHA256)]; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +enum StoredPrivacyMode { + Full, + PrivacyNotAccepted, +} + +#[derive(Debug, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +struct PrivacyStateFile { + #[serde(default)] + mode: Option, + #[serde(default)] + consent: Option, + #[serde(default)] + viewed_policy_updated_at: Option, +} + +#[derive(Debug, Clone, Copy)] +struct BuiltinDocument { + locale: &'static str, + content: &'static str, + expected_sha256: &'static str, +} + +#[derive(Debug)] +pub struct PrivacyCollectionPolicy { + collection_allowed: AtomicBool, + #[cfg(test)] + fail_full_application: AtomicBool, +} + +impl PrivacyCollectionPolicy { + pub fn new(collection_allowed: bool) -> Self { + Self { + collection_allowed: AtomicBool::new(collection_allowed), + #[cfg(test)] + fail_full_application: AtomicBool::new(false), + } + } + + pub fn apply(&self, mode: PrivacyEffectiveMode) -> Result<(), PrivacyError> { + #[cfg(test)] + if mode == PrivacyEffectiveMode::Full && self.fail_full_application.load(Ordering::SeqCst) { + self.collection_allowed.store(false, Ordering::SeqCst); + return Err(PrivacyError::new( + "PRIVACY_POLICY_APPLY_FAILED", + "The full collection policy could not be applied", + )); + } + + self.collection_allowed + .store(mode == PrivacyEffectiveMode::Full, Ordering::SeqCst); + Ok(()) + } + + pub fn effective_mode(&self) -> PrivacyEffectiveMode { + if self.collection_allowed.load(Ordering::SeqCst) { + PrivacyEffectiveMode::Full + } else { + PrivacyEffectiveMode::PrivacyNotAccepted + } + } + + pub fn collection_allowed(&self) -> bool { + self.collection_allowed.load(Ordering::SeqCst) + } + + #[cfg(test)] + fn set_fail_full_application(&self, fail: bool) { + self.fail_full_application.store(fail, Ordering::SeqCst); + } +} + +pub struct PrivacyService { + state_path: PathBuf, + initial_locale: String, + resources_valid: bool, + release_ready: bool, +} + +impl PrivacyService { + pub fn new(storage_dir: PathBuf, initial_locale: &str) -> Self { + let documents = builtin_documents(); + let resources_valid = documents.iter().all(|document| { + sha256(document.content.as_bytes()) == document.expected_sha256 + && !document.content.trim().is_empty() + }); + let legal_content_ready = documents + .iter() + .all(|document| !document.content.contains(LEGAL_CONTENT_SENTINEL)); + Self { + state_path: storage_dir.join("privacy-state.json"), + initial_locale: normalize_locale(initial_locale).to_string(), + resources_valid, + release_ready: resources_valid && (legal_content_ready || cfg!(debug_assertions)), + } + } + + pub async fn initialize(&self, app_version: &str) -> Result { + self.status(&self.initial_locale, app_version).await + } + + pub async fn status( + &self, + locale: &str, + _app_version: &str, + ) -> Result { + if !self.resources_valid { + return Ok(PrivacyStatus { + enabled: true, + lifecycle_state: PrivacyLifecycleState::ResourceError, + effective_mode: PrivacyEffectiveMode::PrivacyNotAccepted, + release_ready: false, + has_unread_update: false, + policy: None, + consent: None, + configuration_error: Some("BUILT_IN_POLICY_INVALID".to_string()), + }); + } + + let document = self.document(locale)?; + let state = self.load_state().await; + let consent_valid = state + .consent + .as_ref() + .is_some_and(|consent| self.consent_is_valid(consent)); + let lifecycle_state = match state.mode { + Some(StoredPrivacyMode::Full) if consent_valid => PrivacyLifecycleState::Full, + Some(StoredPrivacyMode::PrivacyNotAccepted) => { + PrivacyLifecycleState::PrivacyNotAccepted + } + _ => PrivacyLifecycleState::ChoiceRequired, + }; + let effective_mode = if lifecycle_state == PrivacyLifecycleState::Full { + PrivacyEffectiveMode::Full + } else { + PrivacyEffectiveMode::PrivacyNotAccepted + }; + let has_unread_update = CHANGE_TYPE == PrivacyChangeType::Editorial + && state.mode.is_some() + && state.viewed_policy_updated_at.as_deref() != Some(POLICY_UPDATED_AT); + + Ok(PrivacyStatus { + enabled: true, + lifecycle_state, + effective_mode, + release_ready: self.release_ready, + has_unread_update, + policy: Some(policy_view(document)), + consent: state + .consent + .filter(|consent| self.consent_is_valid(consent)), + configuration_error: (!self.release_ready) + .then(|| "LEGAL_CONTENT_REQUIRED".to_string()), + }) + } + + pub async fn accept( + &self, + request: &AcceptPrivacyRequest, + app_version: &str, + ) -> Result { + if !self.release_ready { + return Err(PrivacyError::new( + "PRIVACY_RELEASE_BLOCKED", + "Bundled privacy resources are not release ready", + )); + } + let document = self.document(&request.locale)?; + let expected_hash = sha256(document.content.as_bytes()); + if request.policy_updated_at != POLICY_UPDATED_AT + || request.consent_version != CONSENT_VERSION + || request.document_sha256 != expected_hash + { + return Err(PrivacyError::new( + "PRIVACY_POLICY_MISMATCH", + "The displayed privacy policy no longer matches the bundled policy", + )); + } + + let consent = PrivacyConsentRecord { + consent_version: CONSENT_VERSION.to_string(), + accepted_policy_updated_at: POLICY_UPDATED_AT.to_string(), + accepted_document_sha256: expected_hash, + accepted_at: Utc::now().to_rfc3339(), + locale: document.locale.to_string(), + app_version: app_version.to_string(), + }; + self.store_state(&PrivacyStateFile { + mode: Some(StoredPrivacyMode::Full), + consent: Some(consent), + viewed_policy_updated_at: Some(POLICY_UPDATED_AT.to_string()), + }) + .await?; + self.status(document.locale, app_version).await + } + + pub async fn enter_not_accepted( + &self, + locale: &str, + app_version: &str, + ) -> Result { + self.store_state(&PrivacyStateFile { + mode: Some(StoredPrivacyMode::PrivacyNotAccepted), + consent: None, + viewed_policy_updated_at: Some(POLICY_UPDATED_AT.to_string()), + }) + .await?; + self.status(locale, app_version).await + } + + pub async fn mark_viewed( + &self, + policy_updated_at: &str, + app_version: &str, + locale: &str, + ) -> Result { + if policy_updated_at != POLICY_UPDATED_AT { + return Err(PrivacyError::new( + "PRIVACY_POLICY_MISMATCH", + "Policy update timestamp does not match the bundled policy", + )); + } + let mut state = self.load_state().await; + state.viewed_policy_updated_at = Some(POLICY_UPDATED_AT.to_string()); + self.store_state(&state).await?; + self.status(locale, app_version).await + } + + pub async fn can_apply_full_mode(&self) -> Result { + let status = self.status(&self.initial_locale, "policy-check").await?; + Ok(status.lifecycle_state == PrivacyLifecycleState::Full) + } + + fn document(&self, locale: &str) -> Result { + if !self.resources_valid { + return Err(PrivacyError::new( + "BUILT_IN_POLICY_INVALID", + "A bundled privacy document failed integrity validation", + )); + } + let locale = normalize_locale(locale); + builtin_documents() + .into_iter() + .find(|document| document.locale == locale) + .ok_or_else(|| { + PrivacyError::new( + "PRIVACY_LOCALE_UNAVAILABLE", + format!("Bundled privacy document is unavailable for {locale}"), + ) + }) + } + + fn consent_is_valid(&self, consent: &PrivacyConsentRecord) -> bool { + if consent.consent_version != CONSENT_VERSION + || chrono::DateTime::parse_from_rfc3339(&consent.accepted_policy_updated_at).is_err() + || consent.app_version.trim().is_empty() + || chrono::DateTime::parse_from_rfc3339(&consent.accepted_at).is_err() + || !is_sha256(&consent.accepted_document_sha256) + { + return false; + } + accepted_policy_document( + &consent.accepted_policy_updated_at, + normalize_locale(&consent.locale), + &consent.accepted_document_sha256, + ) + } + + async fn load_state(&self) -> PrivacyStateFile { + let Ok(bytes) = tokio::fs::read(&self.state_path).await else { + return PrivacyStateFile::default(); + }; + serde_json::from_slice(&bytes).unwrap_or_default() + } + + async fn store_state(&self, state: &PrivacyStateFile) -> Result<(), PrivacyError> { + let parent = self.state_path.parent().ok_or_else(|| { + PrivacyError::new("PRIVACY_STORAGE_ERROR", "Privacy storage path is invalid") + })?; + tokio::fs::create_dir_all(parent) + .await + .map_err(storage_error)?; + let body = serde_json::to_vec_pretty(state).map_err(|error| { + PrivacyError::new( + "PRIVACY_STORAGE_ERROR", + format!("Encode privacy state: {error}"), + ) + })?; + let temporary = self.state_path.with_extension("json.tmp"); + tokio::fs::write(&temporary, body) + .await + .map_err(storage_error)?; + tokio::fs::rename(&temporary, &self.state_path) + .await + .map_err(storage_error)?; + Ok(()) + } +} + +fn builtin_documents() -> [BuiltinDocument; 1] { + [BuiltinDocument { + locale: "zh-CN", + content: ZH_CN_CONTENT, + expected_sha256: ZH_CN_SHA256, + }] +} + +fn policy_view(document: BuiltinDocument) -> PrivacyPolicyView { + PrivacyPolicyView { + consent_version: CONSENT_VERSION.to_string(), + change_type: CHANGE_TYPE, + effective_at: EFFECTIVE_AT.to_string(), + updated_at: POLICY_UPDATED_AT.to_string(), + locale: document.locale.to_string(), + document_sha256: sha256(document.content.as_bytes()), + content: document.content.to_string(), + } +} + +fn normalize_locale(_locale: &str) -> &'static str { + "zh-CN" +} + +fn sha256(bytes: &[u8]) -> String { + format!("{:x}", Sha256::digest(bytes)) +} + +fn is_sha256(value: &str) -> bool { + value.len() == 64 && value.bytes().all(|byte| byte.is_ascii_hexdigit()) +} + +fn accepted_policy_document(updated_at: &str, locale: &str, document_sha256: &str) -> bool { + ACCEPTED_POLICY_DOCUMENTS + .iter() + .any(|(accepted_updated_at, accepted_locale, accepted_hash)| { + *accepted_updated_at == updated_at + && *accepted_locale == locale + && *accepted_hash == document_sha256 + }) +} + +fn storage_error(error: std::io::Error) -> PrivacyError { + PrivacyError::new( + "PRIVACY_STORAGE_ERROR", + format!("Persist privacy state: {error}"), + ) +} + +#[cfg(test)] +mod tests { + use super::{ + normalize_locale, PrivacyCollectionPolicy, PrivacyService, PrivacyStateFile, + StoredPrivacyMode, CONSENT_VERSION, POLICY_UPDATED_AT, ZH_CN_SHA256, + }; + use bitfun_product_domains::privacy::{ + AcceptPrivacyRequest, PrivacyChangeType, PrivacyConsentRecord, PrivacyEffectiveMode, + PrivacyLifecycleState, + }; + + fn temporary_directory(name: &str) -> std::path::PathBuf { + std::env::temp_dir().join(format!( + "bitfun-privacy-{name}-{}-{}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_nanos() + )) + } + + async fn accept(service: &PrivacyService) { + let initial = service.initialize("1.2.3").await.unwrap(); + let policy = initial.policy.unwrap(); + service + .accept( + &AcceptPrivacyRequest { + policy_updated_at: policy.updated_at, + consent_version: policy.consent_version, + document_sha256: policy.document_sha256, + locale: policy.locale, + }, + "1.2.3", + ) + .await + .unwrap(); + } + + fn previous_consent(consent_version: &str) -> PrivacyConsentRecord { + PrivacyConsentRecord { + consent_version: consent_version.to_string(), + accepted_policy_updated_at: "2026-07-01T00:00:00Z".to_string(), + accepted_document_sha256: + "9164815a22b2b2021039a19ed6e92556ce6ea44e42dd0103869b7c0887ae48bb".to_string(), + accepted_at: "2026-07-24T00:00:00Z".to_string(), + locale: "zh-CN".to_string(), + app_version: "1.2.3".to_string(), + } + } + + #[test] + fn resolves_every_locale_to_the_chinese_policy() { + assert_eq!(normalize_locale("zh-Hant-HK"), "zh-CN"); + assert_eq!(normalize_locale("zh-TW"), "zh-CN"); + assert_eq!(normalize_locale("zh-HK"), "zh-CN"); + assert_eq!(normalize_locale("en-GB"), "zh-CN"); + assert_eq!(normalize_locale("ja-JP"), "zh-CN"); + } + + #[tokio::test] + async fn first_start_requires_a_choice_without_blocking_local_initialization() { + let directory = temporary_directory("first-start"); + let service = PrivacyService::new(directory.clone(), "zh-TW"); + let status = service.initialize("test").await.unwrap(); + assert_eq!( + status.lifecycle_state, + PrivacyLifecycleState::ChoiceRequired + ); + assert_eq!( + status.effective_mode, + PrivacyEffectiveMode::PrivacyNotAccepted + ); + assert_eq!(status.policy.unwrap().locale, "zh-CN"); + let _ = tokio::fs::remove_dir_all(directory).await; + } + + #[tokio::test] + async fn not_accepted_survives_a_cold_start() { + let directory = temporary_directory("not-accepted"); + let service = PrivacyService::new(directory.clone(), "en-US"); + service.enter_not_accepted("en-US", "1.2.3").await.unwrap(); + let restarted = PrivacyService::new(directory.clone(), "en-US"); + assert_eq!( + restarted.initialize("1.2.3").await.unwrap().lifecycle_state, + PrivacyLifecycleState::PrivacyNotAccepted + ); + let _ = tokio::fs::remove_dir_all(directory).await; + } + + #[tokio::test] + async fn accept_persists_full_mode_atomically() { + let directory = temporary_directory("accept"); + let service = PrivacyService::new(directory.clone(), "en-US"); + accept(&service).await; + let status = PrivacyService::new(directory.clone(), "en-US") + .initialize("1.2.3") + .await + .unwrap(); + assert_eq!(status.lifecycle_state, PrivacyLifecycleState::Full); + assert_eq!(status.consent.unwrap().locale, "zh-CN"); + assert!(!directory.join("privacy-state.tmp").exists()); + let _ = tokio::fs::remove_dir_all(directory).await; + } + + #[tokio::test] + async fn chinese_policy_is_release_ready_and_editorial() { + let directory = temporary_directory("current-policy"); + let status = PrivacyService::new(directory.clone(), "en-US") + .initialize("1.2.3") + .await + .unwrap(); + let policy = status.policy.unwrap(); + + assert!(status.release_ready); + assert_eq!(policy.updated_at, POLICY_UPDATED_AT); + assert_eq!(policy.consent_version, CONSENT_VERSION); + assert_eq!(policy.change_type, PrivacyChangeType::Editorial); + assert_eq!(policy.locale, "zh-CN"); + assert_eq!(policy.document_sha256, ZH_CN_SHA256); + assert!(policy.content.starts_with("# 关于HUAWEI BitFun的隐私协议")); + let _ = tokio::fs::remove_dir_all(directory).await; + } + + #[tokio::test] + async fn timestamp_marks_editorial_update_unread_until_viewed() { + let directory = temporary_directory("editorial-update-marker"); + let service = PrivacyService::new(directory.clone(), "en-US"); + service + .store_state(&PrivacyStateFile { + mode: Some(StoredPrivacyMode::PrivacyNotAccepted), + consent: None, + viewed_policy_updated_at: Some("2026-07-01T00:00:00Z".to_string()), + }) + .await + .unwrap(); + + assert!(service.initialize("1.2.3").await.unwrap().has_unread_update); + let current = service.enter_not_accepted("en-US", "1.2.3").await.unwrap(); + assert!(!current.has_unread_update); + let _ = tokio::fs::remove_dir_all(directory).await; + } + + #[tokio::test] + async fn changed_consent_generation_requires_a_new_choice() { + let directory = temporary_directory("material-update"); + let service = PrivacyService::new(directory.clone(), "zh-CN"); + service + .store_state(&PrivacyStateFile { + mode: Some(StoredPrivacyMode::Full), + consent: Some(previous_consent("previous-generation")), + viewed_policy_updated_at: Some("2026-07-01T00:00:00Z".to_string()), + }) + .await + .unwrap(); + + let status = service.initialize("1.2.3").await.unwrap(); + assert_eq!( + status.lifecycle_state, + PrivacyLifecycleState::ChoiceRequired + ); + assert_eq!( + status.effective_mode, + PrivacyEffectiveMode::PrivacyNotAccepted + ); + let _ = tokio::fs::remove_dir_all(directory).await; + } + + #[tokio::test] + async fn new_state_persists_timestamps_without_policy_versions() { + let directory = temporary_directory("timestamp-state"); + let service = PrivacyService::new(directory.clone(), "zh-CN"); + accept(&service).await; + + let body = tokio::fs::read_to_string(directory.join("privacy-state.json")) + .await + .unwrap(); + assert!(body.contains("acceptedPolicyUpdatedAt")); + assert!(body.contains("viewedPolicyUpdatedAt")); + assert!(!body.contains("PolicyVersion")); + let _ = tokio::fs::remove_dir_all(directory).await; + } + + #[tokio::test] + async fn persistence_failure_uses_a_stable_storage_error() { + let directory = temporary_directory("storage-failure"); + tokio::fs::create_dir_all(&directory).await.unwrap(); + let blocking_file = directory.join("not-a-directory"); + tokio::fs::write(&blocking_file, b"blocked").await.unwrap(); + let service = PrivacyService::new(blocking_file.join("privacy"), "en-US"); + + let error = service + .enter_not_accepted("en-US", "1.2.3") + .await + .unwrap_err(); + assert_eq!(error.code, "PRIVACY_STORAGE_ERROR"); + let _ = tokio::fs::remove_dir_all(directory).await; + } + + #[test] + fn collection_policy_fails_closed() { + let policy = PrivacyCollectionPolicy::new(false); + policy.set_fail_full_application(true); + assert!(policy.apply(PrivacyEffectiveMode::Full).is_err()); + assert!(!policy.collection_allowed()); + policy + .apply(PrivacyEffectiveMode::PrivacyNotAccepted) + .unwrap(); + assert!(!policy.collection_allowed()); + } +} diff --git a/src/web-ui/src/app/BusinessApplication.tsx b/src/web-ui/src/app/BusinessApplication.tsx new file mode 100644 index 0000000000..413f548360 --- /dev/null +++ b/src/web-ui/src/app/BusinessApplication.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import App from './App'; +import { WorkspaceProvider } from '@/infrastructure/contexts/WorkspaceProvider'; +import { I18nProvider } from '@/infrastructure/i18n/providers/I18nProvider'; +import { PeerDeviceProvider } from '@/infrastructure/peer-device/PeerDeviceContext'; +import { PeerDirectoryPickerHost } from '@/infrastructure/peer-device/PeerDirectoryPickerHost'; +import { PeerHostInvokeBridge } from '@/infrastructure/peer-device/PeerHostInvokeBridge'; + +const BusinessApplication: React.FC = () => ( + + + + + + + + + +); + +export default BusinessApplication; diff --git a/src/web-ui/src/app/components/AboutDialog/AboutDialog.tsx b/src/web-ui/src/app/components/AboutDialog/AboutDialog.tsx index c9dd74a51e..03a243a2c2 100644 --- a/src/web-ui/src/app/components/AboutDialog/AboutDialog.tsx +++ b/src/web-ui/src/app/components/AboutDialog/AboutDialog.tsx @@ -20,6 +20,7 @@ import { isTauriRuntime } from '@/infrastructure/runtime'; import { UpdateAvailableDialog } from '@/infrastructure/update/UpdateAvailableDialog'; import { useUpdateInstallStore } from '@/infrastructure/update/updateInstallStore'; import { formatUpdateInstallError } from '@/infrastructure/update/updateErrorMessage'; +import { PrivacyStatementDialog, usePrivacy } from '../Privacy'; import './AboutDialog.scss'; import './AboutDialogLinks.scss'; import './AboutDialogOpenSource.scss'; @@ -118,7 +119,8 @@ export const AboutDialog: React.FC = ({ const updateProgress = useUpdateInstallStore(state => state.progress); const updateError = useUpdateInstallStore(state => state.error); const startUpdateInstall = useUpdateInstallStore(state => state.startInstall); - const [subDialog, setSubDialog] = useState<'openSource' | null>(null); + const [subDialog, setSubDialog] = useState<'openSource' | 'privacy' | null>(null); + const { status: privacyStatus } = usePrivacy(); const aboutInfo = getAboutInfo(); const { version, license } = aboutInfo; @@ -202,6 +204,16 @@ export const AboutDialog: React.FC = ({ } }; + const openPrivacyStatement = () => { + if (privacyStatus?.enabled && privacyStatus.policy) { + setSubDialog('privacy'); + return; + } + void systemAPI.openExternal( + 'https://agreement-drcn.hispace.dbankcloud.cn/index.html?lang=zh&agreementId=1959693293117791424', + ); + }; + return ( <> = ({ ·

{license.text}

@@ -493,6 +508,11 @@ export const AboutDialog: React.FC = ({
+ setSubDialog(null)} + /> + = ({ ); }; -export default AboutDialog; \ No newline at end of file +export default AboutDialog; diff --git a/src/web-ui/src/app/components/FeedbackDialog/FeedbackConversationView.tsx b/src/web-ui/src/app/components/FeedbackDialog/FeedbackConversationView.tsx new file mode 100644 index 0000000000..db50ebaba4 --- /dev/null +++ b/src/web-ui/src/app/components/FeedbackDialog/FeedbackConversationView.tsx @@ -0,0 +1,530 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { LockKeyhole, RefreshCw, Send } from 'lucide-react'; +import { Button, ConfirmDialog, IconButton, Textarea } from '@/component-library'; +import { usePrivacy } from '@/app/components/Privacy/PrivacyContext'; +import { PrivacyStatementDialog } from '@/app/components/Privacy/PrivacyStatementDialog'; +import { + feedbackAPI, + FeedbackApiError, + type FeedbackMessage, + type FeedbackRecordSummary, + feedbackContentLength, + truncateFeedbackContent, +} from '@/infrastructure/api'; +import { useI18n } from '@/infrastructure/i18n/hooks/useI18n'; +import { PrivacyStatementLink } from './PrivacyStatementLink'; +import { useFeedbackInboxStore } from './feedbackInboxStore'; + +interface FeedbackConversationViewProps { + record: FeedbackRecordSummary; + resetDraftVersion: number; + onInteractionStateChange: (state: { hasDraft: boolean; sending: boolean }) => void; +} + +type ReplyError = FeedbackApiError | 'PRIVACY_SAVE_FAILED' | null; + +export const FeedbackConversationView: React.FC = ({ + record, + resetDraftVersion, + onInteractionStateChange, +}) => { + const { t, formatDate } = useI18n('common'); + const { status, accept } = usePrivacy(); + const [messages, setMessages] = useState([]); + const [nextCursor, setNextCursor] = useState(); + const [hasMore, setHasMore] = useState(false); + const [loading, setLoading] = useState(true); + const [loadingEarlier, setLoadingEarlier] = useState(false); + const [refreshing, setRefreshing] = useState(false); + const [error, setError] = useState(null); + const [ackError, setAckError] = useState(false); + const [draft, setDraft] = useState(''); + const [draftTruncated, setDraftTruncated] = useState(false); + const [sending, setSending] = useState(false); + const [replyError, setReplyError] = useState(null); + const [showConsent, setShowConsent] = useState(false); + const [showPrivacy, setShowPrivacy] = useState(false); + const scrollRef = useRef(null); + const topSentinelRef = useRef(null); + const visibleAdminTimesRef = useRef(new Set()); + const lastReadThroughRef = useRef(null); + const queuedReadThroughRef = useRef(null); + const ackInFlightRef = useRef(false); + const mountedRef = useRef(true); + const recordHasNewReplyRef = useRef(record.hasNewReply); + const refreshedReconciledUnreadRef = useRef(false); + recordHasNewReplyRef.current = record.hasNewReply; + const applyServerStatus = useFeedbackInboxStore(state => state.applyServerStatus); + const markInaccessible = useFeedbackInboxStore(state => state.markInaccessible); + const refreshInbox = useFeedbackInboxStore(state => state.refresh); + const draftLength = feedbackContentLength(draft); + const canReply = Boolean(draft.trim()) && !sending && record.status !== 'resolved'; + + useEffect(() => { + onInteractionStateChange({ hasDraft: Boolean(draft.trim()), sending }); + }, [draft, onInteractionStateChange, sending]); + + useEffect(() => () => { + onInteractionStateChange({ hasDraft: false, sending: false }); + }, [onInteractionStateChange]); + + useEffect(() => { + setDraft(''); + setDraftTruncated(false); + setReplyError(null); + setShowConsent(false); + }, [resetDraftVersion]); + + const handleConversationError = useCallback((caught: unknown) => { + const normalized = caught instanceof FeedbackApiError + ? caught + : new FeedbackApiError('SERVICE_UNAVAILABLE', 'Feedback service is unavailable', true); + if (isConversationAccessError(normalized.code)) { + markInaccessible(record.feedbackId); + return; + } + setError(normalized); + }, [markInaccessible, record.feedbackId]); + + const loadLatest = useCallback(async (manual: boolean) => { + if (manual) setRefreshing(true); + else setLoading(true); + setError(null); + try { + const page = await feedbackAPI.openConversation({ feedbackId: record.feedbackId }); + if (!mountedRef.current) return; + setMessages(current => manual ? mergeMessages(current, page.messages) : page.messages); + setNextCursor(page.nextCursor); + setHasMore(page.hasMore); + setError(page.syncError ?? null); + if ( + recordHasNewReplyRef.current + && !page.syncError + && !refreshedReconciledUnreadRef.current + ) { + refreshedReconciledUnreadRef.current = true; + void refreshInbox(true); + } + if (!manual) { + requestAnimationFrame(() => { + const container = scrollRef.current; + if (container) container.scrollTop = container.scrollHeight; + }); + } + } catch (caught) { + if (mountedRef.current) handleConversationError(caught); + } finally { + if (mountedRef.current) { + setLoading(false); + setRefreshing(false); + } + } + }, [handleConversationError, record.feedbackId, refreshInbox]); + + const loadEarlier = useCallback(async () => { + const container = scrollRef.current; + if (!container || !hasMore || !nextCursor || loadingEarlier || refreshing) return; + const previousHeight = container.scrollHeight; + const previousTop = container.scrollTop; + setLoadingEarlier(true); + setError(null); + try { + const page = await feedbackAPI.openConversation({ + feedbackId: record.feedbackId, + cursor: nextCursor, + }); + if (!mountedRef.current) return; + setMessages(current => mergeMessages(page.messages, current)); + setNextCursor(page.nextCursor); + setHasMore(page.hasMore); + requestAnimationFrame(() => { + const current = scrollRef.current; + if (current) current.scrollTop = previousTop + current.scrollHeight - previousHeight; + }); + } catch (caught) { + if (mountedRef.current) handleConversationError(caught); + } finally { + if (mountedRef.current) setLoadingEarlier(false); + } + }, [handleConversationError, hasMore, loadingEarlier, nextCursor, record.feedbackId, refreshing]); + + const flushReadAcknowledgement = useCallback(async () => { + if (ackInFlightRef.current || document.visibilityState !== 'visible') return; + ackInFlightRef.current = true; + try { + while (queuedReadThroughRef.current) { + const requested = queuedReadThroughRef.current; + queuedReadThroughRef.current = null; + try { + const result = await feedbackAPI.acknowledgeFeedback(record.feedbackId, requested); + if (!mountedRef.current) return; + lastReadThroughRef.current = laterTimestamp( + lastReadThroughRef.current, + result.readThrough, + ); + applyServerStatus(record.feedbackId, result.feedbackStatus); + setAckError(false); + await refreshInbox(true); + } catch (caught) { + if (!mountedRef.current) return; + const normalized = caught instanceof FeedbackApiError ? caught : null; + if (normalized && isConversationAccessError(normalized.code)) { + markInaccessible(record.feedbackId); + } else { + setAckError(true); + } + queuedReadThroughRef.current = null; + break; + } + } + } finally { + ackInFlightRef.current = false; + } + }, [applyServerStatus, markInaccessible, record.feedbackId, refreshInbox]); + + const queueVisibleAcknowledgement = useCallback(() => { + if (document.visibilityState !== 'visible') return; + const latestVisible = [...visibleAdminTimesRef.current].reduce( + (latest, value) => laterTimestamp(latest, value), + null, + ); + if (!latestVisible || !isLaterTimestamp(latestVisible, lastReadThroughRef.current)) return; + queuedReadThroughRef.current = laterTimestamp( + queuedReadThroughRef.current, + latestVisible, + ); + void flushReadAcknowledgement(); + }, [flushReadAcknowledgement]); + + const executeReply = useCallback(async (content: string) => { + try { + const result = await feedbackAPI.replyFeedback(record.feedbackId, content); + if (!mountedRef.current) return; + setMessages(current => mergeMessages(current, [result.message])); + setDraft(''); + setDraftTruncated(false); + setReplyError(null); + applyServerStatus(record.feedbackId, result.feedbackStatus); + requestAnimationFrame(() => { + const container = scrollRef.current; + if (container) container.scrollTop = container.scrollHeight; + }); + } catch (caught) { + if (!mountedRef.current) return; + const normalized = caught instanceof FeedbackApiError + ? caught + : new FeedbackApiError('SERVICE_UNAVAILABLE', 'Feedback service is unavailable', true); + if (isConversationAccessError(normalized.code)) { + markInaccessible(record.feedbackId); + } + setReplyError(normalized); + } + }, [applyServerStatus, markInaccessible, record.feedbackId]); + + const sendReply = useCallback(async () => { + const content = draft.trim(); + if (!content || sending || record.status === 'resolved') return; + setSending(true); + setReplyError(null); + await executeReply(content); + if (mountedRef.current) setSending(false); + }, [draft, executeReply, record.status, sending]); + + const requestReply = (event: React.FormEvent) => { + event.preventDefault(); + if (!canReply) return; + if (status?.effectiveMode !== 'full') { + setShowConsent(true); + return; + } + void sendReply(); + }; + + const acceptAndSend = async () => { + const content = draft.trim(); + if (!content || sending || !status?.policy) return; + setSending(true); + setReplyError(null); + try { + await accept({ + policyUpdatedAt: status.policy.updatedAt, + consentVersion: status.policy.consentVersion, + documentSha256: status.policy.documentSha256, + locale: status.policy.locale, + }); + } catch { + if (mountedRef.current) { + setReplyError('PRIVACY_SAVE_FAILED'); + setSending(false); + } + return; + } + if (mountedRef.current) setShowConsent(false); + await executeReply(content); + if (mountedRef.current) setSending(false); + }; + + const handleDraftChange = (value: string) => { + const truncated = truncateFeedbackContent(value); + setDraftTruncated(truncated !== value); + setDraft(truncated); + setReplyError(null); + }; + + useEffect(() => { + mountedRef.current = true; + setMessages([]); + setNextCursor(undefined); + setHasMore(false); + setError(null); + setAckError(false); + visibleAdminTimesRef.current.clear(); + lastReadThroughRef.current = null; + queuedReadThroughRef.current = null; + refreshedReconciledUnreadRef.current = false; + void loadLatest(false); + return () => { + mountedRef.current = false; + }; + }, [loadLatest, record.feedbackId]); + + useEffect(() => { + const root = scrollRef.current; + const sentinel = topSentinelRef.current; + if (!root || !sentinel || !hasMore) return; + const observer = new IntersectionObserver(entries => { + if (entries.some(entry => entry.isIntersecting)) void loadEarlier(); + }, { root, threshold: 0.1 }); + observer.observe(sentinel); + return () => observer.disconnect(); + }, [hasMore, loadEarlier, messages.length]); + + useEffect(() => { + const root = scrollRef.current; + if (!root) return; + const elements = Array.from( + root.querySelectorAll('[data-admin-created-at]'), + ); + const observer = new IntersectionObserver(entries => { + for (const entry of entries) { + const createdAt = (entry.target as HTMLElement).dataset.adminCreatedAt; + if (!createdAt) continue; + if (entry.isIntersecting) visibleAdminTimesRef.current.add(createdAt); + else visibleAdminTimesRef.current.delete(createdAt); + } + queueVisibleAcknowledgement(); + }, { root, threshold: 0.6 }); + elements.forEach(element => observer.observe(element)); + const onVisibilityChange = () => queueVisibleAcknowledgement(); + document.addEventListener('visibilitychange', onVisibilityChange); + return () => { + observer.disconnect(); + document.removeEventListener('visibilitychange', onVisibilityChange); + }; + }, [messages, queueVisibleAcknowledgement]); + + return ( +
+
+ {t('feedback.conversation.messages')} + void loadLatest(true)} + > + +
+
+ + {record.status === 'resolved' ? ( +
+
+ ) : ( +
+