Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,16 @@ USD_CNY_RATE=7.2
# balance may not cover a first job and recommends an own model. 50 CNY default.
PLATFORM_QUOTA_COMFORT_CNY=50

# Feedback triage: route new feedback to the server FlyBot's Fledge Lead.
# Prefer a repo Fledge already manages:
FLEDGE_LEAD_REPO_ID=
# Or target an externally managed FlyBot bridge:
FLEDGE_LEAD_BRIDGE_URL=
FLEDGE_LEAD_BRIDGE_TOKEN=
FLEDGE_LEAD_SESSION_ID=
# The sanitized public issue target the Lead may write to.
FEEDBACK_PUBLIC_REPO=HugoXK/fledge

# ── Service directory (console links) ─────────────────────────────────────
# Point these at your own deployments if you self-host the sibling services.
PORTAL_URL=https://portal.hugoxk.online
Expand Down
1 change: 1 addition & 0 deletions changelog.d/814-feedback-lead.added.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Feedback and unexpected-error reports now go to the server FlyBot's Fledge Lead through a repo already connected to Fledge (`FLEDGE_LEAD_REPO_ID`) or a direct bridge (`FLEDGE_LEAD_BRIDGE_URL/TOKEN/SESSION_ID`). A per-ref signed triage token, expiring after seven days, with a bounded five-attempt delivery outbox, lets the Lead read the redacted record and write a user-facing decision back through the triage API; statuses carry their required message/issue/PR/version fields and a retry is a no-op; the reporter gets an in-app and email update for a user problem, a duplicate, a confirmed issue, a fix with its issue/PR and version, and a final “now live” notice when the running version catches up. The Lead gets a ready-to-use sanitized issue draft; a public GitHub issue may @ the reporter only after a separate sanitized-publication consent, and no raw message, stack, email or token is published by Fledge. Settings also lists the reporter's own feedback status, links and fixed/released version.
13 changes: 13 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,19 @@ name, redacted message, redacted stack, version, commit, the signed-in login and
the consent version/time — never the query, body, headers, cookies or a token.
Both routes carry a route-level limiter, and a 4xx never offers the report button.

The feedback lifecycle is one triage row per ref (`feedback_triage`) plus
`feedback_events`. New reports are sent to the server FlyBot's Lead through a
connected repo (`FLEDGE_LEAD_REPO_ID`) or a direct bridge; the packet carries a
per-ref signed token, not the raw stack. The Lead reads and writes through
`GET`/`POST /api/feedback/:ref` and `/triage`; the read response carries a
sanitized issue draft, every transition appends an event, and the signed-in
reporter sees in-app/mail updates plus a Settings history row. Delivery is a
bounded outbox: a failure is retried with backoff up to five attempts and the
row records the last error/next attempt; a delivered row is never resent. A `fixed_version` at or
below the running `VERSION` sends the fixed notice once; the startup scan then
marks the record `released` and sends the “now live” notice once. Fledge never
creates the public issue: the Lead does, sanitized, and records its URL.

The dashboard's Repositories card owns the full content row; its Add repository
button sits in the card header and opens a native `<dialog>` with the GitHub App
and fine-grained token options. `#add-repo` remains the deep link used by the
Expand Down
3 changes: 3 additions & 0 deletions docs/deploy.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ Key settings:
| `SMTP_HOST/PORT/SECURE/USER/PASS` | For yeah.net: `smtp.yeah.net`, 465, secure |
| `MAIL_FROM` / `ADMIN_EMAIL` | `fledge@hugoxk.online` / `admin@example.com` |
| `FEEDBACK_EMAIL` | Optional feedback/error-report destination; defaults to `ADMIN_EMAIL` |
| `FLEDGE_LEAD_REPO_ID` | Repo connected to Fledge whose FlyBot Lead triages feedback |
| `FLEDGE_LEAD_BRIDGE_URL/TOKEN/SESSION_ID` | Direct bridge target when the Fledge Lead is external |
| `FEEDBACK_PUBLIC_REPO` | Sanitized public issue target; defaults to `HugoXK/fledge` |
| `PLATFORM_LLM_BASE_URL` | OpenAI-compatible `/v1` endpoint |
| `PLATFORM_LLM_KEY` | Platform key; never commit it |
| `INITIAL_QUOTA_CNY` | Default 1 |
Expand Down
1 change: 1 addition & 0 deletions docs/secrets.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ predates this inventory — it is not a value.
| `SMTP_PASS` | approval and digest mail | production `.env` | provider's send-only SMTP credential | owner | unknown | mint a new SMTP credential at the provider | `npm run mail:outbox` on a development box, or one approval mail | unknown |
| `PLATFORM_LLM_KEY` | platform-funded model calls | production `.env` | one OpenAI-compatible key with a spend cap | owner | unknown | mint at the provider, then replace and restart | one platform-funded job reaches the gateway | unknown |
| `FLYBOT_BRIDGE_TOKEN` | shared secret on the optional global bridge | production `.env` and the bridge host | random 32 bytes | owner | unknown | `openssl rand -hex 32` in both places | one mock job round-trips a callback | unknown |
| `FLEDGE_LEAD_BRIDGE_TOKEN` | bearer token for the external Fledge Lead triage bridge | production `.env` and the bridge host | random 32 bytes | owner | unknown | `openssl rand -hex 32` in both places | one feedback packet reaches the configured Lead session | unknown |
| `GITHUB_APP_PRIVATE_KEY_PATH` | GitHub App authentication | file on the host, path in `.env` | App private key | owner | unknown | regenerate the key in the App settings and replace the file | install the App on a repository and list its repositories | unknown |
| `GITHUB_APP_WEBHOOK_SECRET` | verifies App webhooks | production `.env` | random 32 bytes | owner | unknown | `openssl rand -hex 32` in both the App and `.env` | redeliver one webhook and confirm it is accepted | unknown |
| `INBOUND_EMAIL_SECRET` | shared secret with the inbound Email Worker | production `.env` and the worker | random 32 bytes | owner | unknown | `openssl rand -hex 32` in both places | reply to one digest mail and confirm it lands | unknown |
Expand Down
18 changes: 18 additions & 0 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -188,6 +188,24 @@ rate-limited by client address, and a second click does not send a second mail.
Without the consent value the row stays `captured` and nothing is mailed. A 4xx
refusal is user-caused and never offers the button.

