feat: app user feedback system - #3546
Merged
Merged
Conversation
Add puter.ui.showFeedbackDialog(), letting users send feedback to an app's developer. In the app environment the Puter desktop renders the dialog; on a third-party website a puter.com popup hosts it. The message is stored in a new app_feedback table and emailed to the app owner's confirmed email — it never passes through the app's own code. Feedback is strictly opt-in per app via a new apps.feedback_enabled column (a real column, not an app-metadata key, so Dev Center's whole-blob metadata saves can't silently erase it), settable through the existing puter.apps.update path (feedbackEnabled). Backend follows the layered stack: AppFeedbackStore (durable count queries) -> AppFeedbackService (opt-in check, message normalization, abuse caps, best-effort owner email) -> AppFeedbackController (POST /app-feedback, GET /app-feedback/target). New app-user-feedback email template uses the escaping-safe nl2br triple-stash. Defensive by design: - requireUserActor blocks app tokens, so feedback can't be submitted programmatically; guiOriginOnly keeps cross-origin pages out. - App identity comes only from the validated IPC sender (desktop) or the browser-attested opener origin (popup), never from message contents. - The send-feedback popup action is in NON_AUTH_POPUP_ACTIONS, so it never delivers a token to the opener. - Layered limits: route rate limits, plus DB-count caps that fail closed when the limiter backend is down, plus a per-app daily owner-email cap. - Owner email is fully best-effort: an unconfigured transport, unconfirmed/unsubscribed/suspended owner, or send failure never fails the request or blocks storage. - The dialog and SDK method are resolve-only and always settle, so a caller is never left hanging. Migrations for sqlite/mysql/postgres, puter.js types, docs, backend tests (sqlite + postgres), and a Playwright e2e spec are included.
Contributor
Coverage Report for puter.js SDK
File Coverage
|
||||||||||||||||||||||||||||||||||||||||||||
Contributor
Surface the feedback dialog directly from the app window's chrome in dashboard mode: apps that opt in (apps.feedbackEnabled) get a "Send Feedback" button in the dashboard app-drawer, next to minimize/close. It opens the same UIWindowAppFeedback dialog, targeting this app by uid. The control is only rendered when the app opted in — feedback_enabled is threaded from the launched app's metadata into the window options — and the dialog still re-checks opt-in server-side, so a stale flag can't send anywhere. Reuses the existing .dashboard-app-drawer-btn styling and the app_feedback_title i18n string, so no new CSS or strings. Adds e2e coverage: the control appears and opens the dialog for an opted-in app, and is absent for an app that hasn't opted in.
New apps created in Dev Center now have feedbackEnabled set on creation, so users can send the developer feedback without any extra setup. A "User Feedback" toggle in the app's settings lets developers turn it off (and back on); it's wired into the save payload, the dirty-state tracking, and the reset-to-original path like the neighboring toggles. The Save update omits feedbackEnabled unless the toggle is present, and the backend leaves an omitted field untouched, so the default survives the create-then-save flow Dev Center runs. Add an SDK apps-suite guard covering that round-trip (create-on -> unrelated update keeps it -> can be turned off).
Address four issues with the app feedback UI: - Dashboard app-drawer: the extra "feedback" control pushed the close button past the drawer's derived width and clipped it. A `has-feedback` modifier widens the surface by one button + gap so all three controls fit. The control's glyph is now a message bubble with text lines, which reads more clearly at 14px than the previous bare speech bubble. - The feedback dialog is no longer a UIWindow. It's a from-scratch overlay modal in the spirit of the dashboard modals (uninstall, add-app): a fixed scrim + centered card with self-contained, theme-aware color tokens (light default + dark override), a bottom-sheet layout on narrow screens, backdrop/Escape close, and an entrance transition. This renders consistently across the three contexts it's opened from (desktop app-IPC, dashboard drawer, standalone popup), so the callers no longer pass UIWindow-specific window_options. - Feedback now shares the sender's email (not just their username) with the developer so they can respond: the owner email sets Reply-To to the sender and shows the address in the body — but only when the sender's email is verified (an unverified address could be anyone's, so it's never used as a reply target). EmailClient.send gains an optional replyTo. The dialog note now says the email will be shared. Tests: e2e updated for the new modal (7 pass); backend feedback suite covers the verified/unverified sender-email split (sqlite + postgres); EmailClient + GUI unit suites pass; type-check clean.
APP_NAME_REGEX allows names beginning with "app-" (e.g. the seeded
app-center), but resolveTargetApp's startsWith('app-') heuristic sent
those to a uid-only lookup with no name fallback, so feedback for such
apps 403'd even when enabled. Use AppStore.resolveApp (uid, then name)
like the rest of the codebase.
The per-user and per-user-per-app caps were check-then-insert and the per-app email cap was count-then-send, so parallel requests (or multiple nodes, or the route limiter failing open) could all read a stale under-cap count and push past every limit — the exact scenario the DB-backed caps exist to stop. Now the user caps recount after the insert (own row included) and roll the row back with 429 if a burst breached them, and the email cap claims its slot (email_sent=1) before sending, recounts, and releases the slot if over cap or if the send fails.
The Dev Center deliberately creates apps with feedbackEnabled (see 32c950d), but the SDK docs, apps.d.ts, and AppFeedbackService's class doc all described feedback as strictly opt-in / default-false with no qualification — so a Dev Center developer reading them would wrongly conclude feedback is off for their app. State the Dev Center behavior alongside the API default, and correct the update-path docs: an omitted feedbackEnabled leaves the current value unchanged rather than defaulting to false.
Every embedded_in_popup boot ran the user-app token exchange, and the exchange is a write: /auth/get-user-app-token bootstraps an app row for the opener origin, grants flag:app-is-authenticated (what makes the site count as connected to the account), and creates its AppData dir. So merely opening — or immediately cancelling — a send-feedback popup recorded a user<->site relationship the read-only feedback flow never needs: the server resolves the feedback target from the attested origin without any of it. Gate the exchange behind runsUserAppTokenExchange(action) in all three popup paths that mint (main postAuthActions exchange, temp-user signup success, manual signup fallback). request-permission keeps the exchange since grants are written against the app row it bootstraps.
With no email transport configured (the common self-hosted default), submissions were stored in app_feedback — a table with no read path beyond the abuse-cap COUNTs — the owner email was silently skipped, and the sender was still shown 'Feedback sent. Thank you!'. The developer never learns the feedback exists while the user believes it was delivered. Gate acceptsFeedback on clients.email.isConfigured so the pre-flight reports enabled:false (the dialog shows its 'not accepting feedback' pane) and submit returns 403 instead of swallowing messages. Owner-level store-without-email cases (unconfirmed owner email, per-app email cap overflow) keep their existing deliberate semantics.
In the app environment showFeedbackDialog awaited an IPC reply with no capability check. A host GUI that predates this feature (self-hosted Puter running the live js.puter.com SDK) has no handler for the message and never replies, so the promise documented as 'never rejects' also never resolved. The GUI now advertises the IPC dialogs it can answer via a puter.gui_features param on the app iframe URL, and the SDK resolves false when 'feedback-dialog' isn't listed. A reply timeout could not substitute: legitimate replies only arrive when the user closes the dialog, so any timeout would false-negative while the user is typing. Older SDKs ignore the extra param.
The showFeedbackDialog IPC handler had no re-entry or abuse guard, and the dialog it opens is a full-viewport overlay above the taskbar and every window — so 'while (true) await puter.ui.showFeedbackDialog()' kept the desktop permanently covered (for signed-out users, the same loop spams the full-page signup window instead). Every dismissal just settled the promise and let the app immediately reopen it. Allow one dialog at a time, and back off reopens per app after each dismissal that sent nothing: 10s, then 60s, then blocked until page reload. A successful send resets the backoff, and user-initiated paths (dashboard drawer) are unaffected since they don't go through IPC.
launch_app uses options.app_obj verbatim when provided, and the suggested-apps launch paths (open_item.js, UIWindowSearch.js, UIDesktop.js) pass summaries from toAppSummary — which omitted feedback_enabled. So an opted-in editor launched by opening a .txt file showed no Send Feedback control in the dashboard drawer, while the same app launched from the Apps tab (full puter.apps.get object) did.
Escape and backdrop clicks were already ignored while the POST was pending, but the X, Close, and Cancel buttons weren't — clicking one mid-send settled the promise false and tore down the overlay while the submission still landed server-side: the developer got the email, the app was told sent=false, and a user who resubmitted 'the failed one' sent a duplicate and burned a daily-cap slot. Apply the same !sending gate to the buttons and disable them visually while the send is in flight.
readTargetParam accepted values up to 3000 chars but the raw origin is stored verbatim into source_origin VARCHAR(2048) (MySQL/Postgres), so a 2049-3000 char origin passed every validation and then blew up the INSERT with an HTTP 500 on Postgres/strict MySQL — or was silently truncated on non-strict MySQL, corrupting the abuse-forensics value the column exists for. Cap the param at the column size.
The note unconditionally said 'Your email address will be shared with the developer so they can respond', but AppFeedbackService shares the username always and the email only when it exists and is verified — an unverified or temp-user sender was promised a reply path that never materializes, and nobody was told about the username. Show 'username and email' when the signed-in user's email is verified, and 'username' otherwise.
Subjects were compiled with default Handlebars escaping, so the app-user-feedback subject rendered a title like "Bob's App & Games" as "Bob's App & Games" — literal entities in the recipient's mail client. Subjects are plain-text headers, not HTML; compile them with noEscape. Header safety is unaffected: the transport encodes newlines and free-form values collapse whitespace upstream.
The doc claimed the dialog 'tells the user their username will be shared', while the dialog's note talked only about the email address and the implementation shares the username always plus the email (as Reply-To) only when verified. Describe the actual disclosure: username always, email when verified.
Under COOP the popup's opener link is severed, so the SDK deliberately
resolves false while the popup stays open and the user can still submit
(a feedback submission has no server read-back the way a permission
grant does). The documented contract ('resolves to true if the user
submitted feedback') was silently wrong on cross-origin-isolated pages —
state the limitation in the doc and the SDK jsdoc: false means 'not
confirmed', not 'not sent'.
The footer told developers to turn feedback off via a puter.apps.update one-liner, but the toggle lives in the Dev Center app settings — and Dev Center is where apps get feedback enabled by default in the first place. Reword it to 'manage it in the Dev Center' with a link built from config.origin (like app_link) so it holds on self-hosted deployments.
Salazareo
reviewed
Aug 12, 2026
Salazareo
left a comment
Member
There was a problem hiding this comment.
generally looks good, one nit there
is lacking some test coverage though; I think at least the service layer should get coverage since it has a lot of app logic, but ideally also the store
| userCount >= AppFeedbackService.PER_USER_DAILY_LIMIT + slack | ||
| ); | ||
| }; | ||
| const tooManyError = () => |
Member
There was a problem hiding this comment.
nit: you can just prep an error and throw it as needed, method is unecessary:
const tooManyError = new HttpError(
429,
'You have sent a lot of feedback recently — please try again later',
{ legacyCode: 'too_many_requests' },
);
The service owns every feedback business rule — target resolution, eligibility, message normalization, the durable caps, and the owner-email preconditions — but was only reachable through the controller's tests. Give it and the store their own suites so a regression names the layer it broke. Service coverage adds the branches the route tests could not reach: a blocked origin resolving to null rather than surfacing a 403, owners who are suspended or unsubscribed, length measured after normalization, the 24h cap window boundary, subject-header injection via the app title, and the email links being rooted at config.origin. The two describes already labelled `AppFeedbackService ...` move out of the controller test, which keeps only the caller-facing promise that a failed send still returns success.
Both throw sites are in one call and only one can ever run, so a plain const reads the same and drops a function that existed only to defer a constructor.
Add `requireVerified: true` to both app feedback routes (`GET /target` and `POST /`) in `AppFeedbackController`. This tightens access control so only verified user accounts can fetch feedback targets or submit app feedback.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds
puter.ui.showFeedbackDialog(), letting a user send feedback to an app's developer. In the app environment the Puter desktop renders the dialog; on a third-party website a puter.com popup hosts it (signing the user in first if needed). The message is stored in a newapp_feedbacktable and emailed to the app owner's confirmed email — it never passes through the app's own code.Feedback is strictly opt-in per app via a new
apps.feedback_enabledcolumn, which developers set through the existing update path:A real column (not an app-metadata key) is used deliberately: Dev Center saves the whole metadata blob, so a flag stored there would be silently erased on the next save.
What's included
0065,mysql_mig_20,postgres_mig_9): newapp_feedbacktable +apps.feedback_enabledcolumn. Parameter-free, idempotent DDL.AppFeedbackStore(durable count queries) →AppFeedbackService(opt-in check, message normalization, abuse caps, best-effort owner email) →AppFeedbackController(POST /app-feedback,GET /app-feedback/target). Newapp-user-feedbackemail template using the escaping-safe{{{nl2br}}}triple-stash.UIWindowAppFeedbackdialog (server-side opt-in pre-flight, canonical title + unique name shown to prevent impersonation, char counter, in-form errors, Escape / Cmd-Enter, always settles). Wired intoIPC.js(app env) and a newsend-feedbackpopup action ininitgui.js.puter.ui.showFeedbackDialog()— IPC in app env, origin+source+msg_id-pinned popup in web env, resolve-only so it never hangs the caller. Types + docs updated.Security / abuse posture
requireUserActorblocks app tokens, so feedback cannot be submitted programmatically on a user's behalf;guiOriginOnlykeeps cross-origin browser pages out.send-feedbackpopup action is inNON_AUTH_POPUP_ACTIONS, so hosting the dialog never delivers a token to the opener.Testing
showFeedbackDialog.spec.js): 5/5 pass — app submit/cancel/not-opted-in, gui-env returns false, and web popup reports false + never leaks a token; verified a real row lands in the DB. The adjacentrequestPermissionsuite (shared popup/action code) still passes 33/33.Backward compatibility
Purely additive: a new SDK method, a new optional app attribute (default off), and new endpoints. No existing signature, response field, or error code changes.
Entry points
puter.ui.showFeedbackDialog()themselves.