mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-21 19:11:10 +00:00
a552ce0b87ff366f0fc278ee2da9ca43e28006a1
145
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
a552ce0b87 |
fix: better metrics for cache hit rates (#3609)
* fix: better metrics for cache hit rates * chore: type + docs |
||
|
|
9debdffc5d | fix: email send frequency + ai ordering (#3604) | ||
|
|
5e1645d40e |
🔧 PUT-1524: Sanity check file sharing api (#3603)
* fix: sharing answers "who can reach this" from the grants, not the index Two ways a share listing could name access that was no longer there. A recipient's listing paired the flat permission read with the list of permissions it asked for by array index. That read drops misses and dedupes its keys, so the two are not positional: one entry's live grant vouched for another entry that had none, and listShared() kept publishing a withdrawn item's name, size and signed thumbnail URL to someone who could no longer open it. Read the permission off the value instead. getShares() had the same gap from the owner's side, with no liveness check at all — a grant withdrawn through /auth/revoke-user-user or an ACL mode change left the index row behind, and the owner was told someone could reach a file they could not. Checked against the grants now, one batched read per distinct holder. Pending invites are not subject to it: they have no grant yet, which is the point of them. Also covers the access-token actor, which reaches the same reach bound as an app through a different arm of the ACL check. No behavior change there — it was correct and untested. * test: pin that a revoke crosses regions A revoke reaches a peer region as a replicated SQL delete plus three invalidation events, one per cache the region owns: the u2u row cache, the flat view and the scan generation. The delete alone changes nothing there, and each cache has a different consequence if its event is lost — 20s for the generation, 5 minutes for the row cache, and forever for the flat view, whose grant-path entries carry no expiry. Nothing covered the whole path end to end. The store tests prove each event is emitted and applied; this proves the result, which is that the recipient stops being able to read. It asserts the read still succeeds after the SQL delete alone, so the test also records why replication is not sufficient on its own. * test: pin that a leaked uuid buys no access A masked share path hides which folder an item sits in; it was never the thing deciding who may open it. Nothing checked that at the route level, so the guarantee rested on unit tests of the resolver alone. Reads one shared file through its masked path, then tries the sibling four ways: the shared uuid with the sibling's name, the sibling's own uuid, a `..` back out of the root, and the owner's real path. Worth knowing about this one: it's mutation-checked. Removing the head !== root.name guard in sharePathMask.ts fails it with reachable: /testuser/55dd54c0…/share-http-1df8933e.txt. I verified that specifically because two tests I wrote earlier in this chunk passed with their guards broken — both were vacuous, and I deleted them rather than commit false assurance. * docs: say what listShared's total actually counts `total` counts the shares recorded for you; items are filtered after the page is read, so a withdrawn grant leaves the count higher than anything paging will yield. The page description already explained the short-page behaviour, but the field read as an exact count and the example printed it as one. The test pins the gap it describes: two shares, one withdrawn outside the index, one item listed and a total of two. * fix: keep an undelivered broadcast event instead of dropping it The outbound queue was cleared before the send, so a peer that timed out took its events with it. Most were survivable — a lost cache invalidation heals when the entry's TTL lapses. A revoke's flat-perm invalidation is not: grant-path entries carry no expiry, so a peer went on serving a withdrawn grant until something else wrote that key. Failed sends now go back on the queue, which the existing flush timer retries. Anything queued since wins over the retry, and the queue is bounded at 10,000 with the oldest dropped first, so a peer that stays down cannot grow it without limit. Each retry is signed at send time, so it is not rejected against the replay window. * fix: stop plus-addressing from deciding who a share reaches |
||
|
|
b795b219a8 |
✨ PUT-1497: Share file link sharing and notifications (#3595)
* refactor(share): move share notifications into their own service
* feat(share): invite an address with no account, and email it
* feat(share): surface pending invites in the SDK and share dialog
* fix(share): unreachable revoke confirmation, and double-encoded labels
* feat(share): budget share announcements, group them, and let people block senders
Sharing had one defence against noise: a 15-minute quiet window per (sender,
recipient) pair, which dropped the second share rather than folding it in.
Twenty senders each under their own window could still bury someone, and there
was no way to make one of them stop.
Announcements are now budgeted on two axes through the existing sliding-window
limiter: 1 per 15 minutes and 20 per day from one sender, and 10 per hour /
50 per day to one recipient from anyone. Over budget the share still succeeds
and the recipient's notification is still brought up to date — only the
interruption is dropped. Invite email to an address with no account is budgeted
the same way, keyed on a hash of the canonical address.
Notifications now fold across senders: a new share rewrites the notification
the recipient hasn't dismissed, so "alice and bob shared 5 items with you"
replaces a stack of five. The record is written even when suppressed, so the
count is right whenever they next look.
Blocking is a new `user_block` table with enforcement in ShareService: a blocked
sender's share is refused with `recipient_not_accepting_shares`, spends no
quota, and writes no row, and their unclaimed invite is dropped when the address
is confirmed. Existing access is untouched — that is what revoke is for.
Managed from a Blocked people card in the dashboard's Security tab.
Also publishes the sharing limits, including the ones already on this branch
that were never documented.
* fix(share): name the item in share email, instead of 'an item'
* fix(share): make the invite lifecycle canonical, authorized, and race-safe
* refactor(email): drop EmailClient.isConfigured; callers read config.email
* feat(share): batch share email into a per-recipient digest, durably
* docs(share): document the share error codes; steady the disk migration tests
* fix(share): log why a digest wasn't sent, and recover orphaned ones
* feat(share): email recipients about shares by default, with a way to decline
Share email was off unless a deployment opted in, which meant an account
holder was told about a share in the app only. It is now on unless
`share_email_notifications` is set to false.
The reason it defaulted off was that nobody could decline. So this also
honors `user.unsubscribed` — the account-wide opt-out the /unsubscribe page
already writes and app feedback already respects, which share email ignored —
and the digest carries that link. Sharing and the in-app notification are
unaffected by it; only the mail stops.
The link is composed in the template around an interpolated uuid rather than
passed pre-built: Handlebars escapes interpolated values, so a whole URL came
out as `user_uuid=…`, which browsers decode but link scanners and older
mail clients need not.
* fix(share): count every shared file in the digest, not just the first
* feat(share): let a recipient refuse shares from everyone
Blocking answered "not from this person" but had no answer to "not from
anyone", so the only way to stop a stream of unwanted shares was to name
each sender after they had already reached you.
Stored as a key in the user row's existing `metadata` blob rather than a
column: the share path already holds the recipient's row by the time it
asks, so reading it costs nothing, and a one-bit preference doesn't earn
a migration per dialect. `updateMetadata` merges and refreshes the cached
row, so the switch bites on the very next share.
Refusing everyone reports the same code as refusing one person — which of
the two it is is the recipient's business, not the sender's. Enforced at
both moments the per-sender block is: when the share is issued, and when
a pending invite is claimed. The per-sender list is untouched while the
blanket switch is on, so turning it off restores what it hid.
`GET /share/blocks` now carries `all`; `POST`/`DELETE` take `{ all: true }`
beside the existing `{ username }`. Managed from the same Blocked people
card in the dashboard's Security tab.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(share): keep the digest sweep off a window that still has a timer
The sweep treated an entry as orphaned the moment its window closed, which
is also the moment the node that armed it fires. Claiming an entry is only
exclusive among flushers that can see each other's deletes, so the two
could each claim a share of the same digest and both send. It now waits
out a grace period first, which costs a genuinely stranded digest that
much delay and nothing else.
Both digest listings were capped at 200 with no word when they hit it — a
truncated flush sends a digest that undercounts and reads as complete.
The cap is named and logged.
Also: `#emailHolder` still described share email as off by default, which
it stopped being; the config doc said the batch window defaults to 60s
when it is 90; and the two tests that need several calls inside one window
were racing a 50ms window across four sequential round trips, so they
failed under full-suite load rather than on the behaviour they cover.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(share): stop double-encoding the recipient in two dialog messages
`i18n()` encodes what it returns, replacements included, so encoding the
recipient first showed the entities to anyone whose address or username
contains one. Same pattern already fixed two lines above.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(notification): widen the mysql shown/acknowledged columns
Both arrived from the v1 schema as `tinyint(1)`, where they were flags.
The backend rework changed the writes to a unix second; sqlite (`INTEGER`)
and postgres (`bigint`) took it and mysql did not, so on mysql every
`markShown` and `markAcknowledged` has failed with
ER_WARN_DATA_OUT_OF_RANGE and left the column NULL. Dismissing a
notification never stuck — the unacknowledged count never moved and one
already delivered came back on every reconnect.
No backfill: every reader tests `IS NULL` / `IS NOT NULL` only, so a
legacy `1` keeps meaning "yes" once widened. Guarded on the current type,
because changing a column type copies the table and this directory
replays on every boot.
Not reachable from the test suite — it runs against sqlite and postgres,
both of which already have the right type. Verified by hand against mysql:
`/notif/mark-read` and `/notif/mark-ack` now persist, and a dismissed
share notification is no longer the one a later share folds into.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Daniel Salazar <daniel.salazar@puter.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
ec3c33fea6 | fix: misc hardening fixes (#3600) | ||
|
|
f81b76c1d1 | fix: webdav connectivity fixes for macos (#3597) | ||
|
|
2c852bf6b3 |
✨ PUT-1412 File sharing backend api (#3553)
* refactor(permissions): drop hardcoded group permission map for a flat default
* test(drivers): assert credential-gate intent instead of a 403 proxy
* fix(permissions): report whether a revoke removed anything and persist the linked grant row before the flat view
* feat(permissions): replicate permission invalidations across regions
* feat(share): extend the share table into an index of active shares
* feat(share): query and maintain active shares in ShareStore
* feat(users): add a batched lookup by email
* fix(cache): apply cache updates broadcast from peer regions
* fix(permissions): scope a revoke to the issuer that granted it
* feat(share): add ShareService with a per-day share limit
A share is two writes that belong together: the permission grant, which
authorizes access, and a share row, which makes it listable and ties it to an
fsentry so it dies with the file. Nothing else grants fs:* to a user.
Authorization reuses canManagePermission — an owner satisfies it through the
is-owner implicator, a delegate through an explicit manage:fs:<uid> grant. An
owner may clear any issuer's share of their node; anyone else only the ones
they issued, or their own access. Self-revoke skips the manage gate but still
requires `see`, so it cannot be used to probe for files.
The per-day limit counts shares created rather than live rows, so revoking and
re-sharing cannot recycle a slot, and changing an existing share's mode is not
new reach and does not spend budget. Tunable via share_daily_limit.
* feat(share): expose sharing over HTTP
POST /share, POST /share/revoke, GET /share/shared-with-me, GET /share/shares.
The controller was registered but entirely commented out.
Recipients × items fan out concurrently — every pair is a distinct
(holder, entry) key, so none of them contend — bounded by
runWithConcurrencyLimitSettled, which returns results index-aligned with the
input for the per-pair outcome list. Responses carry usernames only, never
internal ids, and the 404-not-403 rule is preserved so a failed call cannot
confirm a file the caller could not otherwise see. Notifications are fired off
the response path; a share must not fail over its own notification.
Per-request caps on recipients and items bound one call's fan-out; the daily
limit bounds the total.
* feat(share): keep recipients consistent when a shared item changes
* fix(fs): stop listing issuer homes at the filesystem root
* fix(acl): serialize concurrent mode changes on one node and pin app containment on shared paths
* fix(fs): expire signed URLs over entries the signer doesn't own
signFile defaults to a ~317k-year TTL and verifySignature checks only uid,
expires and signature — never the ACL. A recipient who ever signed a shared
file therefore held a permanent, revocation-proof URL to its bytes: revoking
the share did nothing to it.
signEntry now takes the acting user and drops to NON_OWNER_SIGNATURE_TTL_SECONDS
(1 hour) when the signer is not the entry's owner. Owners keep the permanent
default, so no existing client changes behavior.
The signature-authenticated directory listing bounds its children
unconditionally: that route has no session actor, and a signature proves
possession rather than ownership, so a recipient holding a short-lived
directory signature could otherwise mint permanent URLs for every child.
A bounded window is not revocation — the durable fix is a per-entry signature
epoch folded into the HMAC and bumped on any permission change.
* refactor(permissions): drop the unused permission-issuer lookup
listUserPermissionIssuers and its store method listUserPermissionIssuerIds
existed to synthesize the filesystem root from the home directories of everyone
who had granted the caller a permission. That listing is gone — it advertised
folders readdir then refused to open — and the share index answers "who shared
with me" directly, so nothing wants them back.
One removed test only asserted that the call returned an array; the other
covered readLinkedUserUserPerms round-tripping and is kept, rewritten without
the issuer lookup.
* fix(share): return the created share, not just an acknowledgement
* feat(puter.js): add file sharing to puter.fs
share(), unshare(), listShared() and getShares() on puter.fs, following the
existing FS operation shape: positional and options-object forms through
defineOperation, JSDoc overloads as the published signature, relative paths
resolved against the app's root directory.
A bare recipient string is read as an email when it contains @ and as a
username otherwise. Sharing an item with someone who already has it replaces
their access rather than stacking a second grant, so raising read to write is
one more call.
Adds a sharing suite to the API runner, which passes unchanged on node,
browser and workerd. Documents all four methods with runnable examples, and
corrects the FS overview callout that told readers one user cannot read
another's files — true before this, not after.
* feat(gui): add a Shared folder for items others shared with you
A sidebar entry listing everything other users have shared with you, backed by
puter.fs.listShared().
The path is the sentinel `puter://shared` rather than /<user>/Shared: this is a
query, not a directory, and a path-shaped value could collide with a folder
someone actually creates. refresh_item_container and update_window_path both
branch on it to skip the stat there is no fsentry for, and the listing swaps
readdir for listShared.
Entries render at their real paths under their owners' directories — the item
container already preferred an explicit fsentry.path over joining onto the
container, so nothing else had to change. Each carries who shared it and at
what level, which the context menu reads next.
* feat(gui): share items from the context menu
A sharing dialog shaped like its neighbours — options object, HTML-string
template, jQuery wiring, delegating to UIWindow() — with a recipient field, a
read/edit/share dropdown, and the current access list with revoke buttons.
Reached from a new "Share…" context menu entry, which is hidden on items shared
*with* you: re-sharing needs manage, so the dialog would only surface an error.
Those items get "Remove from Shared" in place of Delete. Delete moves an item
to *your* trash, which for someone else's file means moving their data out of
their tree — FSService refuses it, and the user saw a bare 403. Removing your
own access is what the action was reaching for, so that is what it now does.
* fix(share): withdraw what a removed recipient re-shared
* feat(share): report access inherited from a parent folder
* fix(gui): load the puter.js bundle the server configured
* refactor(gui): extract the action icon set into a helper
* feat(gui): surface Shared in the file browser
* feat(gui): manage access from the share dialog
* test(share): cover access inherited from a parent folder
* fix(share): keep downstream access from surviving a delegate who leaves
* fix(gui): name the real owner in the share dialog
* fix(gui): page through every shared item instead of the first 50
* feat(share): return item metadata with a share
* fix(share): invalidate a holder's cache when the entry is deleted
* fix(gui): treat items inside a shared folder as someone else's
* feat(permissions): let manage inherit down the filesystem tree
Access already reached descendants through the ancestor chain while authority did not, so someone trusted to manage a shared folder could re-share the folder but nothing inside it, and could not see who had access to a file within it.
A manage-inherits-from-ancestor implicator resolves it in the permission layer, beside is-owner, so every caller agrees rather than just ShareService. It consults only the immediate parent — resolving that re-enters one level up, making a chain of depth d cost d checks rather than d².
That makes two cascade gaps reachable, both fixed here. A revoke now walks the subtree, since a grant on a descendant can rest on authority held at the folder. And it stops at a delegate whose authority survives another issuer, because what they granted was never theirs to lose.
Also pins that manage is not transitive: granting it needs manage:manage:fs:<uid>, which only the owner holds, so delegation is one level deep by construction.
* fix(gui): offer sharing inside a folder you manage
The menus encoded "manage does not inherit" and would now hide an action that works. The Shared listing records each root's mode; the menus resolve a child's by longest matching ancestor, loading on demand so a deep link or restored window works too.
* fix(share): make the daily share limit hold under concurrency
* test(share): cover concurrency, measure cost, and name cases for what they verify
* fix(gui): import the ownership helpers the item menu calls
The single-item context menu handler calls is_owned_by_me and
shared_mode_for, but the imports were only ever added to
generate_file_context_menu.js — so every right-click on an item threw a
ReferenceError before the menu could build, and the non-owner Delete
gating never ran.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(share): authorize before resolving the recipient
share() looked up the recipient in parallel with the entry, before the
manage check — and the two failures carried different error codes. Any
verified user with a real entry uid could probe arbitrary emails and
usernames for account existence, at no quota cost. Resolve the entry,
authorize, and only then resolve the recipient: an unauthorized caller
now sees the identical safe 404 whether or not the recipient exists.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(permissions): broadcast permission row-cache invalidations to peer regions
Every publishCacheKeys call for the u2u, u2a, and access-token row
caches omitted broadcast, so a revoke only cleared the mutating
region's Redis. A peer region applied the replicated generation bump,
re-scanned, read the deleted row from its own still-warm 5-minute row
cache, and re-warmed the flat view from it — revoked access outlived
the revoke by the row-cache TTL instead of the intended 60-second
bound. CacheReplicationService already consumes these events; the
emits were just never sent.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(fs): refuse to rename an entry owned by another user
remove and move both refuse to act on an entry the caller does not
own, even when the ACL allows the write — rename had no such guard, so
a write-mode share recipient could rename the owner's file, or the
shared folder itself, rewriting the owner's whole subtree's paths.
rename now takes the acting user and applies the same policy.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(permissions): decide a flat delete from the primary, not a lagging replica
revokeUserUserPermission deletes the SQL grant, then only drops the
flat KV entry once no issuer still grants the permission. That
remaining-check read through the row cache the delete had just
invalidated, straight to a replica — under any lag the deleted row
reappeared, the flat delete was skipped, and the stale rows were
re-cached for another five minutes. Grant-path flat entries carry no
TTL, so the holder kept working access with zero SQL rows behind it,
invisible to every listing. The check now reads the primary and
re-warms the cache with what it actually saw.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(permissions): keep a failed remote flat-invalidation from crashing the process
The outer.permission.flatInvalidated applier was fire-and-forget with
no catch, and it awaits a KV delete — one transient KV error while
applying a peer region's revoke became an unhandled rejection, which
is process-fatal under default Node. Its sibling appliers were already
guarded; this one now logs and moves on, leaving the entry to the next
invalidation or its TTL, same as a lost event.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(share): revoke every requested item and recipient, not just the first
revokeShare destructured only the first recipient and first item while
the parsers accept arrays up to the request caps — unshare({items:
[a, b, c]}) returned success having revoked only a, leaving access the
caller believes is gone. Revoke now fans out over every (recipient,
item) pair exactly like POST /share, reports per-pair outcomes, and
sums the revoked count; the response stays backward compatible.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(share): only a confirmed email designates a recipient
Recipient resolution by email accepted unconfirmed accounts, so
pre-registering someone else's address (unconfirmed) was enough to
receive shares meant for them once no confirmed account held it.
An email now only resolves to an account that has confirmed it;
username shares are unaffected.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(share): accept tilde-rooted paths like the FS routes do
The SDK resolves relative paths to ~/..., but the share routes never
expanded the tilde — a ~-prefixed string was read as a uid and every
relative-path call 404'd. Item parsing now treats ~ as path-shaped and
expands it to the actor's home with the same helper the legacy FS
routes use, on share, revoke, and the shares listing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(share): walk a directory revoke by parent linkage, not path prefix
listByFsentrySubtree matched descendants with fsentry_id = ? OR path
LIKE ?, which has two problems: fsentries.path is lazily backfilled
and NULL on old rows, so those descendants' shares silently survived a
directory revoke, and the OR'd predicates forced a scan of every
active share. A recursive CTE over parent_id — the same shape the
lineage resolver already uses — covers every descendant and runs on
idx_parentId_name.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(gui): give each item its own share dialog
single_instance keyed the dialog on the app id alone, so opening
Share… on a second file focused the first file's dialog — typing a
recipient there granted access to the wrong file, with only the title
hinting at it. The dialog is now instanced per path: same item
refocuses, different item opens fresh. Also stops pre-encoding the
title, which UIWindow encodes again.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(permissions): let manage answer a write check
A manage grant let its holder re-share a folder but not work in it: the
ACL mode family stops at write, and the fs exploder had no rule for the
narrowest mode, so `manage:fs:<uid>` never satisfied `fs:<uid>:write`.
Fold manage into the candidate list for every non-manage mode, in both
the access-token branch and the scan branch, and give `write` an (empty)
exploder rule so the manage arm is emitted for it too.
* fix(fs): authorize restructuring by write on the parent
* fix(fs): let a share recipient work inside a shared folder
rename, remove and move refused outright when the entry belonged to
someone else, so a recipient with write could neither delete nor rename
anything inside a folder shared with them. The GUI compounded it by
hiding Delete for any item it did not own.
Authorize the three by ACL write on the entry's parent. For an owner
that is the same answer; for a recipient it grants the inside of a
shared folder and withholds the folder itself, whose parent is the
owner's private tree.
Deleting sends the item to its owner's trash rather than the deleter's,
so it leaves the recipient's view without leaving the owner's account
and without changing hands. A move may not otherwise carry someone
else's entry out of their tree.
* fix(fs): give a new entry to the owner of the folder it lands in
A file a share recipient added to a shared folder was recorded as
theirs while living in the owner's tree, so a subtree could hold rows
belonging to several people — and the storage it consumed was checked
against the writer while being counted against the owner.
Take the owner from the parent row at every insert, charge the
allowance to that owner, and hand a moved entry over to the tree it
moves into. An entry now always belongs to whoever owns the directory
holding it.
* feat(fs): address shared entries as ~/share/<uid>
A recipient could read the owner's whole path off any shared entry —
where they keep the file and what sits beside it, neither of which the
share is about.
Give shares their own namespace. `~/share/<entry-uid>/rel/path` resolves
to the real path on the way in, and outgoing paths are rewritten to it
on the way out. Entries the actor owns pass through untouched, so no
existing client contract moves.
* revert(fs): mask only the directory bar, not the addressing
|
||
|
|
d3c9828207 | fix: metering gui maths (#3592) | ||
|
|
87180b1081 | fix: metering (#3591) | ||
|
|
d22b656df8 | fix: metering issues with addons (#3588) | ||
|
|
fb7968a1c7 | fix: metering hardening; handle burst of unfinished ai requests (#3585) | ||
|
|
f15d835eeb | fix: restrict openai and anthropic compatible endpoints to be subscription (#3583) | ||
|
|
27c4506e02 |
feat: usage rendered as credits on a 50c base, free allowance doubled (#3569)
* feat: usage rendered as credits on a 50c base, free allowance doubled Usage is now displayed in credits: a configurable creditsPerDollar rate (default 2,000, making the free tier's 50c allowance an even 1,000-credit base) ships with the metering usage response, and the dashboard's usage cards and per-API table show raw credit numbers through one shared formatter. The dashboard budget math is also fixed — capacity is spend plus server-netted remainder, so held purchased credit can never render as negative usage. The registered-user free allowance doubles to 50c, tier display names move to Basic/Plus/Pro, and dead referral-promise strings leave the English dictionary. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor: the credits display rate exists only in deployment config No default in code and no rate on any wire the plan surfaces read: a deployment that doesn't configure creditsPerDollar renders usage in dollars, exactly as before credits existed. The usage endpoint sends the rate only when configured, and nothing in code or comments states what any deployment's rate is. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor: metering reports pre-scaled credits, never raw amounts The usage, per-app usage, and cost-catalogue endpoints multiply every monetary field by the config multiplier (renamed creditMultiplier) before responding, and flag the unit; the multiplier itself never ships. Counts, units, and byte figures pass through untouched. With no multiplier configured the endpoints report raw amounts and clients render dollars, as before credits existed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * fix: absent addon fields stay absent when scaling to credits Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
4bc969b99e | Update RecommendedAppsService.ts | ||
|
|
25a5799088 | Update RecommendedAppsService.ts | ||
|
|
08f075d774 | Update RecommendedAppsService.ts | ||
|
|
8ed8feed4b | fix: don't let an aborted upload take down the process (#3557) | ||
|
|
22f5bf5429 | fix: duplicate emails (#3556) | ||
|
|
d5ae5a0049 |
🔧 PUR-1072: Flatten driver permissions to hardcoded values (#3545)
* refactor(permissions): drop hardcoded group permission map for a flat default * test(drivers): assert credential-gate intent instead of a 403 proxy |
||
|
|
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
|
||
|
|
20b3b88e39 |
metering: big fixes to metering + jsdoc types (#3547)
Changes are: - global egress metering - remove file egress cost - introduce file op cost for the per request cost s3 has - enforce fs read/download etc to through 402 when out of usage; allow for subdomains - enforce kv metering when out of usage through 402; allow for workers - jsdoc as source of truth for puter.js types - kv driver caching for get and batchget operations with decreased costs |
||
|
|
afad3e37bf | fix: server health tolerance for primary read (#3542) | ||
|
|
a6b2161b2c | fix: misc sec fixes (#3540) | ||
|
|
79d4201f12 |
fix: rate limits, AI routing, and a type-check gate (#3529)
- declare rate + concurrency limits on every route and driver that lacked one - add acquireConcurrent for websocket connections and the DAV mount - bucket AI models by identity key only; keep resold duplicates of any vendor - skip recently-failed provider routes; cap the fallback chain at 3 attempts - let full-access access tokens bind a worker to an app their own user owns - cache resolved subscriptions so tiered limits don't add a round trip |
||
|
|
42299a64ed |
Update recommended apps list
Add 'blockarena' and remove 'pretty-tiles', 'galaxy-troops', and 'blend-fruits' from the recommended apps list. |
||
|
|
344db5cb0b |
Remove vault from recommended apps
Drop `vault` from the recommended apps list so it no longer appears in app recommendations. |
||
|
|
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> |
||
|
|
c7edfc0c74 |
perf: improve metering perf + add extra compat for monthly charges (#3513)
closes PUT-1445 and PUT-1446 |
||
|
|
6df292f495 | fix: PUT-1429 (#3508) | ||
|
|
62be61642d |
fix: phantom name conflict when pasting onto a just-renamed path (#3495)
Renaming (or moving) an entry left the OLD path's cache key serving the pre-update entry for the full 60s TTL: updateEntry invalidated only keys derived from the updated row, whose path is already the new one. Pasting an item under the freed name then hit the stale cache in the collision check and reported a conflict for a file that no longer exists — and accepting the Replace it offered deleted the renamed file, since the stale entry carries its uuid. Read the pre-update entry when the patch changes the path and invalidate its keys alongside the new ones. The move flow was shielded by the outer.gui.item.moved cache-invalidation handler at the controller layer; rename had no such band-aid, and fixing the store covers every caller regardless of which events fire. |
||
|
|
5c2a423658 |
fix: progress window covering paste conflict dialog; ghost row after Replace (#3493)
* fix: progress window covering paste conflict dialog; ghost row after Replace Pasting a copied file onto a name collision buried the Replace/Cancel dialog under a stuck 'Preparing...' progress window, and answering Replace left a stale duplicate row in every client. - helpers.js: copy_clipboard_items armed its delayed progress window with a 0ms timer (its siblings use 2s), so the window opened instantly over the dialog. Use 2s, and in all three of copy_clipboard_items / copy_items / move_items pause the timer while a conflict dialog (or the own-location / trash-deny alerts) is waiting for input, re-arming it after. A window that opens mid-operation now shows the current file instead of a stuck 'Preparing...', and the trash-deny bail no longer leaks a timer that opened an orphan window after the operation ended. - LegacyFSController: /copy and /move dropped the legacy 'overwritten' response field and never emitted item.removed for the entry an overwrite deleted, so clients kept a ghost row until re-listing. Resolve the entry before the operation, return it, and emit item.removed on success. - helpers.js: copy_items read resp[0].overwritten but removed resp.overwritten (always undefined), and the data-uid cleanup selectors were unquoted — invalid CSS when a UUID starts with a digit. Fixed all three sites. * test: pin the v1 overwrite/collision wire contract for move and copy The collision tests asserted only statusCode 409, which is how the item_with_same_name_exists code regressed to 'conflict' unnoticed and broke every replace/skip prompt in the GUI. Assert the legacy code and entry_name explicitly, and add controller tests for the overwrite path: the replaced entry must ride along as 'overwritten' in the /copy and /move responses and be announced via outer.gui.item.removed so clients drop its row. |
||
|
|
1a9f025db6 |
fix: surface name collisions when pasting instead of failing silently (#3492)
Cut+paste onto a folder containing an item with the same name failed with no feedback: the dashboard's moveClipboardItems swallowed the error into console.error and cleared the clipboard, and the rewritten backend broke the v1 wire contract the GUI's conflict prompts key on. - FSService: move() and copy() collisions again return item_with_same_name_exists + entry_name (the contract the write path already preserves) instead of a generic 'conflict' code. This also restores the desktop's existing Replace/Skip dialogs, which were silently broken against the new backend. - Dashboard: moveClipboardItems now mirrors the desktop's move_items conflict flow (Replace / Replace all / Skip / Cancel, retry with overwrite) and surfaces other move errors in an alert. - keyboard.js: the desktop's global Ctrl+V handler threw an uncaught TypeError on every paste in dashboard mode (its file container has no data-path); guard it — which also prevents a double-paste if dashboard containers ever gain one. |
||
|
|
a4667073f5 | Update RecommendedAppsService.ts | ||
|
|
116d6e6663 | tests: big test push for better coverage (#3490) | ||
|
|
7d0d44aef9 | feat: slack alarms (#3489) | ||
|
|
f6a50c4fb4 |
Update recommended apps list order
Reorders and updates the recommended apps list: removes 'butler', 'code', and 'traffic-tap-puzzle'; adds 'contacts', 'diagram', and 'basketball-tap' (relocated from end); adjusts overall ordering of several apps. |
||
|
|
c8113d7514 |
fix: auth message popups (#3487)
* fix: auth message popups * remove v1 auth |
||
|
|
3a11d0d1f1 | feat: sandboxed app workers (#3486) | ||
|
|
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> |
||
|
|
2c9ae3489e |
Suggested apps: rank registered apps above the editor fallback
For extensions with no intentional built-in mapping (doc, docx, and every other unmapped type), suggestionsForExtension fell back to ['editor'], and #resolveForExtension always placed built-ins ahead of apps from app_filetype_association. Since suggested[0] drives the GUI's double-click open path and /open_item, a .docx defaulted to opening as plain text in editor even when a word processor explicitly registered the extension. Tag the unknown-extension result as a fallback and order third-party filetype-association apps ahead of it. Intentional mappings (code, txt, md, images, pdf, media) keep built-ins in the head slot as before, and the editor guess still appears as a last-resort option. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
0e1f617401 | fix: errors for bad keys (#3475) | ||
|
|
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. |
||
|
|
1e280601c6 |
fix: showSaveFilePicker from external websites (#3450)
Saving a file via puter.ui.showSaveFilePicker from a third-party website (popup flow) consistently failed with a DataCloneError alert after clicking Save, and saving over an existing filename showed a raw error instead of the Replace/Cancel prompt. Two bugs: 1. privacy_aware_path is a curried factory (world => fspath => ...), and initgui.js is the only module that imports it directly — so the popup save handler's privacy_aware_path(res.path) returned the inner function, which postMessage cannot structured-clone. Every other call site resolves the bare name to the correctly bound window.privacy_aware_path global, which is why only the external-site popup flow was broken. Use the global at the call site and import the factory under a distinct name so a bare call can't silently resolve to it again. 2. The v2 backend returns `conflict` for a same-name write, but the v1 wire contract is `item_with_same_name_exists` + `entry_name`, which the GUI's save dialogs key on to offer the overwrite prompt. Restore the legacy code/field on the write-conflict error and carry HttpError.fields through the /batch per-op error serializer. Verified end-to-end against a local backend: fresh save resolves the caller's promise with the signed saved_file and closes the popup; saving an existing name shows Replace/Cancel and Replace overwrites. Backend suite shows no new failures. |
||
|
|
e6e6e3ba9a | chore: cleanup API driver calls PUT-1324 (#3448) | ||
|
|
e85cd9d53d | feat: readdir with depth (#3446) | ||
|
|
48cc706ad3 |
feat: paginated fetching for all (#3431)
* feat: paginated fetching for all * fix: metering top up gui reporting |
||
|
|
e35b57068b | test: add extra tests (#3428) | ||
|
|
5faed55076 | fix: autoclaim app when making a subdomain (#3426) | ||
|
|
89f9f9728f |
fix: PUT-1355 PUT-1351 PUT-1208 (#3420)
* fix: PUT-1355 PUT-1351 PUT-1208 * fix: dev center header |