### Feedback triage

Each feedback/error record gets a per-ref triage token derived from
`HMAC(SESSION_SECRET, "feedback-triage:<ref>")`. The token is delivered only in
the private packet to the Fledge Lead; the read and write endpoints accept it as
a bearer capability for that one ref, it expires after seven days, and both are
rate-limited. Each status has required fields (a user message, an issue URL, a
pull URL, a fixed version), public links must be issues/pulls in
`FEEDBACK_PUBLIC_REPO`, and a replay of the same decision is a no-op. The Lead may read the
redacted message/stack and write a conclusion, but Fledge does not publish raw
feedback or error data. A public GitHub issue is created by the Lead only after a
separate sanitized-publication consent, and the issue may @ the reporter only
when that consent is present. The triage read returns a ready-to-use sanitized
issue draft built from `public_summary`; raw message/stack never enters it. The triage row stores the issue/PR URLs, fixed and
released versions, user-facing message, public summary, notification state and
the bounded Lead-delivery attempts;
the raw text remains in the feedback/error row.

### CSRF

Every state-changing browser form includes a hidden `csrf` field derived from
Expand Down
102 changes: 100 additions & 2 deletions src/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,9 @@ import { dedupeRepoNames, instanceLimitRefusal, repoLimitRefusal, type LimitRefu
import { isTrialFilter } from './lib/trials.js'
import { createMailer, type Mailer } from './email/mailer.js'
import { approvedEmail, errorReportMail, feedbackMail, rejectedEmail, testEmail } from './email/templates.js'
import { attemptLeadDelivery, publicIssueDraft, retryPendingLeadDeliveries, verifyTriageToken } from './feedback/lead.js'
import { triageProblems } from './feedback/lifecycle.js'
import { notifyFeedbackReporter, notifyReleasedFeedback } from './feedback/notify.js'
import { notifyEmail } from './email/recipient.js'
import { authenticateApiToken } from './lib/apiToken.js'
import { registerGateway } from './gateway/gateway.js'
Expand Down Expand Up @@ -3937,6 +3940,7 @@ export async function buildApp(options: BuildOptions): Promise<BuiltApp> {
sessions: store.listSessions(viewer.user.id),
currentSessionHash: viewer.sessionHash,
providers: store.listModelProviders(viewer.user.id).map((provider) => ({ ...provider, models: store.listProviderModels(provider.id) })),
feedback: store.listFeedbackTriageForUser(viewer.user.id, 10),
discoveredProvider: queryText(request, 'discoveredProvider'),
discovered: (queryText(request, 'discovered') ?? '').split(',').filter((value) => value !== ''),
notice: request.flash?.notice?.text,
Expand Down Expand Up @@ -4154,7 +4158,10 @@ export async function buildApp(options: BuildOptions): Promise<BuiltApp> {
if (message === '') return reply.redirect(`/settings?error=${encodeURIComponent('请填写反馈内容。')}#feedback`)
const id = randomId('fb')
const createdAt = new Date().toISOString()
const publishConsent = body.publishConsent === '1'
store.createFeedback({ id, userId: viewer.user.id, kind, message, page, createdAt })
const triage = store.createFeedbackTriage({ ref: id, userId: viewer.user.id, userLogin: viewer.user.login, kind, publishConsent, createdAt })
void attemptLeadDelivery({ store, config, instances, triage, feedback: store.feedbackById(id) }).catch((error) => log.warn('feedback lead delivery failed', { ref: id, error: redact(String(error)) }))
store.audit(viewer.user.id, 'feedback.created', `${id} kind=${kind} chars=${message.length}`)
const replyTo = notifyEmail(viewer.user)
try {
Expand Down Expand Up @@ -4184,12 +4191,23 @@ export async function buildApp(options: BuildOptions): Promise<BuiltApp> {
if (report === undefined) {
return htmlPage(reply, 404, errorPage(config, viewer, '页面不存在', '这个报告编号不存在。', request.locale))
}
if (form(request).consent !== '1') {
const reportBody = form(request)
if (reportBody.consent !== '1') {
return htmlPage(reply, 400, errorPage(config, viewer, '需要授权', '请先勾选“我已知晓并授权 Fledge 平台读取这些失败数据”,再提交报告。', request.locale))
}
if (!store.claimErrorReport(ref, ERROR_REPORT_CONSENT_VERSION)) {
return htmlPage(reply, 200, errorPage(config, viewer, '报告已发送', `这个报告(${ref})已经发送过了,谢谢。`, request.locale))
}
const publishConsent = request.viewer !== undefined && reportBody.publish === '1'
const triage = store.createFeedbackTriage({
ref,
userId: report.user_id,
userLogin: report.user_login,
kind: 'error',
publishConsent,
createdAt: report.created_at,
})
void attemptLeadDelivery({ store, config, instances, triage, error: report }).catch((error) => log.warn('error report lead delivery failed', { ref, error: redact(String(error)) }))
try {
await mailer.send({
to: config.mail.feedbackEmail,
Expand All @@ -4216,6 +4234,76 @@ export async function buildApp(options: BuildOptions): Promise<BuiltApp> {
return htmlPage(reply, 200, errorPage(config, viewer, '报告已发送', `谢谢。编号:${ref}。开发者会查看这个错误的类型、页面和时间;报告不包含你的输入、token 或请求体。`, request.locale))
})

const triageAuthorized = (request: FastifyRequest, ref: string): boolean => {
const header = String(request.headers.authorization ?? '')
const token = header.toLowerCase().startsWith('bearer ') ? header.slice(7).trim() : ''
return verifyTriageToken(config.sessionSecret, ref, token)
}

app.get('/api/feedback/:ref', async (request, reply) => {
if (!apiLimiter.take(`feedback-triage:${rateLimitKey(request)}`)) return reply.code(429).send({ error: { type: 'rate_limited', message: 'Too many requests' } })
const ref = String((request.params as Record<string, string>).ref ?? '')
if (!triageAuthorized(request, ref)) return reply.code(401).send({ error: { type: 'invalid_triage_token', message: 'Missing or invalid triage token' } })
const triage = store.feedbackTriageByRef(ref)
if (triage === undefined) return reply.code(404).send({ error: { type: 'not_found', message: 'No such feedback reference' } })
return {
triage,
feedback: store.feedbackById(ref) ?? null,
error: store.errorReportByRef(ref) ?? null,
events: store.listFeedbackEvents(ref),
issueDraft: publicIssueDraft(triage),
}
})

app.post('/api/feedback/:ref/triage', async (request, reply) => {
if (!apiLimiter.take(`feedback-triage:${rateLimitKey(request)}`)) return reply.code(429).send({ error: { type: 'rate_limited', message: 'Too many requests' } })
const ref = String((request.params as Record<string, string>).ref ?? '')
if (!triageAuthorized(request, ref)) return reply.code(401).send({ error: { type: 'invalid_triage_token', message: 'Missing or invalid triage token' } })
const triage = store.feedbackTriageByRef(ref)
if (triage === undefined) return reply.code(404).send({ error: { type: 'not_found', message: 'No such feedback reference' } })
const body = (request.body ?? {}) as Record<string, unknown>
const status = typeof body.status === 'string' ? body.status.trim() : ''
const userMessage = redact(String(body.user_message ?? '')).trim().slice(0, 1000)
const publicSummary = redact(String(body.public_summary ?? '')).trim().slice(0, 1000)
const issueUrl = String(body.issue_url ?? '').trim()
const prUrl = String(body.pr_url ?? '').trim()
const fixedVersion = String(body.fixed_version ?? '').trim()
const problems = triageProblems({ status, userMessage, publicSummary, issueUrl, prUrl, fixedVersion, publicRepo: config.feedback.publicRepo })
if (problems.length > 0) return reply.code(400).send({ error: { type: 'invalid_triage', message: problems.join('; ') } })
if (fixedVersion !== '' && parseVersion(fixedVersion) === null) return reply.code(400).send({ error: { type: 'invalid_version', message: 'fixed_version must be SemVer' } })
const unchanged = triage.status === status &&
(triage.user_message ?? '') === userMessage &&
(triage.public_summary ?? '') === publicSummary &&
(triage.issue_url ?? '') === issueUrl &&
(triage.pr_url ?? '') === prUrl &&
(triage.fixed_version ?? '') === fixedVersion
if (unchanged) {
return { ok: true, status, unchanged: true, issueDraft: publicIssueDraft(triage) }
}
store.updateFeedbackTriage(ref, {
status,
user_message: userMessage === '' ? null : userMessage,
public_summary: publicSummary === '' ? null : publicSummary,
issue_url: issueUrl === '' ? null : issueUrl,
pr_url: prUrl === '' ? null : prUrl,
fixed_version: fixedVersion === '' ? null : fixedVersion,
})
store.addFeedbackEvent(ref, status, userMessage.slice(0, 300), 'fledge-lead')
store.audit(triage.user_id, 'feedback.triaged', `${ref} ${status}`)
await notifyFeedbackReporter({
store,
mailer,
ref,
userId: triage.user_id,
status,
userMessage,
issueUrl: issueUrl === '' ? null : issueUrl,
prUrl: prUrl === '' ? null : prUrl,
})
const updated = store.feedbackTriageByRef(ref)
return { ok: true, status, issueDraft: updated === undefined ? null : publicIssueDraft(updated) }
})

app.post('/settings/tokens', { config: { rateLimit: destructiveLimit } }, async (request, reply) => {
const viewer = approvedCsrfGuard(request)
if (requireRecentAuth(request, reply, viewer, 'token.create')) return reply
Expand Down Expand Up @@ -5575,14 +5663,24 @@ export async function buildApp(options: BuildOptions): Promise<BuiltApp> {
return htmlPage(reply, status, errorPage(config, request.viewer === undefined ? null : viewOf(request.viewer), status === 404 ? '页面不存在' : '出错了', status >= 500 ? '服务器出了点问题,请稍后再试。' : message, request.locale, undefined, report))
})

let retryingLead = false
const sweeper = setInterval(() => {
limits.sweep()
apiLimiter.sweep()
store.deleteExpiredSessions()
if (options.startScheduler === false || retryingLead) return
retryingLead = true
void retryPendingLeadDeliveries({ store, config, instances })
.catch((error) => log.warn('feedback lead retry failed', { error: redact(String(error)) }))
.finally(() => { retryingLead = false })
}, 60_000)
sweeper.unref()

if (options.startScheduler !== false) jobs.start()
if (options.startScheduler !== false) {
jobs.start()
void notifyReleasedFeedback({ store, mailer }).catch((error) => log.warn('released feedback notification failed', { error: redact(String(error)) }))
void retryPendingLeadDeliveries({ store, config, instances }).catch((error) => log.warn('feedback lead retry failed', { error: redact(String(error)) }))
}

return {
app,
Expand Down
17 changes: 17 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,16 @@ export interface Config {
/** Below this remaining balance the first-run checklist recommends an own model. */
comfortCny: number
}
// #814: the server-deployed FlyBot's Fledge Lead. `leadRepoId` uses a repo
// Fledge already manages; the bridge trio targets an externally managed
// instance. `publicRepo` is the sanitized issue target the Lead may write to.
feedback: {
leadRepoId: string
leadBridgeUrl: string
leadBridgeToken: string
leadSessionId: string
publicRepo: string
}
runner: {
mode: RunnerMode
bridgeUrl: string
Expand Down Expand Up @@ -344,6 +354,13 @@ export function loadConfig(overrides: Partial<Config> = {}): Config {
usdCny: number('USD_CNY_RATE', 7.2),
comfortCny: number('PLATFORM_QUOTA_COMFORT_CNY', 50),
},
feedback: {
leadRepoId: text('FLEDGE_LEAD_REPO_ID'),
leadBridgeUrl: text('FLEDGE_LEAD_BRIDGE_URL').replace(/\/$/, ''),
leadBridgeToken: text('FLEDGE_LEAD_BRIDGE_TOKEN'),
leadSessionId: text('FLEDGE_LEAD_SESSION_ID'),
publicRepo: text('FEEDBACK_PUBLIC_REPO', 'HugoXK/fledge'),
},
runner: {
mode: (text('RUNNER_MODE', 'mock') as RunnerMode) || 'mock',
bridgeUrl: text('FLYBOT_BRIDGE_URL', 'http://127.0.0.1:8787/run'),
Expand Down
Loading
Loading