Skip to content

feat: app user feedback system - #3546

Merged
jelveh merged 23 commits into
mainfrom
feat/app-user-feedback
Aug 12, 2026
Merged

feat: app user feedback system#3546
jelveh merged 23 commits into
mainfrom
feat/app-user-feedback

Conversation

@jelveh

@jelveh jelveh commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

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 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, which developers set through the existing update path:

await puter.apps.update('my-app', { feedbackEnabled: true });

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

  • DB migrations (sqlite 0065, mysql_mig_20, postgres_mig_9): new app_feedback table + apps.feedback_enabled column. Parameter-free, idempotent DDL.
  • Backend (layered): 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 using the escaping-safe {{{nl2br}}} triple-stash.
  • GUI: UIWindowAppFeedback dialog (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 into IPC.js (app env) and a new send-feedback popup action in initgui.js.
  • SDK: 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

  • requireUserActor blocks app tokens, so feedback cannot be submitted programmatically on a user's behalf; guiOriginOnly keeps cross-origin browser pages out.
  • App identity is derived 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 hosting the dialog never delivers a token to the opener.
  • Layered limits: route rate limits (per-user + per-IP), durable DB-count caps (per user/app/day and per user/day) that fail closed when the limiter backend is down, plus a per-app daily owner-email cap. Message capped at 4,000 chars after control-char stripping.
  • Owner email is fully best-effort — an unconfigured transport, or an unconfirmed / unsubscribed / suspended owner, never fails the request or blocks storage. The sender's email is never disclosed (only their username, which the app already sees).

Testing

  • Backend feedback tests: 24 pass on both sqlite and Postgres.
  • GUI + root vitest suites and the type-check gate pass.
  • New Playwright e2e spec (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 adjacent requestPermission suite (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

  • Apps call puter.ui.showFeedbackDialog() themselves.
  • In dashboard mode, an opted-in app also gets a Send Feedback control in the app window's top-edge drawer (next to minimize/close), so a user can reach it without the app wiring up a button. It's rendered only for opted-in apps and opens the same dialog, targeting that app.

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.
@github-actions

github-actions Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for puter.js SDK

Status Category Percentage Covered / Total
🔵 Lines 61.5%
⬇️ -0.65%
3763 / 6118
🔵 Statements 60.31%
⬇️ -0.67%
3955 / 6557
🔵 Functions 60.94%
⬇️ -0.65%
696 / 1142
🔵 Branches 54.46%
⬇️ -0.49%
2399 / 4405
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
src/puter-js/src/modules/UI.js 6.71%
⬇️ -0.61%
6.46%
⬇️ -0.52%
2.97%
⬇️ -0.23%
7.2%
⬇️ -0.62%
253-257, 279-421, 478-541, 560, 563, 567-572, 584-587, 593, 596, 600, 605-608, 614-638, 643-656, 663-679, 685-708, 713-715, 720-721, 726-728, 733-734, 740-825, 830-832, 837-842, 845-868, 879-888, 915-1616, 1632-1633, 1645-2163, 2180-2335, 2362-2408, 2412-2422, 2444-2713, 2728-2878
src/puter-js/src/modules/apps/lib/appObject.js 100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
Generated in workflow #147 for commit b290c1d by the Vitest Coverage Report Action

@github-actions

github-actions Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Coverage Report

Status Category Percentage Covered / Total
🔵 Lines 93.62%
⬆️ +0.01%
22245 / 23760
🔵 Statements 91.99%
⬇️ -0.04%
23892 / 25970
🔵 Functions 89.46%
⬇️ -0.23%
3729 / 4168
🔵 Branches 80.62%
⬇️ -0.20%
16087 / 19952
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
src/backend/clients/database/SqliteDatabaseClient.ts 89.14%
🟰 ±0%
57.45%
🟰 ±0%
81.6%
🟰 ±0%
95.58%
🟰 ±0%
6, 9, 13, 16, 24-26, 32, 33, 34, 38-39, 44, 45, 46, 47, 48, 228-230, 478-481, 496
src/backend/clients/email/EmailClient.ts 100%
🟰 ±0%
97.05%
⬇️ -2.95%
100%
🟰 ±0%
100%
🟰 ±0%
src/backend/clients/email/templates.ts 100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
src/backend/clients/event/types.ts 100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
src/backend/controllers/index.ts 100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
src/backend/controllers/feedback/AppFeedbackController.ts 81.3% 51.09% 50% 90.66% 6, 24-26, 32, 33, 34, 38-39, 43, 44, 45, 46, 47, 130-134, 139-141
src/backend/controllers/fs/FSController.ts 91.92%
🟰 ±0%
75.98%
🟰 ±0%
91.27%
🟰 ±0%
93.4%
🟰 ±0%
6, 24-26, 32, 33, 34, 38-39, 44, 45, 47, 93-101, 298, 380, 418, 424, 429-436, 439, 470-474, 541-545, 565-569, 621-625, 690-692, 711-715, 918, 1180, 1181, 1182, 1198-1200, 1232, 1236, 1268-1270, 1283, 1287, 1295, 1346-1348, 1357-1359, 1365, 1383-1384, 1391-1392, 1403, 1427, 1443, 1447, 1451, 1474, 1530-1532, 1570, 1577-1580, 1585-1587, 1591-1593, 1647-1649, 1733, 1762, 1787, 1828-1832, 1835, 1838, 1882-1885, 1893, 1962-1966, 2019-2021, 2030-2038
src/backend/controllers/fs/LegacyFSController.ts 92.26%
🟰 ±0%
80.4%
🟰 ±0%
80.24%
🟰 ±0%
93.25%
🟰 ±0%
95-105, 405-411, 465, 994-995, 1187, 1198, 1270-1272, 1334-1343, 1348-1354, 1362, 1404-1406, 1692, 1819-1821, 1943, 1999-2000, 2014, 2046-2050, 2055, 2196-2198, 2238-2240, 2383-2388, 2392-2397, 2401-2406, 2442-2448, 2452, 2466, 2474, 2484, 2488, 2509-2510, 2515-2518, 2537-2539, 2551, 2552, 2554, 2585-2595
src/backend/core/http/types.ts 100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
src/backend/drivers/apps/AppDriver.js 86.12%
⬇️ -0.17%
79.07%
⬇️ -0.53%
100%
🟰 ±0%
90.39%
⬇️ -0.20%
109, 237-239, 377, 541, 690-694, 729, 775-777, 784, 786-787, 819, 821, 829, 843-844, 888, 896, 909, 927, 982, 1015-1034, 1076, 1095, 1100, 1116, 1130-1134, 1139, 1165, 1167, 1180-1186, 1191, 1220, 1221, 1222, 1223, 1224, 1246-1254, 1258, 1270, 1271, 1272, 1367-1373, 1376-1382, 1391-1397, 1415, 1441-1445
src/backend/services/index.ts 100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
src/backend/services/apps/RecommendedAppsService.ts 100%
🟰 ±0%
71.42%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
src/backend/services/apps/SuggestedAppsService.ts 98.38%
🟰 ±0%
87.17%
🟰 ±0%
100%
🟰 ±0%
98.24%
🟰 ±0%
157
src/backend/services/feedback/AppFeedbackService.ts 96.92% 93.22% 100% 100% 180, 185
src/backend/stores/index.ts 100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
src/backend/stores/app/AppStore.js 94.56%
🟰 ±0%
84.31%
🟰 ±0%
94.44%
🟰 ±0%
96.74%
🟰 ±0%
176, 210, 229, 446, 503, 513, 518-520, 584, 604, 681-682, 890, 894, 897, 902, 904, 908, 940, 946, 973, 975, 988, 1010, 1164, 1333, 1382, 1420-1430, 1497, 1511
src/backend/stores/appFeedback/AppFeedbackStore.ts 94.11% 66.66% 100% 94.11% 58-60
src/backend/stores/systemKv/SystemKVStore.ts 95.25%
🟰 ±0%
84.66%
🟰 ±0%
98.03%
🟰 ±0%
97.08%
🟰 ±0%
223-225, 280, 292, 293, 389, 400-401, 441, 464, 479-483, 500, 522, 557-560, 598-603, 640, 645, 1151-1152, 1263-1265, 1293, 1487, 1666, 1686
Generated in workflow #711 for commit b290c1d by the Vitest Coverage Report Action

jelveh added 18 commits August 11, 2026 16:39
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&#x27;s App &amp; 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 Salazareo left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 = () =>

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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' },
);

jelveh added 4 commits August 11, 2026 22:10
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.
@jelveh
jelveh merged commit 7ceb209 into main Aug 12, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants