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
6 changes: 6 additions & 0 deletions .dev.vars.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Copy this file to .dev.vars. Wrangler reads it during local development.
# Do not commit .dev.vars.
LINEAR_API_KEY=
# Cloudflare's public test keys always pass and work only for local testing.
TURNSTILE_SITE_KEY=1x00000000000000000000AA
TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA
61 changes: 61 additions & 0 deletions FEEDBACK.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
# Feedback deployment

Desktop branch: `feat/in-app-feedback`. Website branch: `feat/feedback-site`.
Deploy the website before releasing the desktop buttons.

## Configure

1. Create a Cloudflare Turnstile widget for `qrate.dvnl.work`.
2. Set Worker variable `TURNSTILE_SITE_KEY` to its public site key.
3. Run `bunx wrangler secret put TURNSTILE_SECRET_KEY`.
4. Run `bunx wrangler secret put LINEAR_API_KEY`.

The Linear key needs read access, issue creation and file upload access for Grass Labs.
Never put either secret in source files, command arguments, or the browser.
Routing IDs live in `src/lib/feedback.ts`. New issues go to qrate / Beta Intake & Stabilization / Backlog
with one category label, the concrete desktop platform when available, and Logs Attached when applicable.

## Test locally

1. Copy `.dev.vars.example` to `.dev.vars`.
2. Put the Linear key in `.dev.vars`.
3. Keep Cloudflare's public test Turnstile keys from the example file.
4. Run `bun install`.
5. Run `bun run dev`.
6. Open `http://localhost:4321/feedback`.
7. In another checkout on `feat/in-app-feedback`, run `cargo run`.

Debug qrate builds open the local form. Release builds open `https://qrate.dvnl.work/feedback`.
The repository ignores `.dev.vars`. Stop the local server before switching site branches.
Cloudflare test keys work on local loopback addresses only. Production requires a real Turnstile widget.

## Verify before deployment

- Run `bun test src/lib/feedback.test.ts`.
- Run `bun --bun run build`.
- Run Wrangler preview with local secrets supplied through the Cloudflare-supported local environment.
- Verify Turnstile hostname and action checks using the preview's hostname.
- Submit a synthetic report with no private data. Verify routing and labels in Linear.
- Submit a text attachment and screenshot. Verify private attachment access and Logs Attached.
- Simulate a lost response and retry the same UUID. Verify only one issue exists.
- Confirm no analytics beacon appears on `/feedback`.
- Confirm the browser clears the fragment immediately and diagnostics are editable.
- Test the title-bar button in packaged Windows, macOS, and Linux builds.

## Operations and limits

Five attempts per minute per Cloudflare location/IP. Workplace users may share an IP.
Three user attachments, up to 5 MiB each. PNG/JPEG screenshots and TXT/LOG logs only.
The optional automatic session log is shown inline in Linear up to 64 KiB. Larger logs become
gzip attachments in browsers that support compression and have a separate 512 KiB limit.
No automatic crash uploads, session recording, screenshot capture, or full project upload.
The report UUID also serves as Linear issue UUID for retry deduplication. Retries require a new Turnstile token.
Uploads that succeed before a later provider failure can leave orphaned Linear assets.
No private report text is logged by the handler.

The public privacy policy describes purpose-based retention, not automatic timed deletion.
Confirm the team's retention/deletion process and Linear team visibility before launch.
Configure the Feedback Dashboard manually using project, milestone, active states and Feedback OR Needs Triage labels.
Remove Needs Triage when reviewed. Apply Reproducible only after verification.

Deployment and live provider verification are separate operator steps. Unit tests use mocked providers, not actual tickets.
3 changes: 2 additions & 1 deletion src/integrations/strip-html-links.mjs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { readdir, readFile, writeFile } from 'node:fs/promises';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';

