Skip to main content

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

  1. User opens the Report Bug view from the launcher utility cluster.
  2. User fills title, description, category, and optionally drops up to 3 screenshots.
  3. Each screenshot is uploaded individually via the UPLOAD_BUG_REPORT_IMAGE IPC channel. The main process forwards the bytes to Supabase Storage under the user's JWT.
  4. On submit, the main process calls the report-bug Edge 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.
  5. The Edge Function persists the report row and returns { id, created_at }.
  6. The INSERT fires the bug_reports_to_linear Postgres trigger, which calls the bug-report-to-linear edge 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 to bug_reports.linear_ticket.

Triage happens entirely on Linear. The Supabase row is the system of record; Linear is the viewer / queue.

Linear pipeline

PropertyValue
Triggerbug_reports_to_linear (AFTER INSERT on public.bug_reports)
Edge functionbug-report-to-linear (verify_jwt: false, auth via shared secret)
Linear teamEC
Linear projectCommunity Bug Reporting
Initial statusBacklog
LabelsBug, from-app
Title format[{category}] {title}
Issue bodydescription + screenshot embeds + version table + collapsible hw_config + collapsible electron_runtime
Cross-ref commentSupabase row link + auto-creation note
Retry policy3 attempts, 1s / 4s / 16s backoff per Linear call
Image uploadeach image uploaded individually; partial failures don't block issue creation
Idempotencyskip if linear_ticket IS NOT NULL
Identifier writebackUPDATE 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:

SecretWherePurpose
LINEAR_OAUTH_TOKENEdge Function secretOAuth2 application token (workspace-scoped) used to call Linear's GraphQL API.
BUG_REPORT_WEBHOOK_SECRETEdge Function secretShared secret presented as x-webhook-secret by the trigger. The function rejects calls that don't match.
bug_report_webhook_secretvault.secretsSame 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:

  1. Deploy the bug-report-to-linear edge function.
  2. Add the three secrets (LINEAR_OAUTH_TOKEN, BUG_REPORT_WEBHOOK_SECRET in Edge Function secrets, bug_report_webhook_secret in the Vault). The two webhook-secret entries must hold the same value.
  3. Apply migration 20260516280000_bug_reports_to_linear_webhook.sql to 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:

  1. Update the Edge Function env var on the dashboard.
  2. 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

PropertyValue
IPC channelUPLOAD_BUG_REPORT_IMAGE
Storage bucketbug-report-images (private)
Object path{userId}/{uuid}.{ext}
Max images per report3
Max size per image5 MB
Allowed MIME typesimage/png, image/jpeg, image/webp, image/gif
Signed URL TTL15 minutes (the bug-report-to-linear worker re-signs with a 7-day TTL when uploading to Linear)
Upload modeupsert: 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 iff bucket_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

PropertyValue
IPC channelSUBMIT_BUG_REPORT
Edge Functionreport-bug (verify_jwt: true)
Required fieldstitle, description, category
Categoriesui, recording, performance, onboarding, other
Title max length200 characters
Description max length10,000 characters
Auto-attached contextinstaller_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_IMAGE rejects 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/png but contain HTML / SVG / executable bytes are rejected with bad_mime.
  • Extension derived from sniffed MIME only. The original fileName is 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 named pwn.html would otherwise be stored as .html under 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 (from playroll-core GET /api/hw/snapshot), and electron_runtime (from app.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.warn with [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

CodeSurfaced whenUI behavior
unauthorizedNo Supabase session, or token rejected by edgeSubmit blocked, user must re-auth
rate_limitedEdge function returns a rate-limit errorSubmit blocked, asks user to retry later
validation_failedMissing title/description, invalid categorySubmit blocked with field-level hint
invocation_failedEdge function 5xx or malformed responseGeneric error feedback
too_largeImage > 5 MBImage not added to draft, others continue
bad_mimeMIME outside the 4-type whitelistImage rejected, others continue
upload_failedNetwork error, storage error, missing configImage 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:

  1. Time-based purge. A scheduled Edge Function (cron) deletes objects in bug-report-images older than 90 days since created_at. Recommended default.
  2. Status-driven purge. When the linked bug report transitions to resolved or closed, 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.

  • Renderer view: src/renderer/features/launcher/views/BugReportView.tsx
  • IPC handlers: src/main/ipc/handlers.ts (search SUBMIT_BUG_REPORT, UPLOAD_BUG_REPORT_IMAGE)
  • Edge function: report-bug (Supabase)
  • Storage bucket: bug-report-images (private)