mirror of
https://github.com/HeyPuter/puter.git
synced 2026-08-25 07:27:04 +00:00
NS/dynamic-worker-event
9
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
7ceb2090b7 |
feat: app user feedback system (#3546)
* feat: app user feedback system
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.
* feat: add feedback control to the dashboard app-drawer
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.
* feat: enable app feedback by default in Dev Center
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).
* fix: feedback modal polish + share sender email
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.
* fix: resolve 'app-'-prefixed app names in feedback target lookup
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.
* fix: make feedback daily caps fail closed under concurrent submissions
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.
* docs: disclose the Dev Center's feedback-on-by-default in SDK docs/types
The Dev Center deliberately creates apps with feedbackEnabled (see
|
||
|
|
d202be10a9 |
feat: let apps use another app's data with user consent (#3516)
* feat(perms): add cross-app app-data permission vocabulary * feat(perms): sweep app grants by permission prefix * feat(perms): resolve and withdraw cross-app data grants * feat(kv): support an authorized namespace override and per-key privacy * feat(kv): gate cross-app KV access behind app-data grants * feat(fs): allow cross-app AppData access and require a scope to delete * feat(auth): accept permission lists and gate app-data grants * feat(perms): add requestAppData to the puter.js SDK * feat(gui): carry permission lists through the IPC and popup transports * feat(gui): describe cross-app data requests in the consent dialog * docs: document requestAppData and per-entry KV privacy * perf(perms): sweep cross-app grants only for origin-bootstrapped apps * fix(gui): stop double-encoding cross-app consent text * fix(perms): close three gaps in cross-app grant enforcement * fix(kv): meter and batch the per-entry privacy probe * fix(perms): resolve app identifiers and scopes more strictly in the SDK * test(perms): cover the cross-app consent flow end to end * fix: small missing token resolution for app also adds the same exclusion for the batchPut api, small change * fix: make resolved actor optional --------- Co-authored-by: Daniel Salazar <daniel.salazar@puter.com> |
||
|
|
c8113d7514 |
fix: auth message popups (#3487)
* fix: auth message popups * remove v1 auth |
||
|
|
08d1708378 |
change cors auth path (#3464)
* change cors auth path * undo oidc ref changes * scary OIDC state changes * fix: bad cors signin * puterjs changes --------- Co-authored-by: Daniel Salazar <daniel.salazar@puter.com> |
||
|
|
8491a0b55d | fix: limit api pointing to apps only (#3470) | ||
|
|
5bad6a972f |
fix: puter-js cleanup around docs and ai resolution (#3459)
* fix: puter-js cleanup around docs and ai resolution * more jsdoc stuff * fix: docs * fix: doc types + misc hardening * fix: harden auth |
||
|
|
f3fd8a30da |
Rework permission requests: new dialog, working popup flow for websites (#3447)
* Rework permission requests: new dialog, working popup flow for websites - Replace the UIWindow-based permission prompt with a standalone top-layer <dialog> (responsive, light/dark, app/site identity, input protection) - Implement puter.ui.requestPermission for env=web: opens the GUI's /action/request-permission popup with pinned origin/source/msg_id, popup-closed detection, and a check-permissions polling fallback for crossOriginIsolated openers - Move the GUI's request-permission action into postAuthActions so signed-out users sign in first; identify the app by opener origin, correlate responses with original_msg_id, close the popup after answering - Always respond from the IPC handler so the SDK promise can't hang; normalize the result to a strict boolean (a failed grant no longer resolves truthy) - Accept origin in /auth/grant-user-app and /auth/revoke-user-app, mirroring grant-dev-app (fixes puter.perms.grantOrigin/revokeOrigin) - Add Playwright e2e coverage for both the desktop and popup flows; update e2e harness for the auth_token_v2 localStorage key - Update types and docs (perms request methods now work on websites) * Harden permission request flows - Grace period before treating popup close as denial: the GUI posts the decision then closes the popup, and postMessage delivery is not ordered relative to `closed` becoming true, so a grant could race to a false - Unique popup window name per request so window.open name-reuse can't hijack a still-pending request's popup - request-permission action always answers the requester and closes the popup, even when app resolution or the dialog throws - Permission dialog: refuse unidentifiable requesters, allowlist icon URL schemes, and time out the grant request into the retryable error path - Validate app_uid/origin/permission types and length in grant-user-app and revoke-user-app - Tests: revoke-by-origin and input-validation backend tests; e2e for dialog dedup, unsupported permissions, and the consent-dialog path * Sign in first in the requestPermission fixture's email flow on the web In env=web the site has no auth token, so whoami threw 401 immediately and the email button appeared to do nothing. Sign in via popup first, matching the real third-party flow; env=app already has a token and is unaffected. * Point the requestPermission fixture at the real api subdomain The SDK sends credentialed CORS requests; the GUI host doesn't answer with Access-Control-Allow-Credentials, so whoami (and any authed call) from the fixture origin failed CORS and looped through retries. * Fix /auth/list-permissions schema mismatches The endpoint's queries referenced columns that don't exist: user_to_app_permissions stores a numeric app_id FK (not app_uid), and user_to_user_permissions uses holder_user_id (not target_user_id) — every call 500'd. Join apps to expose the app's uid and use the real column names. Replace the catch-either-branch test (which documented the breakage instead of failing on it) with real assertions covering all three sections of the response. * Identify permission requesters by origin only The request-permission action took `app_uid` straight from the query string and used it as the grant target whenever the origin was absent or unresolvable. Now that /auth/grant-user-app accepts `origin` and prefers `app_uid` when both arrive, the displayed identity and the grant target could diverge; with only `app_uid` in the URL the dialog rendered with an empty name, so a link could produce a bare "Allow" prompt for an unnamed requester. Resolve the uid from the origin alone, and let the server resolve it from that same origin when the client lookup fails. Also give the no-gesture consent popup a unique window name. UI.js does this on the direct path because window.open() reuses a window with a matching name, but the PuterDialog fallback opened under the default 'Puter' — the same name sign-in uses, so a consent click could navigate an in-progress sign-in popup away. And guard the IPC responder: an app that closes its own window while the dialog is up leaves target_iframe.contentWindow null. * Keep the permission popup from signing the site in The popup loads the GUI with embedded_in_popup=true, so it ran the sign-in token exchange and posted puter.token to the opener before the user answered the prompt. A site that called requestPermission() walked away holding a user-app token for the account even when the user pressed "Don't Allow" — and because the SDK's global puter.token handler feeds event.data.token into setAuthToken() without looking at `success`, a failed exchange posted token: null and wiped a token the site already had. Keep running the exchange (it bootstraps the app row the grant needs and caches host_app_uid) but leave the token in the popup. A site that wants credentials still has to call signIn(). Escape on the SDK's consent dialog left the caller pending forever. PuterDialog wired its Cancel and close buttons but not the <dialog>'s native cancel event, so the browser dismissed the dialog and nothing reported it: no dialog, no popup, no answer. Route cancel to the same handler. Programmatic close() fires only `close`, so launching the popup — which closes this dialog — is unaffected, and the implicit-auth flow stops hanging on Escape too. Serialize the permission dialogs. showModal() makes the whole document inert rather than just the requesting app's window (which is what the UIWindow it replaced did), and the dedup map only coalesced identical requests, so an app asking for permissions in a loop stacked one modal per request and walled the user off from the desktop — including from the app doing it. Prompts now queue and open one at a time, and each caller still gets its own decision. Identify apps by more than their title. `title` is free-form text the author picks and is not unique, so it was the whole identity of a prompt an app titled "Puter Settings" could raise; the registered `name` is unique and format-restricted, so show it underneath. Give the name line the unicode-bidi isolation the origin line already had, since escaping leaves bidi overrides intact. Stop the dialog from answering over its own in-flight grant: a dismissal while the POST was outstanding resolved false for a permission the server was committing. Ignore dismissals while granting, and time-box the request with AbortController (AbortSignal.timeout isn't everywhere) so a hung network can't leave a modal no one can close. Fail closed on the remaining paths that could reject or prompt uselessly — showModal() throwing under <iframe sandbox>, and a requester known only by app_name, whose Allow the server would always reject. Pass the error string to .text() unencoded so translations containing an apostrophe don't render '. The e2e suite covers all of it; each new test fails without its fix. * Restrict the permission popup flow to third-party websites requestPermission's new web path ran in every environment with a window, including env='gui' — so a permission_denied driver retry inside the Puter GUI would open a popup to the Puter origin from the desktop itself and try to grant the permission to a phantom app for Puter's own origin. Resolve false everywhere except env='web', the previous behavior. * Settle the permission dialog when a failed grant has no dialog left The cancel handler is preventDefault'd, but close requests can't be suppressed forever: Chrome's close watcher lets a repeated Esc skip cancel and force-close the dialog while the grant POST is in flight. The close handler defers to that grant on purpose — but if the grant then failed, fail_grant re-enabled buttons on a closed dialog and the promise never settled, leaving the requesting app waiting forever. Settle as a denial when the dialog is no longer open. Also add regression tests for this and for the popup-flow env guard (routing the CDN SDK URL to the local build, since the prod-built GUI loads its SDK from js.puter.com). * Withhold the auth token on the popup's first-visit paths Keeping the token inside the permission popup only covered the plain token exchange. Two other popup paths mint a user-app token and posted it to the opener unconditionally: first-visit temp-user creation, and the manual signup shown when temp users are refused. Both sit on the path a brand-new visitor takes — the audience the website popup flow exists for — so a site that asked about one permission and was denied still walked away holding a token, for a temp account or a real one. The SDK's global puter.token handler feeds whatever arrives straight into setAuthToken(), so posting it is the whole of it. Move the rule into util/popupAuth.js and consult it at every site that posts the token, so the next token path has one place to ask. The first-visit path also left the prompt itself unreachable: it waits on the spinner promise, which only resolves when the spinner was up for under 2s. End that wait for any action that keeps the popup open; sign-in still closes the window as before. The e2e test fails without the fix — the site holds a token after the user presses "Don't Allow". * Poll for the decision when the popup's opener is severed crossOriginIsolated was the test for "the popup can't message us back", but being isolated also requires COEP. A site sending COOP: same-origin on its own still has its opener relationship severed when it opens the Puter popup, and took the watch-the-window path instead — where the detached proxy reports closed === true on the first tick, so requestPermission resolved false about a second after the popup opened, while the user was still reading the dialog. Their "Allow" then had nowhere to go. Treat an already-closed popup as severed and poll. Pin the expected event.source before those early returns. popupWindow was assigned after them, so for the whole consent-dialog wait — as long as the user takes to click Continue — the handler accepted a decision from any window on the GUI origin. A forged answer is only advisory since the grant is written server-side, but the check may as well hold. Settle instead of rejecting when the consent dialog can't be appended: document.body is null in a <head> script, and the throw both rejected a promise documented to resolve to a boolean and left the message listener behind. * Key the dialog dedup by the identity its gate accepts The gate treats an empty app_uid as absent and falls through to the origin; the dedup key used ?? and kept the empty string, so two requests from different origins would collide on one key and share a single decision. No caller can produce a blank uid today — server uids are never empty and the IPC path's empty attribute is stopped by the gate — but the two lines have to agree. * Close the gaps the permission-request flow left open Seven defects found reviewing the new permission flow end to end, each reproduced against a running server before being fixed. Security: - `cross_origin_isolated=true` bypassed `deliversTokenToOpener` entirely. That branch is checked first, mints a user-app token, publishes it via `/login/set` and returns — so one query parameter on a request-permission URL skipped the prompt and handed the opener a token through the unauthenticated `/login/wait`. Gate it with the same rule. - Grant/revoke by `origin` could land on an unrelated app. An origin with no app row synthesises `app-<uuidv5>`, and the permission services resolve their identifier as uid *or name* — and the uuid namespace is a source constant, so the string is computable offline and registrable as an app name. Resolve origins to a uid that names a real app row. - A website's host was elided on the right, hiding the registrable domain that says who is asking. Elide it from the left, as the sibling rule already intended. - A grant whose response was lost (client-side abort, dropped reply) left the row committed while the dialog reported a denial. Withdraw it when the user then answers "Don't Allow". Correctness: - `pollDecision` needs the site's own token, which a permission popup deliberately never delivers, so a signed-out cross-origin-isolated site burned the full five-minute timeout before answering. Answer at once when there is nothing to poll with. - `getUserAppToken` reports failure by returning null, and three callers read `.app_uid` off it. Guard all three, keep the first-visit spinner promise settling on its failure paths, and dispatch the `login` event on the manual-signup path so `postAuthActions` runs at all — a user who signed up inside a permission popup got a blank window and the site got no answer. - Time-box the lookups that run while a request holds the dialog queue's slot: they have no timeout of their own, and a stall (not a failure) wedged every later permission request in the page. Also harden the grant/revoke input validation the PR introduced — it skipped `extra`/`meta`, so a non-object faulted *after* the row was written, and its length cap was 16x the column it lands in — stop a non-URL `origin` from throwing past the answer-and-close, and drop the CSS left behind by the deleted dialog. * Close three gaps left in the permission-request flow Each was reproduced first — the squatting grant against a running server, the COOP timing in a real browser — and each fix was then confirmed by reverting it and watching the new test fail. Security: the dialog could name one site and grant to another. The squatter guard added for grant/revoke by `origin` only ran when `app_uid` was absent, and the dialog sends both — so `app_uid` won and the guard never applied. `getAppUIDFromOrigin` returns the synthetic `app-<uuidv5(origin)>` for any origin with no app row of its own, and the grant endpoint resolves `app_uid` as uid *or name*, so the grant landed on whoever registered an app under that computed name (the format allows it, and the namespace is a source constant). A link like `/action/request-permission?origin=https://a-site-you-trust.example` named that site in the prompt while "Allow" handed the permission elsewhere. Fixed on both sides of the wire. The action now sends the origin alone — no uid resolved in the browser is safe to forward, whatever its source — and a supplied `origin` now decides the target on the server even when an `app_uid` travels beside it: the origin is what the prompt showed the user, so it is what the grant has to follow. Correctness: a COOP-only site was answered before the user decided. |
||
|
|
1d056253ef |
contextmenu design tweak (#3298)
* contextmenu design tweak * icon gap fix (mobile context menu web component) * fix submenu item padding * add dark theme support for puter.ui.contextMenu and puter.ui.setMenubar (works only with puter.env === 'web') * Add theme test & refactor context menu items Extract shared mediaMenuItems to remove duplication and simplify context menu calls. Add a light-theme context menu handler and a "Run theme test" button that executes runContextMenuThemeTest(), which programmatically renders menus to verify the theme attribute and .puter-theme-dark class for dark, light, and default cases (mirrors existing Playwright assertions). Update testContextMenu and testContextMenuDark to use the new item factory and clean up rendered menus after checks. UI text is updated to show pass/fail results. --------- Co-authored-by: jelveh <nj@puter.com> |
||
|
|
b593eb5630 | Tests update |