Bug Reporting
This runbook documents the in-app Report Bug feature: how reports are submitted, where attached screenshots live, and how long they are retained.
End-to-end flow
- User opens the Report Bug view from the launcher utility cluster.
- User fills title, description, category, and optionally drops up to 3 screenshots.
- Each screenshot is uploaded individually via the
UPLOAD_BUG_REPORT_IMAGEIPC channel. The main process forwards the bytes to Supabase Storage under the user's JWT. - On submit, the main process calls the
report-bugEdge Function with the report metadata, the list of uploaded image URLs, and machine context (versions, hardware) collected server-side so the renderer cannot tamper with them. - The Edge Function persists the report row and returns
{ id, created_at }. - The
INSERTfires thebug_reports_to_linearPostgres trigger, which calls thebug-report-to-linearedge function. That function re-signs each screenshot, uploads it to Linear as a native attachment, creates an issue in the Community Bug Reporting project, adds a cross-reference comment pointing back to the Supabase row, and writes the Linear identifier back tobug_reports.linear_ticket.
Triage happens entirely on Linear. The Supabase row is the system of record; Linear is the viewer / queue.
Linear pipeline
| Property | Value |
|---|---|
| Trigger | bug_reports_to_linear (AFTER INSERT on public.bug_reports) |
| Edge function | bug-report-to-linear (verify_jwt: false, auth via shared secret) |
| Linear team | EC |
| Linear project | Community Bug Reporting |
| Initial status | Backlog |
| Labels | Bug, from-app |
| Title format | [{category}] {title} |
| Issue body | description + screenshot embeds + version table + collapsible hw_config + collapsible electron_runtime |
| Cross-ref comment | Supabase row link + auto-creation note |
| Retry policy | 3 attempts, 1s / 4s / 16s backoff per Linear call |
| Image upload | each image uploaded individually; partial failures don't block issue creation |
| Idempotency | skip if linear_ticket IS NOT NULL |
| Identifier writeback | UPDATE bug_reports SET linear_ticket = '{identifier}' after success |
If the writeback fails after the Linear issue has been created, the function returns 207 with the Linear identifier in the response; an operator must paste the identifier into bug_reports.linear_ticket by hand to prevent a duplicate issue on the next replay.
Secret handling
Two Edge Function secrets and one Vault entry power this pipeline:
| Secret | Where | Purpose |
|---|---|---|
LINEAR_OAUTH_TOKEN | Edge Function secret | OAuth2 application token (workspace-scoped) used to call Linear's GraphQL API. |
BUG_REPORT_WEBHOOK_SECRET | Edge Function secret | Shared secret presented as x-webhook-secret by the trigger. The function rejects calls that don't match. |
bug_report_webhook_secret | vault.secrets | Same value as BUG_REPORT_WEBHOOK_SECRET. Read at trigger runtime via vault.decrypted_secrets, so the value never appears in pg_get_triggerdef() or migration files. |
The trigger is defined in migration 20260516280000_bug_reports_to_linear_webhook.sql. The trigger function (public.bug_reports_to_linear_dispatch) runs as SECURITY DEFINER so it can read from vault.decrypted_secrets regardless of the inserting role's grants.
One-time provisioning
When applying this pipeline to a fresh project:
- Deploy the
bug-report-to-linearedge function. - Add the three secrets (
LINEAR_OAUTH_TOKEN,BUG_REPORT_WEBHOOK_SECRETin Edge Function secrets,bug_report_webhook_secretin the Vault). The two webhook-secret entries must hold the same value. - Apply migration
20260516280000_bug_reports_to_linear_webhook.sqlto create the trigger.
Vault insertion (one-time, run from the SQL editor with appropriate permissions):
select vault.create_secret(
'<shared secret value>',
'bug_report_webhook_secret',
'Shared secret presented as x-webhook-secret by the bug_reports_to_linear trigger.'
);
Rotation
To rotate BUG_REPORT_WEBHOOK_SECRET:
- Update the Edge Function env var on the dashboard.
- Update the Vault row:
update vault.secrets
set secret = '<new value>'
where name = 'bug_report_webhook_secret';
No re-deploy or migration replay is required — the trigger re-reads from the vault on every call.
Image upload
| Property | Value |
|---|---|
| IPC channel | UPLOAD_BUG_REPORT_IMAGE |
| Storage bucket | bug-report-images (private) |
| Object path | {userId}/{uuid}.{ext} |
| Max images per report | 3 |
| Max size per image | 5 MB |
| Allowed MIME types | image/png, image/jpeg, image/webp, image/gif |
| Signed URL TTL | 15 minutes (the bug-report-to-linear worker re-signs with a 7-day TTL when uploading to Linear) |
| Upload mode | upsert: false (no overwrites) |
The upload is performed with a per-request Supabase client that carries the user's access token, so storage RLS treats the caller as authenticated. Two policies on storage.objects scope all access to the user's own folder:
bug_report_images_insert_own— INSERT allowed iffbucket_id = 'bug-report-images' AND (storage.foldername(name))[1] = auth.uid()::text.bug_report_images_select_own— same condition on SELECT.
There are no UPDATE or DELETE policies — neither the renderer nor the user can modify or remove an uploaded screenshot. Cleanup is operator-driven (see Data retention).
Report submission
| Property | Value |
|---|---|
| IPC channel | SUBMIT_BUG_REPORT |
| Edge Function | report-bug (verify_jwt: true) |
| Required fields | title, description, category |
| Categories | ui, recording, performance, onboarding, other |
| Title max length | 200 characters |
| Description max length | 10,000 characters |
| Auto-attached context | installer_version, ui_version, core_version, hw_config (canonical HW snapshot from playroll-core), electron_runtime (Chromium auxAttributes), connection (Cloudflare speedtest snapshot) |
The main process collects versions via the update manager. Hardware is fetched from playroll-core via GET /api/hw/snapshot — the same canonical payload the core writes into every recording's _meta.json::hardware, so bug reports and recording analytics share one schema. The Electron/Chromium runtime details (vulkanVersion, skiaBackendType, supportsDx12, …) are collected separately from app.getGPUInfo("basic").auxAttributes and live in their own electron_runtime jsonb column — different producer (Chromium GPU process), different purpose (UI/renderer debug, not device inventory). The renderer never sees or sends any of this; the IPC handler composes and forwards everything server-side. See also: Device HW Inventory.
Security posture
The renderer is treated as untrusted (the form is user input, and a compromised renderer could try to abuse the IPC channels). All trust decisions live in the main process:
- Size pre-check before allocation.
UPLOAD_BUG_REPORT_IMAGErejects payloads above 5 MB before constructing any byte view, bounding memory use even under hostile renderers. - Per-user upload rate limit. 20 uploads per 10-minute sliding window per user, enforced in-process. The renderer's 3-image cap is cosmetic; the IPC cap is the real defense against storage flooding.
- MIME spoofing protection. The declared MIME must (a) be in the 4-type whitelist and (b) match the actual magic bytes detected from the first 12 bytes of the file. Files that declare
image/pngbut contain HTML / SVG / executable bytes are rejected withbad_mime. - Extension derived from sniffed MIME only. The original
fileNameis never used to build the storage object path; extension is fixed by the verified MIME (png,jpg,webp,gif). This prevents extension-spoofing attacks where a file namedpwn.htmlwould otherwise be stored as.htmlunder the trusted domain. - Title / description sanitized server-side. HTML tags are stripped, Discord mass-mentions (
@everyone,@here, role/user mentions) are neutralized, and length caps (200 / 10,000 chars) are enforced in the IPC handler — the renderer caps are convenience, not security. - Short-lived signed URLs. Signed URLs are 15 minutes — long enough for the renderer to render a thumbnail and for the report to reach the operator surface, short enough that a leaked URL (logs, Slack paste, CI dumps) is useless after. Operator tooling re-signs on demand at view time.
- Server-collected context cannot be forged.
installer_version,ui_version,core_version,hw_config(fromplayroll-coreGET /api/hw/snapshot), andelectron_runtime(fromapp.getGPUInfo("basic").auxAttributes) are gathered in the main process. The renderer cannot override them via the IPC payload. - Generic error messages to the renderer. Upstream errors from Supabase Storage and the edge function are logged server-side (
console.warnwith[Playroll]prefix) but replaced with a generic"Upload failed."/"Could not submit report."string before crossing back to the renderer. Internal paths, SQL hints, and bucket names are not exposed.
Trust assumption (by design)
A user reporting a bug from their own machine can attach any screenshot, including one containing third-party content (e.g., visible game UI, other apps in the background). This is treated as the user's responsibility — they're consenting to upload by submitting the report. The feature is not a data-leak prevention surface; it is a support channel.
Error codes
| Code | Surfaced when | UI behavior |
|---|---|---|
unauthorized | No Supabase session, or token rejected by edge | Submit blocked, user must re-auth |
rate_limited | Edge function returns a rate-limit error | Submit blocked, asks user to retry later |
validation_failed | Missing title/description, invalid category | Submit blocked with field-level hint |
invocation_failed | Edge function 5xx or malformed response | Generic error feedback |
too_large | Image > 5 MB | Image not added to draft, others continue |
bad_mime | MIME outside the 4-type whitelist | Image rejected, others continue |
upload_failed | Network error, storage error, missing config | Image marked with red overlay; user can remove and retry |
Data retention
Current policy: indefinite retention. Bug-report screenshots remain in bug-report-images until manually removed. The signed URL embedded in each report expires after 1 year, but the underlying object stays in storage.
This is acceptable for the pilot (low volume, short window). It is not the long-term policy.
Planned retention (post-pilot)
When pilot data is no longer actively used for triage, screenshots should be purged. Two equivalent approaches; pick one:
- Time-based purge. A scheduled Edge Function (cron) deletes objects in
bug-report-imagesolder than 90 days sincecreated_at. Recommended default. - Status-driven purge. When the linked bug report transitions to
resolvedorclosed, a trigger or job deletes the referenced objects after a 30-day grace period.
Either job must use the service-role key (the only credential that can DELETE — there is no user-facing delete policy).
Operator-initiated deletion
To delete a specific user's screenshots (right-to-erasure / GDPR request):
-- list objects for the user
select name, created_at, metadata->>'size' as bytes
from storage.objects
where bucket_id = 'bug-report-images'
and (storage.foldername(name))[1] = '<user-uuid>'
order by created_at desc;
-- delete (run from Supabase SQL editor with service role)
delete from storage.objects
where bucket_id = 'bug-report-images'
and (storage.foldername(name))[1] = '<user-uuid>';
The associated bug_reports rows (if they reference signed URLs scoped to this user) should be handled in the same operation per the relevant DSR runbook.
Cost notes
Supabase Storage on the Pro plan includes 100 GB storage and 250 GB egress per month. Overage is billed at $0.021/GB/month (storage) and $0.09/GB (egress). At pilot scale (a few hundred users × ~2 screenshots × ~1 MB), the bucket footprint is well under 1 GB and the cost is effectively zero. The 90-day purge cap (once implemented) keeps the steady-state footprint bounded regardless of report volume.
Related
- Renderer view:
src/renderer/features/launcher/views/BugReportView.tsx - IPC handlers:
src/main/ipc/handlers.ts(searchSUBMIT_BUG_REPORT,UPLOAD_BUG_REPORT_IMAGE) - Edge function:
report-bug(Supabase) - Storage bucket:
bug-report-images(private)