/**
* Drop the trailing slash from the URLs in the MCP corpus.
Expand Down Expand Up @@ -53,7 +54,7 @@ export default function stripHtmlLinks() {
name: 'qrate:strip-html-links',
hooks: {
'astro:build:done': async ({ dir, logger }) => {
const root = new URL(dir).pathname.replace(/^\/([A-Za-z]:)/, '$1');
const root = fileURLToPath(dir);

const walk = async (p) => {
const out = [];
Expand Down
5 changes: 4 additions & 1 deletion src/layouts/Base.astro
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ const {
current = '',
version = '',
noindex = false,
noReferrer = false,
analytics = true,
} = Astro.props;

// Absolute URLs: scrapers do not resolve relative ones. `build.format: 'file'`
Expand All @@ -25,7 +27,7 @@ const ogImage = new URL('/og.png', Astro.site);
// Cloudflare Web Analytics: cookieless, no cross-site identifier, and it only
// exists when CF_BEACON_TOKEN is set at build time. The docs pages get the same
// tag from astro.config.mjs, because Starlight does not render this layout.
const beacon = import.meta.env.CF_BEACON_TOKEN;
const beacon = analytics ? import.meta.env.CF_BEACON_TOKEN : undefined;
---

<!doctype html>
Expand All @@ -39,6 +41,7 @@ const beacon = import.meta.env.CF_BEACON_TOKEN;
<meta name="description" content={description} />
<link rel="canonical" href={canonical} />
{noindex && <meta name="robots" content="noindex" />}
{noReferrer && <meta name="referrer" content="no-referrer" />}

<meta property="og:type" content="website" />
<meta property="og:site_name" content="qrate" />
Expand Down
165 changes: 165 additions & 0 deletions src/lib/feedback.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,165 @@
import { test, expect } from 'bun:test';
import { categories, platforms, submit, validate } from './feedback';

function form() {
const data = new FormData();
for (const [key, value] of Object.entries({
id: 'd721d1ca-6213-4bda-8928-21ca37671d96', category: 'bug', summary: 'Example',
description: 'Steps and expected result', consent: 'yes', 'cf-turnstile-response': 'test',
})) data.set(key, value);
return data;
}
const env = {
LINEAR_API_KEY: 'test-only', TURNSTILE_SECRET_KEY: 'test-only',
FEEDBACK_LIMITER: { limit: async () => ({ success: true }) },
};
const request = (data = form(), url = 'https://qrate.dvnl.work/api/feedback') => new Request(url, {
method: 'POST', headers: { Origin: 'https://qrate.dvnl.work' }, body: data,
});

test('all six categories validate; unknown fields, category and missing consent fail', () => {
for (const category of Object.keys(categories)) {
const data = form(); data.set('category', category);
expect(validate(data).category).toBe(category);
}
for (const [key, value] of [['category', '__proto__'], ['consent', ''], ['unexpected', 'x']]) {
const data = form(); data.set(key, value);
expect(() => validate(data)).toThrow();
}
});

test('reject executable and oversized attachments', () => {
const data = form();
data.append('files', new File(['x'], 'secret.exe'));
expect(() => validate(data)).toThrow();
data.set('files', new File([new Uint8Array(5 * 1024 * 1024 + 1)], 'large.log'));
expect(() => validate(data)).toThrow();
data.set('files', 'not a file');
expect(() => validate(data)).toThrow('Invalid files');
data.set('files', new File([], 'empty.exe'));
expect(() => validate(data)).toThrow();
data.set('files', new File([], 'empty.log'));
expect(validate(data).files).toHaveLength(1);
});

test('automatic compressed log does not consume a user attachment slot', () => {
const data = form();
data.append('files', '');
for (let index = 0; index < 3; index++) {
data.append('files', new File(['image'], `screenshot-${index}.png`));
}
data.append('files', new File(['gzip'], 'qrate-session.log.gz'));
expect(validate(data).files).toHaveLength(4);
});

test('no provider calls for invalid input or unconfigured service', async () => {
const never = async () => { throw new Error('Unexpected network call'); };
expect((await submit(request(), {}, never as any)).status).toBe(503);
const data = form(); data.delete('consent');
expect((await submit(request(data), env, never as any)).status).toBe(400);
});

test('create issue with exact routing and triage labels', async () => {
let input: any;
const data = form();
data.set('platform', 'windows');
data.set('logs', '15:00 [ERROR] example');
const mocked = async (url: string, options: any) => {
if (url.includes('siteverify')) return Response.json({ success: true, hostname: 'qrate.dvnl.work', action: 'feedback' });
const body = JSON.parse(options.body);
if (body.query.startsWith('query')) return Response.json({ data: { issues: { nodes: [] } } });
input = body.variables.input;
return Response.json({ data: { issueCreate: { success: true, issue: { id: input.id, identifier: 'TSGB-42' } } } });
};
const response = await submit(request(data), env, mocked as any);
expect(response.status).toBe(200);
expect(await response.json()).toEqual({
receipt: 'd721d1ca-6213-4bda-8928-21ca37671d96',
ticket: 'TSGB-42',
});
expect(input.stateId).toBe('4b7df4af-a498-4656-8d0a-c87e1c1d28aa');
expect(input.projectMilestoneId).toBe('7cdb86ec-c9a9-4d58-b46b-2e382566f1f5');
expect(input.labelIds).toEqual([
categories.bug,
platforms.windows,
'0429eef3-39f3-4f67-a303-a9f985122a61',
]);
expect(input.labelIds).toContain('0429eef3-39f3-4f67-a303-a9f985122a61');
expect(input.description).toContain('\n\n---\n\n');
expect(input.description).toContain('```text\n15:00 [ERROR] example\n```');
expect(input.description).not.toContain('Category:');
});

test('Turnstile wrong hostname fails before Linear', async () => {
const mocked = async () => Response.json({ success: true, hostname: 'attacker.example', action: 'feedback' });
expect((await submit(request(), env, mocked as any)).status).toBe(403);
});

test('Cloudflare test keys work only on a local form', async () => {
const mocked = async (_url: string, options: any) => {
if (options.body instanceof URLSearchParams) {
return Response.json({
success: true,
hostname: 'example.com',
metadata: { result_with_testing_key: true },
});
}
return Response.json({ data: { issues: { nodes: [{ id: 'existing' }] } } });
};
const local = request(form(), 'http://localhost:4321/api/feedback');
local.headers.set('Origin', 'http://localhost:4321');
expect((await submit(local, env, mocked as any)).status).toBe(200);
expect((await submit(request(), env, mocked as any)).status).toBe(403);
});

test('existing issue returns receipt without creating another', async () => {
let calls = 0;
const mocked = async () => {
calls++;
return calls === 1
? Response.json({ success: true, hostname: 'qrate.dvnl.work', action: 'feedback' })
: Response.json({ data: { issues: { nodes: [{ id: 'existing' }] } } });
};
expect((await submit(request(), env, mocked as any)).status).toBe(200);
expect(calls).toBe(2);
});

test('GraphQL errors with HTTP 200 never report success', async () => {
let calls = 0;
const mocked = async () => ++calls === 1
? Response.json({ success: true, hostname: 'qrate.dvnl.work', action: 'feedback' })
: Response.json({ errors: [{ message: 'private provider error' }] });
const response = await submit(request(), env, mocked as any);
expect(response.status).toBe(502);
expect(await response.text()).not.toContain('private provider error');
});

test('log upload uses generic filename and labels only the successful issue', async () => {
let input: any;
let uploadName: string | undefined;
const data = form();
data.append('files', new File(['redacted log'], 'private-collection.log'));
const mocked = async (url: string, options: any) => {
if (url.includes('siteverify')) return Response.json({ success: true, hostname: 'qrate.dvnl.work', action: 'feedback' });
if (options.method === 'PUT') return new Response(null, { status: 200 });
const body = JSON.parse(options.body);
if (body.query.startsWith('query')) return Response.json({ data: { issues: { nodes: [] } } });
if (body.query.includes('fileUpload')) {
uploadName = body.variables.name;
return Response.json({ data: { fileUpload: { success: true, uploadFile: { uploadUrl: 'https://upload.example', assetUrl: 'https://asset.example/log', headers: [] } } } });
}
input = body.variables.input;
return Response.json({ data: { issueCreate: { success: true, issue: { id: input.id } } } });
};
expect((await submit(request(data), env, mocked as any)).status).toBe(200);
expect(uploadName).toBe('log-1.log');
expect(input.labelIds).toContain('0429eef3-39f3-4f67-a303-a9f985122a61');
expect(input.description).not.toContain('private-collection');
});

test('rate limit stops all provider work', async () => {
const response = await submit(request(), {
...env, FEEDBACK_LIMITER: { limit: async () => ({ success: false }) },
}, (() => { throw new Error('Unexpected fetch'); }) as any);
expect(response.status).toBe(429);
});
Loading