* docs: remove Discord and Reddit links
Drops both links from the docs README (header row and Support list) and
from the footer generated on every built docs page.
* docs: remove Mastodon link
* refactor(puter-js): collapse puter.perms request* to resource + access
Fifteen request methods differed only by a folder name or an access level, so
every new resource meant another method. Replace them with requestFolder,
requestApps, requestSubdomains and requestAppRootDir, each taking the access
level as an argument.
The old names stay as @deprecated aliases: puter.js ships unpinned from
js.puter.com/v2, so removing them would break live apps. They remain in the
generated declarations because stripInternal has no effect on declarations
emitted from JavaScript, and hand-omitting them would break TypeScript callers
the runtime still serves.
Also drops the user-to-user and user-to-group grant wrappers (groups.js and the
grantUser/grantGroup half of grants.js) plus the req_ shim, none of which were
documented or called. The app, origin and dev-app grants stay: the dashboard
uses puter.perms.revokeApp() to clear grants on app uninstall.
* docs(perms): document the collapsed puter.perms surface
Replace the twelve one-method-per-task pages with requestFolder, requestApps
and requestSubdomains, and rewrite the Perms overview around the seven public
methods. The deprecated aliases keep working but are no longer documented.
Boy Scout: drops the long-dead commented-out grantUser/revokeOrigin sidebar
block for pages that were never published.
* refactor(perms): drop unused user-to-user and user-to-group permission routes
Filesystem access is shared through /share, which records the grant so the
owner can see and revoke it. The older direct-grant paths were left behind with
no caller anywhere - not the GUI, not a doc, not an app: grant-user-user
(already a 501 stub), revoke-user-user, grant/revoke-user-group, and the five
/group/* CRUD routes.
Removing them orphans PermissionService.grant/revokeUserGroupPermission and its
group-members cache bump, the three PermissionStore group writers, and six
GroupStore methods, so those go too.
What stays, and why:
- grant/revokeUserUserPermission - ACLService and ShareService power fs.share
through them.
- The group permission read path (#scanUserGroup, readUserGroupPerms) - a
migration seeds the admin group unrestricted driver access, so it is
load-bearing.
- GroupStore getByUid/addUsers/removeUsers - signup, save_account, OIDC and the
self-hosted default user assign group membership.
No schema change: user_to_user_permissions, user_to_group_permissions and their
audit tables are untouched. Group rows now come only from migrations, so tests
that need one seed it with SQL the way a migration does.
* refactor(perms): reduce GroupStore to membership writes
With the /group/* routes gone, `getByUid` had no production caller left — the
routes were the only thing that read a group back. Removing it takes the row
decoder and the GroupRow type with it, since they exist only to shape its
result.
What remains is `addUsers`/`removeUsers`: signup, save_account, OIDC and the
self-hosted admin bootstrap all assign group membership. Permissions attached to
a group are read through PermissionStore, which joins the junction table itself
and never needed the store.
Tests that wanted a group id now select it, which is all `getByUid` was doing
for them.
* chore(perms): drop the three by-hand groups no code reads
freeai, experimental and dangerous exist in prod but in no migration — they
were added by hand when hardcoded permissions were keyed by group name. That
map is now a flat per-user floor (`default_user_permissions`), so a group
nothing looks up grants nothing.
Guarded rather than unconditional, because both tables the delete can reach
cascade: dropping a group that still carries permissions or members would
silently revoke them from every member. Only a group with neither goes. One that
survives has dependents and needs a deliberate decision — query
user_to_group_permissions by group_id to see what it holds.
system, admin, user and temp are untouched: config names two of them and code
names the others.
Matches on `extra.name`, not `metadata.name` — `metadata` carries the display
title and colour, and `critical: true` is set on all of these including freeai,
so it does not discriminate.
* feat(puter-js): collapse puter.perms onto request(resource, details) + check()
One method per task meant a new method, doc page and sidebar entry for every
resource. `request` now takes the resource and a payload whose accepted fields
depend on it, and `check` answers the same question without prompting.
request('folder', { name: 'Documents', access: 'write' }) -> path
request('apps', { access: 'read' }) -> boolean
request('email') -> address
check('folder', { name: 'Documents', access: 'write' }) -> boolean
Returns stay per-resource: a folder gives its path, email the address, the rest
a boolean, and anything denied is falsy so one `if` covers both.
An array asks for several at once. Everything already held is settled first, so
the prompt covers only what is missing and does not appear when the whole set is
held - the user answers once for the lot. `check` answers per entry, in order,
so a caller can tell which parts are missing rather than only that some are.
Each resource declares four things in one registry entry: how to ask for it
alone, whether it is held, the strings a batch pools into a prompt, and the
value once held. The strings themselves are defined once in
lib/permissionStrings.js, so a request and its check cannot name them
differently. `check` is built on /auth/check-permissions, already live and
already used by UI.js, and it throws rather than answering false when the check
cannot run: a caller that cannot tell "denied" from "never ran" would prompt
someone who had already granted it.
Backward compatibility: all 22 older methods stay callable and typed, marked
@deprecated with the call that replaces them. A lone string still routes to the
raw-permission path - no resource name contains a `:` and every permission
string does, so the two forms cannot collide. The grant/revoke app methods are
untouched; the consent dialog and the dashboard's uninstall path use them.
Also drops three copies of the access-level assertion onto one shared
validator, and gives `appRootDir` a non-prompting server probe, since
`app-root-dir:` only resolves while a grant is being written and a permission
check on it always answers false.
* docs(perms): document request() and check() as the perms surface
Five per-method pages became one `request()` page carrying the resource table,
the batch form and the raw-string escape hatch, plus a `check()` page. The
overview is rewritten around the two methods.
requestAppData's page is re-homed as /Perms/appData rather than deleted - its
scope table, private-entry guidance and lifetime notes are not signature
documentation and have nowhere else to live. Inbound links from KV/set.md and
Objects/app.md follow it.
Playground examples move to the new call form. They are not wired into
examples.js, but an example demonstrating a deprecated method is worse than one
nobody loads.
* fix(perms): keep /auth/revoke-user-user as a deprecated route
Dropping this route with the rest of the unused user-to-user plumbing went too
far. The grant side is retired and stays retired - puter.fs.share() is the only
way in - but access those grants left behind has to remain withdrawable, and a
caller reaching the endpoint over HTTP directly had no replacement. Revoking can
only ever narrow what someone can reach, so keeping it carries no risk.
revokeUserUserPermission never left the permission service; it is load-bearing
for puter.fs.share(). This only re-wires the handler to it, with the gates it
always had.
Nothing in this repo calls the route, which makes it exactly what a later
cleanup reads as dead, so a test pins the registration and its gate alongside
the restored 400 and grant/revoke round-trip cases.
The 501 stub at grant-user-user and the never-called /group/* routes stay
deleted, as does puter.perms.revokeUser - puter.fs.unshare() replaces it and
falls back to live grants when no share row exists.
* fix(perms): let a write grant satisfy a read check on apps and subdomains
`apps-of-user:<uuid>:write` covers managing the user's apps, which includes
reading them, but nothing said so to the permission system. Prefix implication
only widens the other way — an `apps-of-user:<uuid>` grant covers both modes —
so a scan for `:read` missed a `:write` grant, and `puter.perms.check('apps')`
reported an app holding write as holding nothing. A batched request would then
prompt again for access already granted.
Adds the read-from-write exploder for both namespaces, mirroring
`fs-access-levels`. The widening runs one way only, and does not cross into
another user's namespace; both are covered by tests.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* fix(perms): answer the app-root-dir check without provisioning it
`/auth/request-app-root-dir` conflates two questions: may the caller claim its
root directory, and where is it. The second provisions `AppData/<uid>` on first
ask, so a caller that only wanted the first — `puter.perms.check('appRootDir')`
— created a directory by asking about it.
Adds `check: true`, which runs the same actor guard and stops at the answer.
A caller that may not claim it still gets the 403, so the flag can't widen
anything. Existing callers are unaffected: without it the route behaves exactly
as before.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* fix(perms): put request() and check() on one path
`request` dispatched to the old per-task methods while `check` asked the
permission tables, so the two answered different questions about the same
access. Concretely, before this: `request('folder', { access: 'write' })`,
`'apps'`, `'subdomains'`, `'appData'` and `'permission'` prompted every time,
whether or not the access was held — which the docs said they wouldn't;
`check('folder')` reported false for a folder the app could read through an ACL
grant that no `fs:` string names, so a batch prompted for it needlessly; a
batch entry for `'appRootDir'` skipped the post-grant retry the single call
does, resolving `undefined` after a grant that had in fact succeeded; and an
N-entry batch made N permission reads plus 2N `whoami` calls.
Both now run the same pipeline — resolve the permission strings, read what is
held once, prompt for the remainder, resolve each entry — with per-resource
hooks for the parts only that resource can answer. So a batch costs one
permission read and one `whoami`, a check reports exactly what a request would
skip the prompt for, and `'folder'` uses the same stat-or-permission reading in
both.
Also:
- A resource is looked up as an own property, so `request('constructor')` is
the permission string it always was rather than a TypeError.
- A permission read that fails no longer decides anything: `request` falls
through to the prompt it would have raised anyway, `check` throws. Before,
`check('appRootDir')` folded a failed check into "not granted", which is what
the documentation says must not happen.
- Drops `requestFolder`, `requestApps`, `requestSubdomains` and
`requestAppRootDir`. They were added in this branch and immediately deprecated
— never shipped, and `request()` no longer needs to route through them. The 22
methods that did ship keep their exact behaviour, prompting without consulting
what is held, which the suite now asserts alongside the new behaviour.
- Documents `'appRootDir'`, which was a supported resource in every overload and
in `PermsResource` but named in none of the docs.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* fix(perms): act on review of the three preceding commits
- `src/puter-js/test/perms.test.js` still called `requestApps`,
`requestFolder`, `requestSubdomains` and `requestAppRootDir`, which the
previous commit removed. Four cases in the interactive browser harness threw.
Pointed at `request(...)` instead.
- `request('appRootDir', …)` made two round trips where the shipped method
makes one: a read-only probe, then the call that names the directory. A
request is going to claim it either way, so the claim is now the check, and
the entry it returns carries through to the result. `check` keeps the
read-only mode, which is the reason that mode exists. Matters because the
route sits on the FS_SIGN bucket, shared with signed-URL minting.
- `requestPermission` is one of the shipped methods, and the previous commit's
message was wrong to say all 22 keep their exact behaviour: it forwards to
`request`, so it now settles a permission the caller already holds instead of
prompting for it. The value can differ, not just the prompt count — a user who
would have clicked Deny on a re-prompt used to get `false`. It is the more
honest answer (the app does hold the access, and denying a re-prompt never
took it away), but it is a change, and the suite assertion had been switched
to an unheld permission, which hid it. Asserted both ways instead, in the unit
tests and the API suite.
- An entry that names no permission no longer rides a grant given for the other
entries in the same call. Unreachable today — every resource either names one
or reports itself held — but nothing pinned it.
- Reverted three type-union reformats in `LegacyFSController.ts` that a
formatter had folded into the app-root-dir commit. That file was not
prettier-clean to begin with; reformatting it is somebody else's change.
- Docs and types: `Perms.md`'s `appRootDir` row now matches `request.md`'s,
`check.md` says that a `true` is per entry and a batch still prompts if any
one entry is missing, and `types.js` no longer names `requestFolder` /
`requestAppData` in prose that ships in the generated declarations.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
---------
Co-authored-by: Daniel Salazar <[email protected]>
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
Reordered the recommended app list so app-center appears earlier and dev-center is kept at the end. This keeps the default app suggestions aligned with the expected homepage/app-center placement without changing behavior beyond ordering.
The Usage card gives each of its three columns a meter and two lines. The
plan column had a boxed, tinted, bordered paragraph instead — and at the
~230px a third of that card comes to, thirty words of it wrapped into a
five-line ribbon standing twice as tall as the storage and resources meters
beside it. The box is the shape we use for the dunning banner. A trial is a
date you should know about, not a fault, and it was reading as the loudest
thing on the card.
So it takes the slot its neighbours use for "n% of 100 GB": a quiet hint-
coloured line under the badge. All three columns now measure the same.
The copy stopped repeating itself while it was there. The badge said "ends
Sep 20" and the sentence beneath it said "ends on September 20, 2026" — two
elements, one fact, and neither of them answering the question someone
actually has, which is how much room is left to decide. The badge now
carries "31 days left" and the line under it carries the consequence:
"First charge Sep 20 unless you cancel." Billing keeps the full sentence and
the opt-out, and Manage is directly below.
`fingerprint.ts` reads the device fingerprint from a request body or, for
authenticated requests with no body to carry it, from
`x-puter-device-fingerprint`. That header is documented there as "the
header the GUI may send the device fingerprint on for non-signup
requests", and the middleware has always honoured it.
A browser could never actually send it. The CORS allowlist in
`#installCors` does not include it, so the preflight comes back without
it in `Access-Control-Allow-Headers`, the browser abandons the request,
and `fetch` rejects before anything reaches the server. Nothing in tree
sends the header today, which is why this went unnoticed: the first
client to try it sees every call fail with a network error rather than a
readable status.
Adds the header to the allowlist, and a preflight test asserting the
browser is allowed to ask for it.
- Implemented MetaProvider to interface with Meta's Muse Spark models.
- Integrated MetaProvider into ChatCompletionDriver, allowing for dynamic model selection.
- Added unit and integration tests for MetaProvider to ensure functionality and reliability.
- Updated documentation to include Meta as a supported vendor for AI models.
Adjust the share-notification email copy to reference the current "Shared" files section instead of "Shared with me," in both HTML and plain-text variants. This keeps onboarding messaging aligned with the UI.
* redesign: share notification emails
The share digests were an unstyled fragment: a 520px div with a couple
of paragraphs, no preview line, no small-screen or dark-mode handling,
and an html-only body that left every text-only client reading a machine
down-conversion of the markup.
Both digests (`file_shared_with_you` and `file_shared_invite`) now render
through one layout: a 600px table column that goes full width under
600px, every color written inline so a client that drops `<style>` still
gets the design, with the stylesheet carrying only what inline CSS can't
express — the small-screen and dark-mode overrides. The shares are a
tinted panel of hairline-separated rows rather than loose text, the call
to action is a cell-padded button Outlook can draw, and the reason for
receiving plus the unsubscribe sit below the card where they belong. No
images at all, so nothing depends on an asset a client may refuse.
Templates may now carry a `text` part, and both digests do: it goes out
beside the HTML as multipart/alternative, which is what plain-text
clients, screen readers and spam scoring prefer.
Copy: a preview line that adds to the subject instead of repeating it, a
heading before the list, and an honest unsubscribe label — the link
silences notification mail account-wide, not just shares. The invite now
says what to do with the address and that an existing account can claim
the shares by confirming it.
Also drops `share_by_username` and `share_by_email`, unreferenced since
the digests replaced them.
* fix: widen the template map to the interface before compiling
`Object.entries` over the literal map yields a union with a distinct
type per template, and only the share digests carry a `text` part —
reading it off the union is an error the build tsconfig hides
(`noCheck: true`) and `npm run typecheck` catches.
* feat(dashboard): rewrite Files-tab sharing as a from-scratch modal
The Files tab's Share… action opened UIWindowShare, a desktop UIWindow,
which doesn't fit the dashboard. Replace it with UIShareModal, built on
the same overlay pattern as UIItemPropertiesModal: a centered card on
desktop, a bottom sheet on mobile, styled with the dashboard's design
tokens (dark mode included).
Wiring follows the onShowProperties precedent: generate_file_context_menu
gains an optional onShare hook that TabFiles supplies; every other caller
falls back to the desktop share window unchanged.
Feature parity with UIWindowShare — grant by email/username with an
access level, list who has access (owner, direct, and inherited-from-
ancestor grants), change a grant's mode, revoke — plus:
- revoke confirmation inlined into the row instead of a stacked UIAlert
- real form semantics: Enter submits, button disabled until input,
spinner while in flight, API errors keep the typed name for correction
- dialog a11y: focus moves in on open and back on close, Tab is trapped,
Escape closes (first Escape cancels an open revoke confirmation),
status messages announce via aria-live
- backdrop close keyed on where the press started, so a text-selection
drag out of the input can't dismiss the dialog
- name-derived avatar colors and a loading state for the first fetch
Also fixes a double-encoding bug (shared by UIWindowShare) where mode
labels ran through html_encode twice, rendering "Can edit & share".
* fix(dashboard): keep keyboard focus inside the share modal across re-renders
Changing a grant's mode, revoking, or Escape-canceling the inline
confirmation re-renders the access list (or disables the focused
control), which dropped focus onto <body> - outside the dialog's Tab
trap and invisible to screen readers. Focus is now restored explicitly
after each of those actions: to the same holder's control when it
survives, else to the dialog itself (tabindex=-1), and the Tab trap
steps back into the cycle when focus rests on the dialog container.
A failed grant likewise returns focus to the recipient input.
* fix(dashboard): give share modal controls 44px touch targets
On touch devices the close (32x32) and revoke (30x30) buttons and the
26px-tall per-row mode select were below the 44px minimum. On coarse
pointers the icon buttons now hit-test at 44x44 through a centered
pseudo-element (visuals unchanged, and the row spacing absorbs the
overhang without overlapping neighbors), and the row select gets
taller padding. Fine-pointer rendering is untouched.
* fix(dashboard): clear the iOS home indicator in the share bottom sheet
The app opts into viewport-fit=cover, so on notched phones the
bottom-docked sheet extends to the physical screen edge and its last
row sat under the home indicator. The sheet body's bottom padding now
adds env(safe-area-inset-bottom), matching how the codebase pads other
bottom-docked surfaces.
* fix(share): say 'Updated access' when changing a grant's mode
Changing someone's access level reported 'Shared with X', the same
message as a fresh grant, which reads as if a new share happened.
Both share dialogs now confirm mode changes with a dedicated
'Updated access for X' message. The UIWindowShare call also drops the
html_encode() around the recipient - i18n() already encodes
interpolations, so the wrapper double-encoded.
* fix(dashboard): align the share dialog's accessible name with the properties modal
The dialog's aria-label read 'name - Share' with an em dash while the
sibling properties modal uses plain 'name Properties'; screen readers
announce the dash as noise. Use the same name-then-noun pattern.
* fix(dashboard): ellipsize the share input's placeholder when it overflows
At phone widths the 'Add people by email or username' placeholder was
clipped mid-letter; text-overflow: ellipsis truncates it cleanly.
* fix(dashboard): raise the share selects' chevron contrast in light mode
The hardcoded slate-400 stroke measured ~2.6:1 against light surfaces,
below the 3:1 minimum for non-text indicators. Light mode now uses
slate-500 (~4.7:1); dark mode keeps slate-400, which already clears
5:1 there.
* fix(dashboard): stop Files-tab shortcuts from firing behind the share modal
The document-level keydown.tabfiles handler kept running while the
share (or item-properties) modal was open. With focus on any of the
modal's buttons or selects, Enter and Space were preventDefault-ed
before they could activate the control, arrows could not drive the
mode selects, and letter typeahead was hijacked into row typesearch —
while Enter opened the selected row behind the overlay, Delete moved
it to Trash, and Cmd+A/C/X/V acted on the hidden list. Yield the
keyboard to the modal for as long as one is up; its own handlers
already cover Escape and Tab.
* fix(dashboard): keep the on-screen keyboard down when the share sheet opens
Autofocusing the recipient input popped the keyboard over the bottom
sheet the moment it opened, hiding the access list before the user had
chosen what to do — the same reason the revoke flow already focuses
the dialog instead of the input. On touch-primary devices give the
dialog container initial focus (which also anchors the Tab trap);
desktop keeps the input autofocus.
* fix(dashboard): give the share sheet's confirm and submit buttons 44px touch height
The inline revoke confirmation's Cancel/Remove pair rendered ~29px
tall on touch — small targets 6px apart where one of the two is
destructive — and the submit button was fixed at 38px. Grow both to
44px under pointer: coarse, matching the standard the modal's icon
buttons already meet. The touch block moves below the confirm-button
base rules it now overrides, since a media query adds no specificity
and source order decides.
* fix(dashboard): name the person in the share rows' accessible labels
Every grant row's mode select announced as bare 'Access level' and
every revoke button as 'Remove access', so a screen reader user
tabbing the list could not tell whose grant a control changes. Carry
the holder in the aria-label ('Access level for alice' / 'Remove
access for alice'); the visible UI and the revoke tooltip stay as
they were. i18n() encodes the interpolated string as a whole, quotes
included, so the labels stay attribute-safe for any holder name.
* fix(dashboard): keep the share status region in the accessibility tree
* fix(dashboard): give the share modal's add-row controls 44px touch height
* refactor(gui): extract the share dialogs' pure logic into tested modules
* feat(dashboard): adopt the shared mode helpers and handle pending invitations
---------
Co-authored-by: Juan Castro <[email protected]>
* 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
* 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) <[email protected]>
* 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) <[email protected]>
* 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) <[email protected]>
* 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) <[email protected]>
---------
Co-authored-by: Daniel Salazar <[email protected]>
Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
Prevent first-time visitors from being treated as temporary users when they open an app landing page such as `/app/<name>` or `/desktop/app/<name>`. These routes now always go through the real login/signup flow.
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* 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
fe535d598 made `~/share/<uid>` the actual address for every shared
entry. That reached far past the intent: item names became uuids, the
Shared views rendered uuids instead of filenames, and navigation
addressed entries through a namespace nothing else understood.
Put real paths back everywhere — responses, the share listing, and
request handling — and do the masking where it was wanted, in the
window's directory bar. A recipient sees `Shared › Contents › sub`
while every crumb keeps the real path it navigates to.
The share listing now carries the entry's name, content type, owner and
a signed thumbnail. A share row has no fsentry behind it for a client
to stat, and the stored thumbnail is an `s3://bucket/key` URI that no
client can render and none should see.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
* fix: path obfuscation, webdav + small ui stuff
* test(share): assert the masked share path by its exact shape
The substring check tripped on the scratch files' own names, which start
with `sharing-`; the exact-equality assertion on `/<owner>/<uid>/<name>`
already proves nothing above the share leaks.
Co-Authored-By: Claude Fable 5 <[email protected]>
* fix(share): rename policy, shared-view guards, webdav share parent, quota/bucket invariants
- Rename: a directly-shared FILE renames with write on it; a shared
folder root stays fixed (its name is the owner's tree structure).
GUI can_rename mirrors the backend, guards all editor entry points.
- Up from a share root goes to the Shared view on both surfaces; the
Shared view gets a single crumb, a disabled Up button, and refuses
drops, New/Paste, uploads and ctrl+V everywhere (it is a query, not
a directory).
- WebDAV: PROPFIND on /owner/uuid answers as a virtual collection
holding the share root (ACL-gated; 404 for strangers).
- Storage allowance override no longer crosses user boundaries: a
recipient's plan cannot raise the owner's cap.
- Overwrites stay in the bucket the entry already lives in instead of
repointing to the handling server's bucket and stranding the old
object.
- manage mode documented as implying write (matches enforcement);
share dialog label now "Can edit & share". Documented that fs socket
events are owner-only.
- Sidebar: saved orders gain the Shared entry once the user has shares.
Co-Authored-By: Claude Fable 5 <[email protected]>
* fix(share): close review findings, and retire /auth/grant-user-user
Security
- Recipient writes no longer echo the owner's real path back. The pending-write
event and the upload-progress meta both go to the *acting* user, so a write
into a shared folder handed out the layout above the share root.
- `maskerFor` failed open: the first caller fixed the actor, and one that ran
before the request knew who was acting pinned `undefined` — after which every
path published unmasked. It now adopts the first real actor and rebuilds for
a different one.
- `/auth/grant-user-user` returns 501. It wrote user-to-user grants straight to
the permission tables with no share row, so nothing could list or cascade a
revoke over them — and the new `manage` mode meant a delegate could reach it
for the owner's files. Filesystem access goes through `/share`; nothing else
is meant to pass between two users. Undocumented (its docs page was never
written) and no callers. Revoking is untouched, so grants made before this
can still be withdrawn.
- `#assertCanManage` asks about the permission actually being granted rather
than a fixed `read`, so sharing at `manage` is refused at the share layer
instead of by `grantUserUserPermission` two levels down.
- `listSharedWithMe` checks the grants, not just the index: withdrawing access
any other way left the row publishing name, size and a signed thumbnail URL.
- Moving an item into another tree now retires its shares. Grants are keyed on
uuid, so they followed it and left the new owner with recipients they never
agreed to.
Correctness
- `acl.check` gets the real path again, not the masked one. ACL matches on the
path string, and a mask hides the `AppData/<appUid>` shape it needs.
- "Leave this share" works for a grant that predates the index; the fallback
scoped the delete to the caller as issuer, which can never match.
- `move`/`copy` reject a name with a slash or a `.`/`..` segment, as `rename`
already did. It matters more here: a move into the owner's Trash skips the
destination write check.
- Descendant walks scope by path, not by owner, so rows predating the
one-owner-per-subtree invariant aren't orphaned by their parent's deletion.
Performance
- Index `user_to_user_permissions(permission)`. Retiring a node's grants is a
prefix match, but the primary key is (issuer, holder, permission), so it was
a full scan.
- Retirement is coalesced and chunked. `remove()` emits one event per
descendant, so deleting a directory fired one unindexed lookup per file, all
at once and unawaited.
- `listReaching` is a plain `fsentry_id IN (...)` on `idx_share_fsentry`. It
runs behind every file write, and the join-plus-OR it replaced was
unindexable.
---------
Co-authored-by: Claude Fable 5 <[email protected]>
Co-authored-by: Daniel Salazar <[email protected]>
Express derives `req.subdomains` by dropping a fixed number of labels
from the right of the hostname, defaulting to 2. A deployment whose
`domain` has more than two labels therefore reads its own root domain as
an active subdomain, so the user-site redirect sends the root origin to
the static hosting domain, which sends it back. Self-hosting docs
recommend exactly that shape (`puter.example.com`).
Set the offset from the label count of `config.domain`. Two-label
domains keep the express default, so existing deployments are unchanged.
Fixes#3561
* feat: add BytePlus ModelArk chat provider
Adds BytePlus ModelArk as a provider for the puter-chat-completion
driver, following the MiniMax/ZAI providers as reference per
doc/contributing-apis.md.
- OpenAI-compatible endpoint at ark.ap-southeast.bytepluses.com/api/v3
(apiBaseUrl config selects the region)
- Static catalog of 16 chat models (Seed 2.x/1.x incl. vision, GLM,
DeepSeek, GPT-OSS) with limits and per-token pricing from the
official docs
- Passes Ark's thinking/response_format/stop params through custom;
normalizes reasoning_content to reasoning
- Bare deepseek-v4-* names stay with the first-party DeepSeek
provider; BytePlus only claims prefixed aliases
- Offline unit tests (mocked SDK against a real test server) plus an
env-gated integration test
* feat: add BytePlus image and video providers
Extends the BytePlus ModelArk integration to the puter-image-generation
and puter-video-generation drivers, reusing the same services.byteplus
API key and regional apiBaseUrl as the chat provider.
Image (Seedream/SeedEdit via OpenAI-compatible /images/generations):
- dola-seedream-5-0-pro (pixel-tier pricing + billed input images from
the 2nd on), seedream-5-0-lite, 4-5, 4-0, and seededit-3-0-i2i
- quality tiers 1K/1.5K/2K; aspect ratios resolve to Ark's documented
pixel sizes; explicit WxH passes through with Ark's bounds enforced
Video (Seedance via Ark's async /contents/generations/tasks + polling):
- Seedance 2.0 / 2.0 Fast / 2.0 Mini / 1.5 Pro / 1.0 Pro / 1.0 Pro Fast
(2.5 is priced but its API isn't live yet, so it's excluded)
- per-video-token billing from usage.completion_tokens, with per-second
estimates feeding the credit cap; audio vs silent rates for 1.5 Pro
- first/last frame and reference-image inputs; generate_audio param
added to IGenerateVideoParams
Pricing and capabilities hardcoded from the official docs (ModelArk
pages 1544106, 1330310, 1520757, 1521309, 1541523). Offline unit tests
mock the SDK / global fetch; integration tests are env-gated on
PUTER_TEST_AI_BYTEPLUS_API_KEY.
* fix: correct BytePlus catalogs and validation against the live API
Verified the three BytePlus providers against ModelArk with a real key;
these are the mismatches that surfaced.
- Drop seededit-3-0-i2i-250628. Ark reports it as Shutdown and every
request 404s. Its now-unreachable image-to-image branches in the
provider go with it.
- seedream-4-5 and the 5.0 series enforce a 3,686,400 pixel minimum, so
they only accept the 2K tier. Mark them 2k-only and snap an
unsupported tier up to the nearest allowed one, which also keeps the
aspect-ratio table from mapping to a sub-minimum size.
- glm-4-7 has a 204,800 token context, not 256K.
- Guard the actor in the image provider like the video provider does.
- Round a sub-minimum video duration up to the shortest supported clip
instead of reporting it as insufficient funds.
- Gate video resolution on the model's own dimensions; the dims table is
shared across a family and accepts more than any one model does.
* Tighten BytePlus AI provider handling
Extract shared reasoning-content normalization for OpenAI-style chat providers, and harden BytePlus image/video behavior. This updates image tier and size validation, normalizes aspect ratios and input image refs, prevents mismatched BytePlus key/base URL fallback config, makes video resolution matching case-insensitive, and rejects excess reference images instead of silently truncating them. Tests were expanded to cover the new BytePlus request and validation paths.
- Add grok-4.6 model definition with pricing and context specs
- Add aliases x-ai/grok-4.6 and grok-4.6-latest
- Add unit tests for alias resolution and metering calculation
Closes#3562
* 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 <[email protected]>
* 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 <[email protected]>
* 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 <[email protected]>
* fix: absent addon fields stay absent when scaling to credits
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: Claude Fable 5 <[email protected]>
The spy sits on the test server's shared dynamo client, so a raw call
count also picks up background work and the async tail of earlier tests
in the file — which made the block-window test flakily report a third
read. Each test runs in its own random namespace, so filtering the
spy's calls to that namespace removes the cross-talk without loosening
the assertion.
Co-authored-by: Claude Fable 5 <[email protected]>
The zset rework of the redis concurrent backend (#3529) kept the same
key names the old INCR counter used. Against a leftover string key every
zset command in the acquire MULTI fails WRONGTYPE while the trailing
EXPIRE still succeeds — so steady traffic refreshes the stale key
forever, and the failed zcard result turned into NaN, which admitted
every caller unbounded. Release then errored WRONGTYPE on each request
(the '[concurrent] release failed' log storm).
Surface per-command MULTI errors instead of coercing them to NaN, and on
WRONGTYPE delete the legacy key and re-acquire against a clean zset.
Co-authored-by: Claude Fable 5 <[email protected]>
Developer-facing reference for every rate limit, concurrency cap,
quota, and error shape — an advanced page, since puter.js already turns
the common failures into prompts. AGENTS.md and CONTRIBUTING.md now
carry the rule that a PR moving any of these numbers updates the page
in the same PR: an undisclosed limit is one developers discover as a
service failure.
Co-authored-by: Claude Fable 5 <[email protected]>
* fix: over-quota batch uploads surface the storage prompt instead of failing quietly
Partial batch failures now carry each item's code/status, and when every
failed item failed the same way the shared code/status is hoisted onto
the rejection itself — so an upload that exceeds the storage quota
rejects with storage_limit_reached/413 and the SDK's upload handler
shows the free-up-space prompt. A partial failure is also no longer
misread as the signed-batch endpoint being unavailable.
Co-Authored-By: Claude Fable 5 <[email protected]>
* fix: out-of-storage copies prompt to upgrade the same way uploads do
Any fs operation the server refuses with 413 storage_limit_reached now
surfaces the upgrade prompt — the check that lived inline in upload's
error handler moves to a shared helper wired into the operation
scaffold's reject path, so copy/move/mkdir/rename get it too. The
desktop's copy/paste suppresses its generic alert for that code, since
the SDK dialog already explains the refusal and carries the fix.
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: Claude Fable 5 <[email protected]>
A search that matches none of the user's own apps now puts the same
query to the App Center catalogue and renders the hits under the empty
state — debounced, filtered to apps the user doesn't already have, and
launchable in place.
Co-authored-by: Claude Fable 5 <[email protected]>
Typing in the Apps tab's search dropped the tail tile — deliberately, on the
grounds that a query asks which apps you HAVE and a tile matching nothing has
no business in the answer. That reads the search too narrowly: looking for an
app you don't have is the same gesture as looking for one you do, and the
moment the search comes up short is exactly when the way to GET an app is
worth having on screen.
So the tile is pushed unconditionally now, and a query that matches nothing no
longer replaces the grid with a bare "No apps match your search" — the line
moves above a grid holding just that tile. Keeping it a real grid, rather than
parking a tile inside the empty state, is also what keeps it reachable: arrows
and Enter only walk tiles inside .myapps-page, and the tail tile carries
tabindex -1 for the pager's roving focus.
The no-apps-at-all state is untouched: with nothing installed and nothing
typed, the empty state still stands in for the whole grid.
`launchApp(name, args, { background: true })` hid the child's window and
nothing else, so the instance was hidden but not private: it kept its
taskbar item, lit the running dot on its dashboard tile, and — because
the dashboard enforces one instance per app — WAS what clicking that tile
opened. Minimize the parent, click the child's tile, and Puter handed the
user the window the parent was mid-conversation with; the app, which only
ever meant that instance for its caller, said so on screen. Showing it
also dropped the never-shown marker, so from then on the helper outlived
its launcher and owned the tile.
Hidden now means unreachable. `user_facing_windows` is the one rule: of
an app's windows, the ones that are the user's. Everything the user
drives filters through it — the tile's reopen and its running dot and
Quit item, the taskbar item's click, window list and show/hide/close-all,
the file row's switcher and open-file dot, and the dashboard's
Back/Forward — so a helper is never focused, restored, listed, or counted
as running. With none of the user's own left, the tile simply launches a
fresh instance and the helper keeps serving whoever launched it. Paths
that act on the APP rather than on a window it happens to have open —
uninstall, and a launcher's close taking its helpers down with it —
deliberately keep the unfiltered list.
The taskbar item is now what a hidden launch EARNS by becoming visible,
rather than something it advertises from the start; makeWindowVisible
adds it (and fires the dots event) before showing, since focusWindow
marks an item active and needs it to exist. Windows stamp data-in_taskbar
when they take their place in data-open-windows, so the close path only
decrements a count it actually joined — otherwise a helper closing beside
the user's own window would zero the count and strip the item off a
window still open. For the same reason a helper's close no longer pops
the dashboard URL: it never claimed an entry (the push only happens for a
window created visible), and consuming one would minimize the user's
window of the same app.
is_unseen_background_window gains the other half of its own definition:
launched hidden AND launched BY another app. An app that is always
windowless (`background` on the app record) starts hidden too, but when
the user opens it there is nobody it is serving — it is the instance
their tile has to find, or every click would start another one they
cannot see.
A mouse right-click on a file row in the dashboard Files tab opened the
mobile context-menu sheet (centered over the row, nowhere near the
cursor) on any touch-capable laptop or desktop, because phone/tablet
detection used navigator.maxTouchPoints > 0. The same check applied the
.touch-device class, degrading mouse selection and drag on those
machines.
Route by how the menu was invoked (the row's tracked pointer type) plus
isTouchPrimaryDevice() — coarse pointer and no hover — which is false on
touch-capable laptops but true on iPads whose UA claims macOS. Also let
sidebar-folder and background menus accept taphold on such iPads, where
they were previously unreachable (taphold dismissed, and iOS never fires
native contextmenu).
An app launched with `background: true` gets a real window from the moment it
starts, just hidden. Nothing ever took it down: when the app that launched it
closed, the child kept running with no way to reach it and no reason to be
there. On the dashboard the only sign was a running dot on a tile the user had
never lit up, and clicking that tile did nothing at all.
Three things were wrong, and all three had to go:
A window nobody has seen now dies with its launcher. UIWindow's close path
closes hidden children the closing app launched, keyed on a marker stamped at
creation. makeWindowVisible drops that marker the first time the window becomes
visible — showing itself with `puter.ui.showWindow()`, or the user showing it —
because from then on the window is the user's, and keeps running.
The dashboard tile is a real handle again. focusExistingAppWindow only routed
MINIMIZED windows through showWindow(); a hidden one fell through to
focusWindow(), which leaves it invisible while handing it the keyboard. With no
taskbar in dashboard mode the tile is the only handle on a background app, so
that click had nowhere else to go. It now asks whether the window is on screen
at all. The Files tab's row-click had the same one-line defect.
And a background instance can't take a tile from the user's own session: an
on-screen window wins, then a window the user has seen, then a hidden one.
Removing the child then hit a crash of its own: ExecService's `remove` handler
dereferenced the launcher's iframe to say goodbye, and the launcher was already
gone. Throwing there aborts jQuery's remove() itself, so the window stayed in
the DOM — running dot and all. That one also hit anyone closing a background app
from the taskbar after its launcher had closed.
The predicates behind all of this live in one helper, window_visibility.js, with
showWindow() reading the same `hidden, not minimized` rule it spelled out inline
before.
Launching an app to serve another one took focus away from the app the user
was working in, and they had to click it to get it back. Typing a guest name
into Calendar's event editor — which launches Contacts to read the address
book — dropped the user out of the field mid-word.
Four things conspired, all of them variations on "a window nobody can see is
treated as the window in front":
`window-active` was hardcoded into every window's markup, including windows
created hidden. focusWindow() — the thing that normally grants that class,
and strips it from everyone else — is already skipped for a hidden window, so
two windows ended up claiming it. It now follows the same rule as the
window_stack push a few lines above: visible windows only.
Both "focus the window once its IPC attaches" sites read that class to decide,
so a hidden child window pulled focus a beat after launching. Neither focuses
a window with data-is_visible="0" any more.
makeWindowInvisible left `window-active` on the window it had just hidden.
Since focusWindow() disables pointer events on every other app's iframe, an
app calling puter.ui.hideWindow() on itself left whoever launched it both
unfocused AND unclickable until the next click. It now hands activation back
to the top of the window stack, the way closing a window does, and drops
itself out of the activation order until it is shown again.
showWindow only ever un-minimized: for a window hidden by hideWindow() (which
keeps its geometry and writes no data-orig-*) every branch left it hidden and
stamped NaN geometry on it from the missing attributes. It now routes such a
window to makeWindowVisible. That is what makes the taskbar item a real
handle on a background app — clicking the item calls showWindow on the group.
`puter.ui.launchApp(name, args)` had no way to say "I need this app's API,
not its window". That matters because we create and show an app's window
before the app's own code runs, so an app launched purely to serve another
one cannot avoid appearing on screen: the best it can do is call
`puter.ui.hideWindow()` once it boots, which reads as a window flashing
open and shut. In dashboard mode it was worse than a flash — the child
maximized into the tab and minimized its parent behind it, so asking a
service app a question took the user's app away from them.
So `launchApp` now accepts `background: true`, and the window starts
hidden. The app is otherwise entirely normal: it keeps its taskbar item,
so a user can see that it is running, show it, or close it, and it can
show itself with `puter.ui.showWindow()` whenever it has something to say.
Only a literal `true` counts, since the flag arrives over IPC from another
app.
The decision now lives in one predicate, `starts_hidden(app_info, options)`,
which folds this together with the existing app-level `background` flag and
is used everywhere the old flag was read — including the dashboard's
minimize-the-parent branch. `show_in_taskbar` deliberately still keys on the
app-level flag alone: an app that is always windowless has nothing to put in
the taskbar, while a background *launch* should stay visible there.
Existing callers are unaffected: with `background` unset, both paths
evaluate exactly as they did.
Reopening still failed intermittently: any call the backoff refused looks
exactly like the user closing the dialog, so an app's "Send feedback"
button just did nothing, with nothing to see anywhere.
Rate limiting this dialog is not worth that. The other modals an app can
open with no user gesture — requestPermission, alert, prompt — have no
rate limit either, so one dialog at a time is the only rule left, and a
refusal now says so in the console.
The abuse guard counted every dismissal that sent nothing as a strike
against the app, so closing the dialog blocked the app's next
showFeedbackDialog() for 10s, then 60s, then for the rest of the page's
life. The key is the app uuid, so relaunching the app did not clear it,
and a blocked call replies sent:false — the app just sees nothing happen.
Key the guard off how fast the app comes back instead. A reopen within a
second of the app's last dialog activity is machine-paced and gets the
same escalating backoff; attempts made while backed off count as activity
too, so a loop cannot wait out a tier and start from zero strikes. Any
human-paced reopen clears the app's record, so the tiers are only reached
by an app that reopens at machine speed several times running.
One dialog at a time, and a successful send clearing the record, are
unchanged.
The dashboard's files tab filtered out dot-files unconditionally, so
there was no way to reach them the way the desktop Explorer allows.
Add the same "Show hidden" toggle next to Refresh, driven by the
shared show_hidden_files preference so the two stay in sync, and dim
revealed rows with item-revealed like the Explorer does.
The socket-driven item.added path applies the same rule, so a hidden
file created elsewhere can't appear in a view that filtered its
siblings out.
* fix: show the new folder's row in the dashboard before mkdir answers
Both New Folder paths in the dashboard Files tab awaited mkdir before
drawing anything, so on a slow connection the button appeared to do
nothing for seconds. The toolbar path then did a full renderDirectory on
top of that -- a second round-trip, and with the default eventual
consistency the new folder could miss the listing entirely, leaving the
rename editor unopened.
Both now go through one createFolderInstant(), which renders the row
first: a locally predicted name (mirroring the backend's " (N)" dedupe
convention), a temporary uid, and the name editor already open. mkdir is
still asked for the plain "New Folder" path with rename: true, so the
server keeps owning deduplication; the prediction is only what we draw in
the meantime, and the row corrects itself if the two disagree. On failure
the row is withdrawn and the error is shown rather than swallowed.
Renaming is the next thing the user does and it needs the real uid, so
rename() awaits the create promise parked on the row. Everything else
that acts on the item -- open, menus, drag -- sits out while the row is
pending. mkdir's item.added comes back to the originating client (the
event carries no original_client_socket_id) and cannot match a temporary
uid, so _creatingItem still suppresses it, now as a counter: as a flag,
one create finishing uncovered another still in flight.
Two things found on the way:
- directory_depth_limit_exceeded had no translation key, so that alert
(including the pre-existing desktop create_folder path) rendered the
raw slug.
- The empty-directory notice is now restorable without refetching, since
a withdrawn create has to put it back. It carries its own class because
the loading overlay shares the same container.
Verified end to end against a dev backend with mkdir slowed to 3s:
toolbar and context-menu paths, rename and Escape before the response,
two overlapping creates, and a rejected mkdir.
* fix: don't draw the instant new-folder row while a listing is rebuilding
renderDirectory() clears .files before its readdir resolves, so a row
drawn during a load survives the clear and is left behind in whatever
directory the load lands on. Clicking the new-folder button while a
folder is still opening produced a row for `<old dir>/New Folder` sitting
in the new directory's listing, with the rename editor open on it —
clicking it navigated elsewhere and renaming it renamed a folder the user
wasn't looking at.
Drop the click while a rebuild is in flight, the same way renderDirectory
already drops navigation clicks that arrive while it renders.
* fix: stop drops onto a not-yet-created folder row from mangling files
A row drawn ahead of its mkdir carries a predicted path, but it was still
registered as a live drop target. Dropping a file on it called
move_items() with a destination that does not exist yet, and move treats
a non-existent destination as a rename target — so the dragged file was
silently renamed to "New Folder" instead of moving into the folder.
Reproduced against a slow mkdir: victim.txt became a 5-byte file named
"New Folder".
Sit the row out of the jQuery UI droppable (drop and the spring-load
hover) until the real fsentry lands, matching the guards already on
opening it, its menus and dragging it. The native-file dragster drop gets
the same guard: it uploads into the same predicted path.
Clicking inside the feedback overlay (e.g. the textarea) reached
initgui's global mousedown -> focusWindow path: mouseover_window is
computed geometrically and is blind to the overlay, so focusWindow
focused the app window's iframe underneath — stealing keyboard focus
from the textarea and forwarding the click into the app.
Suppress it the established way (same fix as the dashboard app-group
panel): set window.mouseover_window = undefined in the overlay's own
mousedown handler, which runs before the document-level one; undefined
is the only value initgui's guard skips.
The generic .app-feedback-btn:hover:not(:disabled) rule out-ranked the
.app-feedback-btn-primary:hover override (the :disabled argument inside
:not() counts toward specificity), so hovering Send repainted it with
the light --afb-hover background under white text. Strengthen the
primary hover selector so the blue gradient wins.
* 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
32c950de5), 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.
* fix: don't mint a user-app token as a side effect of the feedback popup
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.
* fix: refuse feedback when the deployment cannot deliver it
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.
* fix: settle showFeedbackDialog instead of hanging on older host GUIs
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.
* fix: guard app-triggered feedback dialog against desktop-lockout loops
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.
* fix: carry feedback_enabled in suggested/recommended app summaries
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.
* fix: gate feedback dialog Cancel/X on an in-flight submit
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.
* fix: reject feedback origins longer than the source_origin column
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.
* fix: make the feedback dialog's privacy note match what is shared
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.
* fix: stop HTML-escaping email subject lines
Subjects were compiled with default Handlebars escaping, so the
app-user-feedback subject rendered a title like "Bob's App & Games" as
"Bob's App & Games" — literal entities in the recipient's mail
client. Subjects are plain-text headers, not HTML; compile them with
noEscape. Header safety is unaffected: the transport encodes newlines
and free-form values collapse whitespace upstream.
* docs: state exactly what showFeedbackDialog shares with the developer
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.
* docs: document the COOP false-resolve limitation of showFeedbackDialog
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'.
* feat: point the feedback email footer at the Dev Center
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.
* test: cover the feedback service and store layers directly
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.
* refactor: prep the feedback cap error once instead of via a factory
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.
* Require verified users for app feedback API
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.
The Files tab's folder-background context menu (empty listing area,
sidebar folders, breadcrumb segments) now offers Publish as website and
Properties for the folder the menu was opened on, matching the desktop's
folder menus. Both are omitted at the filesystem root and in Trash, and
Publish is omitted inside trashed folders.
The properties modal can now resolve an entry by path when the caller
has no uid, which the folder-background menu doesn't.
The publish gate (account creation + email confirmation) was duplicated
in three places, so it moves to a publish_as_website helper that all
call sites now share.
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
Translate the app discovery, app request, desktop shortcut, and app folder controls that currently fall back to English in the Traditional Chinese interface. Preserve the positional placeholders used by accessibility labels.
- 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
The rows already answer hover with a --select-ring border; filling them
with the grey --dashboard-hover under that accent ring read as two
systems disagreeing. The weak select tint keeps the hover in one voice
and stays under the icon chip's stronger tint in both themes.
Update the English `add_app_ai_desc` translation string in the GUI to a more direct, enthusiastic message: “Describe the app you want and AI will build it!”.
The godmode gate in ExecService.launchApp checked `godmode === 1`, but
GET /apps/:name serializes the flag as a boolean, so the check always
failed and file_paths launches (e.g. Dev Center opening a worker's
source in the editor, #2218) silently dropped the file. Accept both
shapes, matching the existing guard in launch_app.js.
Also fix the two issues hiding behind that gate:
- puter.apps.get returns `uid`, not `uuid`, so the /sign call for the
target app sent app_uid undefined — no write grant and no user-app
token for the launched app. Use `uid` with a `uuid` fallback.
- The closeApp IPC handler had the same `godmode === 1` comparison,
preventing godmode apps from closing other apps' windows.
60686643b made App Center and the AI builder morph out of the + tile the
user clicked, which answers where the window came from but not the
question these two options exist to answer: where the APP went. The grid
never showed it arriving, so the user still ended a launch with nothing
to find, no tile for minimize to fly back to, and no lesson learned.
Choosing either option now plays exactly what an /app/<name> landing
plays (beginDeepLinkLaunch): when the app already has a tile the intro
travels to it, plays the click flourish, and the window grows out of the
app's own slot; when it doesn't, the tile is INSTALLED first — the slot
opens, the progress stroke draws, the icon springs in — and only then
does the window morph out of the tile that just arrived. Same beats,
same exposure decay, same interruptibility, same duplicate-launch claim.
The app info is prefetched in parallel and handed to launch_app, the
same contract as initgui's landing path, so the intro (which may need to
draw the arriving tile from it) never costs a second round-trip.
This also reverts 60686643b's anchor plumbing (UIWindow's
dashboard_tile_el option and the dashboard_tile_in_view export): the
intro installs a real tile for the by-app-name morph to find, so no
stand-in anchor is needed.
Choosing App Center or the AI builder opened the window with the plain
fade while every other launch in the Apps tab grows out of the icon that
was clicked — the one launch that most needs to say where it came from
read as the app appearing from nowhere.
The morph resolved its anchor by app name, which cannot work here: the
whole point of these two options is installing an app you do not have,
and an app with no tile has no name to find. So the launcher may name its
anchor element (dashboard_tile_el), and TabApps resolves it once before
launching so the click flourish and the window's half can never disagree
about which tile the launch came from: the app's own tile when it already
has one on screen — minimize flies the window back there, so that is
where opening should come from — and otherwise the + tile itself.
dashboard_tile_in_view's visibility test is now a named helper it shares
with the new path, so an anchor on an offscreen pager page is still
rejected and still falls through to the fade.
The Apps grid showed what you have and offered no way to get more. A
dashed plus tile now rides at the tail of every page of it: after every
app, on the last page, and nowhere else. It wears the same tile skeleton
as an app so hover, focus, and arrow keys treat it identically, but
carries no app name or group id — which is what keeps it out of the
saved order, the running dots, and the deep-link tile lookups. It cannot
be picked up, is never a drop target (so no app can land past it), has
no context menu, and sits still while the apps jiggle in reorder mode.
Search results leave it out: a query asks which apps you have.
Clicking it asks how you would like to get an app: find one in the App
Center, have one built by the AI builder, or request one that does not
exist yet. The request is a form in the same card rather than the stock
Contact Us window — a titled pane one step deeper, whose Back arrow
returns to the options with the draft intact. It posts the same
/contactUs request that window did; a failure is said inside the form,
where the request is still there to retry, rather than in an alert
window the next click on the dashboard would bury.
Extract app tile URL generation into a shared helper and use it for a new dashboard context menu action that copies the tile's destination. This keeps copied links aligned with the existing open-in-new-tab behavior and adds regression coverage for app, external, encoded, and folder tiles.
A /app/<name> landing for an app the dashboard didn't list opened with
a plain fade and left the app NOWHERE: the grid had been fetched before
the launch granted the permission that installedApps reports, so Back
found no tile to minimize into and the user landed on a dashboard
without the app they were just inside. The round trip that teaches
"windows are inflated tiles" broke on exactly the landing where a new
user is forming that model.
Now the landing SAYS what it does. The app joins the grid at the tail
(where new apps land) with a tile drawn from the landing's own app-info
prefetch -- only a confirmed-real app ever materializes, with its real
icon -- held invisible until the intro has revealed the grid and
travelled to the tile's page, and then it is INSTALLED, in the grammar
every app store taught: the slot opens (the folder well's own surface)
with the icon dim inside it, a progress stroke draws clockwise around
the slot, and on completion the icon springs to full color and size as
the label names it. Only then does the flourish play and the window
grow out of the tile. The arrival is the one beat that never decays:
per-app news delivered at most once per app, not a repeated lesson.
Minimize now has a target, and the app is simply there afterward.
The regression surface is guarded on every edge: the parked-invisible
state is re-applied by renderApps itself so mid-intro re-renders can't
strip it, and settleDeepLinkLaunch -- which the landing's finally
always reaches -- force-reveals it, so no path leaves an invisible
tile. A _pendingInstalls overlay (the mirror of _removedLocal) keeps a
refresh fetched before the grant lands from evicting the tile, and
retires itself once installedApps confirms the app or the user
uninstalls it again. The order is never saved on the splice, so the
default-position append can't freeze a custom order the user never
made. Failed or absent app info adds nothing; installed-app landings
are untouched; animations-off and reduced-motion users skip the
choreography but still get the tile, silently. The progress ring is a
transient element (the tile's ::before is the well, its ::after the
running dot) drawn as an SVG dashoffset sweep -- no Houdini dependency
-- and the app-info prefetch now asks for the 128px icons every other
tile already uses.
The control drawer on headless dashboard apps opened itself on EVERY
window open, forever -- 2.6 seconds of tray over the app's own top
edge to teach a lesson (the controls live in this tongue) that is
learned in a few exposures. Same doctrine as the dashboard's
deep-link intro: pedagogy that knows when to step aside.
A per-account kv counter records deliveries, once per window: a flash
played while the user could see it, or the user opening the drawer
themselves (hover, tap, focus) -- the stronger proof, since the
drawer is a headless app's only chrome and must never decay out of a
user's awareness. After three, new windows keep the bare tongue and
the flash stops; hover, tap, and focus expansion never decay.
The attach is synchronous and the stored count is not: the first
drawer of a session races the read against a short cap and defaults
to teaching -- a hung read must not hold the intro hostage, and every
failure mode (unparseable value, failed read, timed-out read, failed
write) errs toward teaching once more, never toward never teaching.
The resolved count is cached and kept current in memory, so later
windows decide synchronously and a session's own not-yet-written
increments still count. kv.incr keeps two devices from losing an
increment, and the pre-read caps the key once the lesson is learned.
If the user beats a still-deciding flash to the drawer, the flash is
skipped rather than played over their hand -- its auto-collapse would
shut a drawer they are actively using. A flash in a hidden tab still
defers to first reveal (it rides requestAnimationFrame, as before)
and an unseen one is never counted.
* 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 <[email protected]>
Three fixes to the My Apps folders:
A folder big enough to need it now paginates like the grid outside
(iOS folders page too) instead of growing a scrollbar: pages of
cols x 3 tiles in a scroll-snap scroller with dots below, wheel and
swipe to flip, and a drag held at the card's edge flips folder pages
so reordering works across them. The card's height stays constant
across pages, and a deep-link landing on an app that lives on a later
folder page travels there before the launch morph.
Dragging an app out of the open folder no longer drops it blind:
after a beat held outside the card, the app leaves the folder, the
card closes behind it, and the SAME drag carries on over the grid --
placeholder shuffle, edge page flips, even a drop into another
folder -- so the app lands where the user watched themselves put it.
A quick drop during the beat still lands beside the folder, and
swinging back inside within the beat cancels cleanly.
The folder well's fill played twice on nearly every merge: the rest
countdown anchored at first contact with the target, so the hand
decelerating INTO the tile always read as movement when the dwell
elapsed, and the deliberate refill-from-empty replayed the fill. The
countdown now re-anchors on the move events themselves -- rest is
only visible at its edges -- so the fill that completes is the one
started by the last movement, played exactly once.
* feat(gui): iOS-style app folders in the My Apps grid
Drag one app onto another and let it settle: a well opens under the
target and the drop makes a folder of the two. Dropping onto an existing
folder joins it. A folder opens by growing out of its own icon into a
card over a blurred grid, where its apps can be launched, rearranged,
renamed, or carried back out.
Hovering a tile mid-drag means two things — "push over, I'm passing
through" and "swallow me" — and the tile is barely bigger than its icon,
so pixels can't separate them; motion does. The shuffle is held while
the folder offer stands and fires when the icon leaves the tile, or at
the drop, so a quick drop onto a neighbour still reorders exactly as it
did. The offer itself re-arms rather than cancelling on movement: the
last events of a drag are the ones carrying the icon onto the target and
nothing is dispatched while it rests, so a cancel-on-movement dwell
could never fire at all.
Folders are stored in their own kv key; the grid's ORDER stays entirely
in the existing saved app order, with a folder occupying its first
member's slot and its members contiguous. Every saved order therefore
stays valid with no migration, and an app whose installedApps page
failed to load keeps both its folder and its position. Folders never
nest, one that drops below two apps dissolves, and a corrupt kv value
degrades to "no folders" rather than a broken tab.
Elsewhere:
- Search looks THROUGH folders — a match the user then has to hunt for
inside one is not an answer.
- Minimize morphs into the FOLDER when an app lives in one, and a
/app/<name> landing opens the folder so the launch grows out of the
icon where the app actually is.
- The uninstall FLIP keyed surviving tiles by app name, which a folder
tile doesn't have; it now keys by identity.
New pure model in appGroups.js with tests; verified end to end in the
running dashboard (create, join, open, rename, reorder, eject, ungroup,
launch-from-folder) alongside plain reorder, search, and uninstall.
* fix(gui): keep the folder name field from inheriting input[type=text] sizing
style.css styles every input[type=text] with `width: 100%` and grows it to
`padding: 7px; border: 2px` on focus. `input.myapps-group-name` matches at the
same specificity, so it only wins the properties it actually declares — width
was never one of them, and the focus rule declared neither padding nor border
width. The name field therefore spanned the entire folder card (so its hover
and focus chip read as a full-width bar rather than the name) and grew 8px
taller the moment it was clicked, shoving the folder's app grid down.
Spell the three out, in both the resting and the focus rule — the same trap
.myapps-search already documents next door.
* fix(gui): size folder icon ghosts to the icon they stand on
Border-box only reaches a folder's icon through `.dashboard * { box-sizing }`,
and every ghost cloned from one is appended to <body>, outside that rule: the
drag ghost, the click-time launch flourish, and the open/minimize morph ghosts
all fall back to content-box, where .myapps-group-icon's 5px padding is added
to the 56px slot. Each ghost rendered 66px square and 5px off, so it visibly
popped at exactly the moment it was supposed to sit flush on the real icon.
State box-sizing on the rule itself so a clone carries it wherever it lands.
* fix(gui): stop a closing folder from swallowing the next click
_closeGroup drops the open class and leaves the overlay in place for
GROUP_PANEL_CLOSE_MS so the card can recede into its tile. The scrim is
`position: fixed; inset: 0` and still hit-testable for that whole quarter
second, so a click on the grid during it landed on the outgoing overlay — whose
handler only re-runs _closeGroup, now a no-op. Shutting a folder and reaching
straight for an app did nothing.
Take the outgoing overlay out of hit-testing; it has no interactive job left.
* fix(gui): keep a folder name typed right up to the moment it closes
The name box commits on blur, and every exit that goes through a pointer blurs
it while the folder is still open — so clicking outside, or launching an app
from inside, keeps what was typed. Escape does not: _closeGroup clears
_openGroupId first and only then moves focus to the tile below (or removes the
card outright), so the blur arrives with no open folder to rename and
_renameGroup drops it. A brand-new folder opens with its name selected for
exactly this edit, so "type Games, press Escape" — the obvious way to dismiss
a dialog — was the path most likely to lose it.
Commit the pending name on the way out, before the folder id is gone.
* fix(gui): close an open folder when the Apps tab is re-entered
The dashboard hides an inactive section and calls onActivate on the way back
in; there is no deactivate hook, so a folder left open survives the round trip
and greets the user still open over a grid they walked away from. Worse,
onActivate's focusSearch then lands the caret in the search box behind the
folder's scrim, and typing filters the grid the card is covering — a modal with
the keyboard pointed outside it.
Shut the folder as the tab comes back: returning to the tab is returning to
the grid.
* fix(gui): move focus into a folder when it opens
Opening a folder called .focus({ preventScroll: true }) on a jQuery
object. jQuery's .focus() shorthand reads a lone non-function argument
as event DATA and binds a handler with it, so it never moved focus:
the folder opened modal over the grid with focus still on the tile
behind its scrim, where Tab walked away through the inert grid instead
of cycling inside the dialog — and the object it bound as a handler
threw a TypeError on that tile's every subsequent focus.
Focus the DOM node instead, as every other focus call in this file
already does.
* fix(gui): stop renaming a folder from swallowing the click that commits it
The folder's name box commits on blur, and blur fires on the PRESS —
before the click that press belongs to. Committing re-rendered, and the
re-render replaced every tile in the open folder, so by the time the
click was dispatched the tile under the pointer was detached and the
delegated handler never saw it. Typing a name and then tapping an app
in the folder — the path a brand-new folder puts the user on — renamed
the folder and did nothing else; the app only opened on a second click.
Rebuild the folder's contents only when they actually differ from what
is on screen. A rename doesn't change them, so nothing is detached, and
a background refresh no longer throws away hover/focus either. Tiles
that survive get their drag resting-rects cleared, since the card can
have moved under them since the rects were taken.
* fix(gui): keep Enter in a folder's name box from leaving the folder
Committing the name with Enter blurred the box, which left focus on
<body> — outside a dialog that is marked aria-modal and that traps Tab
on its own subtree. The next Tab therefore walked off through the inert
grid the folder is covering, exactly what the trap exists to prevent.
Step out onto the folder's first app instead; the same blur still
commits the name. The keystroke is stopped at the box because the
grid's document-level key handler reads Enter on a focused tile as
"launch it", and would otherwise have taken the focus move as its cue
to open an app the user never asked for.
* fix(gui): stop an open folder clipping its own uninstall badges
The folder's grid scrolls, so it clips anything outside its padding box
— and reorder mode's uninstall badge deliberately overhangs the top-left
corner of every tile. The top row's badges therefore rendered as flat
tabs rather than circles, on the one surface where they are a touch
user's only way to uninstall an app they have filed away.
Give the scroller 9px of top padding for the overhang to sit in and take
it straight back off as margin, so the card and everything in it stays
exactly where it was.
* fix(gui): hold page edge-flips while a folder merge is being offered
A tile in the pager's last column sits inside the 60px edge-flip zone, so
resting a dragged icon on it — the folder-making gesture — armed the edge
dwell alongside the merge dwell, and the page flipped out from under the
very folder the user was watching form (the merge target scrolls away
mid-offer, and the drop then resolves against its stale resting rect).
Worst on phones, where 72px tiles overlap the zone across the whole last
column.
A live merge offer now holds the edge flip: entering the zone arms no
dwell while an offer stands, and an offer that arrives during a running
dwell is re-checked at the flip (a resting pointer fires no event that
could clear the timer). Carrying the icon off the tile withdraws the offer
and the hold with it, so deliberate flips — resting in the bare edge
gutter — behave as before.
* fix(gui): make Escape cancel a folder rename instead of saving it
Escape pressed mid-edit fell through to the folder's close handler, and
closing commits whatever the name box holds — so the one key every inline
rename uses for "never mind" stored the abandoned half-typed name. Escape
in the box now puts the stored name back and steps out to the folder's
tiles, exactly the cancel Finder/Explorer taught; with nothing left to
cancel (name untouched) it falls through and closes the folder as before,
so a second press still exits.
* fix(gui): refill the folder well when the merge countdown restarts
The merge dwell pins its stillness anchor at first contact with the tile,
and a hand decelerating INTO a target routinely covers more than the
7px allowance between that moment and the first tick — so the countdown
quietly restarts. The well's fill, tuned to the same 460ms, had already
completed by then and just sat there half-open: a drop it seemed to
promise a folder for actually reordered. Restart the fill with the
countdown, so what the well shows is always the countdown that is
actually running.
* fix(gui): keep keyboard focus inside an open folder across its edits
The folder card is a modal dialog, but three of its flows stranded focus
on <body>, where Tab walks the inert grid behind the scrim:
- Remove from Folder rebuilds the card's grid after the context menu has
dropped focus, so nothing inside the dialog holds it. The rebuild now
hands focus back to the same app's tile (or the first) whenever it finds
focus on <body> — never stealing from an uninstall modal, which holds it
legitimately.
- The same eject dissolving the folder re-renders the grid right after
_closeGroup's focus hand-back, replacing the very tile it chose. The
ejected app's own tile — where the user is looking — takes focus instead.
- A click on the card's empty space focused nothing at all. The card now
carries tabindex=-1 so such clicks land on it (no visible ring; a focus
ring around the whole card would misread plumbing as selection), and the
Tab trap wraps from there instead of stepping off through the scrim.
* fix(gui): size the folder name box to the name it holds
The box sat at the browser's default ~20ch regardless of content: a short
name floated in a hover pill far wider than the word, and a 40-character
one clipped while the card had room to spare. field-sizing: content hugs
the text between a 90px floor and the card's width, iOS-style; engines
without the property keep the default box exactly as before.
Signing up from a direct /app/<name> landing redirected to '/' on
success, silently dropping the app the URL asked for — the deep link's
launch (and its dashboard intro) never happened. Signup now mirrors
login's pathname-preserving redirect, but only for app-landing routes
(/app/<name>, /desktop/app/<name>): every other route keeps the
historical '/', notably /action/signup, where returning to the same
path would just show the signup form again. The query string stays
dropped, matching login's credential-leak hygiene. This covers all
three signup entry points reachable from a landing: the login cover's
"Sign up", the session picker's "Create Account", and the
must_login_or_signup fallback.
The session picker (UIWindowSessionList) also left itself on top of
the cover windows its two links open, hiding their username fields:
"Create Account" tried to close the picker via the LOGIN window's
c2a selector (which matches nothing in the picker), and "Log Into
Another Account" never closed it at all. Both now close the picker —
in the reload flows only: the no-reload (popup) flows keep it open,
where it doubles as the fallback UI when the login/signup window is
abandoned mid-flow.
Picking an account was already correct (location.reload() keeps the
landing URL); with these fixes all three picker paths, plain login,
and signup all return to the app landing, where the boot replays the
launch and its intro.
The intro exists to teach the dashboard's spatial model; once learned it
would only tax every bookmarked landing. Two mechanisms remove that tax:
- Interruptible: any real user input (pointer/key/wheel, isTrusted only)
during the intro skips the remaining choreography and launches at
once — it wakes the in-flight beat sleep, so the skip is immediate.
Input never cancels the launch itself and is never swallowed;
whatever it was doing (typing a search, clicking a tile) still
happens.
- Exposure decay: after 3 delivered — or deliberately skipped — intros
the beats collapse and the sequence plays in one breath, exactly like
a warm tile click. Counted per account in kv (kv.incr, atomic across
devices; capped at the threshold so the key stops changing) rather
than per device: the lesson lives in the user's head and the account
follows them, while localStorage would also bleed between accounts on
a shared browser. Only real exposures count: a delivered flourish or
an active skip with the grid on screen — hidden tabs, timeouts, and
no-tile landings teach nothing and don't count. The animated page
flip is exempt from decay: it isn't a repeated lesson, it's live
wayfinding to where this app lives, and its settle is needed anyway
to put the tile in view for the morph. Every failure mode (slow or
failed kv read) errs toward teaching once more, never toward never
teaching.
Also bounds the intro's wait on the app-list load by the same deadline
as the tile wait — fetch has no timeout of its own, so a stalled
installedApps request used to hold the deep-link launch hostage
indefinitely.
The deep-link intro used to flip to the tile's page instantly behind the
grid's load-fade, so landings on off-page apps woke up on page N with
nothing but the pager dots hinting a move happened. Now the grid always
reveals on its first page, holds the grid beat, and then visibly travels
to the tile's page before the tile's flourish — the journey shows where
the app lives, which is also where minimize will put it back.
Smooth scrollTo has no reliable completion event across engines, so the
travel uses a settle allowance (DEEP_LINK_INTRO_FLIP_SETTLE_MS, same
pattern as the drag code's DRAG_FLIP_SETTLE_MS): the scroll's ~450ms
plus a rest so the landing reads before the tile pops. First-page apps
skip the flip entirely and are unaffected; the hidden-page bail-out is
re-checked after the flip settles.
A direct landing on /app/<name> now plays the same sequence a real
Apps-tab tile click does — grid appears, a beat, the tile's icon ghost
pops out of its slot, a beat, and the window morphs out of the tile —
so the landing shows the user what is being opened and where minimize
puts it back.
TabApps.beginDeepLinkLaunch waits (3s cap) for the tile to be genuinely
visible (list loaded, render done, pager flipped to the tile's page,
load-fade revealed, icon painted), paces the beats, and claims the app
in _launchingApps so a click mid-intro can't spawn a duplicate. The
launch's app-info fetch is prefetched in parallel so the intro never
delays the app's own round-trip. No tile, animations off, reduced
motion, or a background tab (hidden pages throttle timers and defer
rendering) all skip straight to the immediate plain-fade launch.
Also fixes the tab title sticking as the app's name after closing a
deep-linked app: the landing's replaceState('/') committed the
dashboard's history entry while the page still carried the server's
app-name title, and Chrome shows an entry's stored title when close/
Back traverses onto it — document.title is now reset before the
replaceState so the entry is stamped with the dashboard's own title.
Right-clicking a sidebar/breadcrumb folder in the dashboard Files tab and
choosing New > Folder (or any file type) created the item in the right-
clicked folder correctly, but the UI inserted the new row and started the
inline rename in whatever directory was currently open — so the item
appeared to be created in the wrong place until a refresh.
Now, when the creation target isn't the directory on screen, navigate to
the target and run the select + rename flow there; same-directory
creation keeps the incremental insert.
Uninstall only revokes permissions, but the recommended launch list is a
global hardcoded set that knows nothing about per-user revokes — so an
uninstalled recommended app's tile came back on every reload. Persist
uninstalled app names in kv (dashboard_removed_apps) and filter only the
recommended merge against them; installedApps is never filtered, so a
genuinely (re)installed app always shows.
The drawer resolved its own icon URL (a differently-sized variant under a
different URL than the dashboard tiles use), so its <img> fetched cold and
the icon popped in mid-intro. Reuse the bitmap an Apps-tab tile or Home-tab
recent already decoded — instant from the image cache; when nothing is
rendered yet (deep links), fade the icon in on load instead of popping, and
fall back to the generic app icon on a failed fetch.
Pasted (copied or cut) items now land selected, the same treatment
uploads get. copy_clipboard_items resolves with the created items'
paths instead of firing and forgetting, moveClipboardItems returns
each move response's authoritative final path (with Keep Both the
landed name differs from the source's), and both dashboard paste
entry points refresh with strong consistency and hand the new paths
to selectUploadedRows. Pastes into a folder that isn't rendered match
no rows and the highlight is a no-op.
Pasting or moving onto an existing name only offered Replace or
Cancel. Add a macOS-style middle ground: Keep Both retries the
operation with dedupe_name, landing the item as "name (1).ext" and
leaving the existing item untouched.
- puter-js move.js now forwards dedupeName to the wire (copy already
did; move dropped it).
- All four conflict dialogs offer the new button: copy_clipboard_items,
copy_items and move_items in helpers.js, and the dashboard's
moveClipboardItems. Multi-item selections show
Replace / Replace all / Keep Both / Skip.
- New keep_both translation key (other locales fall back to English).
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.
A copied fsentry kept its source's thumbnail pointer verbatim, so both
rows shared one S3 thumbnail object. fs.remove.node deletes the
pointed-to object, so the first removal among the sharers — e.g. the
remove performed by a replace-on-copy — broke every other sharer's
thumbnail. FSService already emits fs.copy.node for exactly this
reason (its doc comment describes the duplication), but the thumbnails
extension never subscribed to it.
Add the missing handler: S3-copy the thumbnail to a freshly minted key
and repoint the copied row, under the same only-keys-we-minted trust
rule as the read/remove paths. If the shared object is already gone
(pre-fix damage), null the pointer instead of leaving the row
advertising a thumbnail it doesn't have.
* 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.
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.
Re-rendering the directory already on screen (after an upload, sort
change, undo, etc.) used to clear the list and show a spinner before
the readdir round-trip, blanking the pane for the whole fetch. Keep
the current rows visible until the fresh listing arrives, then swap
the DOM in one pass and restore the scroll position. Navigation to a
different directory still clears immediately.
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.
Update `.dashboard-settings-card .button` to use dashboard theme tokens for text, background, and borders, and add clearer hover/focus/active states with subtle elevation and transitions. Also override disabled styles in this context so dark mode no longer shows hard-coded light greys from the base `.button:disabled` rule.
Long-press-to-drag can't be made reliable on touch: touch-action is
consulted at gesture start, so the pager's pan-x claims the finger
before a drag can begin (worst on iOS). Replace it with an explicit
edit mode - a cog button (touch-primary devices only) enters it, tiles
jiggle and drag on first movement, iOS-style x badges uninstall, and
Done exits. Drops still persist immediately, same as desktop.
Also raise the drag ghost above the window z-index bands; it was
rendering invisibly behind the fullpage dashboard window.
Express 5's app.listen wraps the listen callback in once() and also
invokes it on 'error', with the error as the first argument — at which
point server.address() is null, so the startup log threw a TypeError
and killed the process before the EADDRINUSE handler could try the
next port. Bail out of the callback when it receives an error and let
the 'error' listener own the retry.
Verified: with 4000 occupied the server now logs the retry and comes
up on 4001; with the port free it binds 4000 as before.
- npm start --server=puter.com (or -- --server=...) skips the local backend
and serves the bundled GUI locally, pointed at the remote server's API.
Bare domains resolve to https://api.<domain>; full origins are used
verbatim. gui_origin points at the remote origin so /whoarewe, login,
anti-csrf, socket.io, and builtin apps hit the real backend (CORS-open).
- --extensions=<dir>[;<dir>...] bundles out-of-tree GUI extension
directories (sugar for PUTER_GUI_EXTENSION_PATHS). Their imports resolve
as if the files lived in src/gui/src/extensions, with the extension's own
files taking precedence, and bare imports fall back to the repo-root
node_modules.
- Fix the bit-rotted dev-server: Express 5 wildcard routes, pass gui()
params (previously called with none), inject the service_script shim,
load bundle.min.js + bundle.min.css in prod mode, serve /sdk, and
properly await the webpack build (it previously resolved immediately).
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 <[email protected]>
* Apps: normalize filetype associations to bare lowercase extensions
Suggested-apps lookups match app_filetype_association rows against the
bare lowercase extension ('docx'), but writes stored whatever the
developer typed. Rows like '.docx' never matched, so those apps
silently dropped out of Open With suggestions.
AppStore now canonicalizes on write (trim, lowercase, strip leading
dots, dedupe, drop empties) and tolerates the dotted legacy form on
read: getAppsByFiletype normalizes the requested extension, matches
both 'docx' and '.docx', and dedupes apps associated under both forms.
Cache invalidation keys are normalized the same way. Existing dotted
rows work without a data migration.
* Update apps tests for extension canonicalization
Adjust apps API tests to match current normalization behavior for `filetypeAssociations`: extension values are stored as lowercase bare extensions (e.g. `.txt` -> `txt`), while MIME types remain unchanged. Added inline comments in both test suites to document this expected remap.
An app launched by another app minimizes its launcher (iOS-style
takeover). Closing the child then popped onto the launcher's history
entry, and the popstate handler restored it — so quitting an app threw
the user into a different full-screen app they had not asked for, zoom
animation and all. Closing an app should go home.
Mark the launcher when it is minimized FOR a child
(data-minimized_for_child). If it still carries that mark when the child
closes, the close's single history hop lands on the dashboard instead:
the entry is rewritten to the dashboard's route and the launcher is left
minimized, still running behind its tile's running dot. No extra history
traversal — the joint session history an app's iframe shares is exactly
what pop_dashboard_app_url's watchdog exists to survive, so the hop
count is unchanged.
Back is untouched and still returns to the launcher — that is the
navigation gesture, whereas close dismisses. Any restore (Back, tile
click, Forward) clears the mark, so quitting a child after the user has
brought the launcher back leaves the launcher alone.
In dashboard mode apps are full-tab experiences, but an app launched by
another app (puter.ui.launchApp) opened as a floating titlebar window
over its full-tab parent: the parent stayed on screen around its edges,
couldn't be raised (focusWindow never raises stay_on_top windows), and a
child with no Apps-tab tile had no switcher to come back to once
minimized.
Give child launches the same treatment as tile launches — maximized, so
they get the headless chrome and control drawer — and minimize the
parent behind them, iOS-style. State only: the parent's /app/<name>
history entry already sits beneath the child's, so Back from the child
(and the child's close, which consumes its own entry) lands on the
parent's entry and the existing popstate handler restores it. The parent
keeps running while hidden, so parent/child IPC is unaffected.
Explorer keeps its windowed form, background apps don't minimize their
parent, and an explicit `maximized` option still wins. Desktop mode is
untouched: both changes are gated on is_dashboard_mode, so an app
launching an app there still gets a floating child over a visible
parent.
focusWindow re-raised a focused window's parent (and children) with the
bare z counter. For a file dialog owned by a fullpage/dashboard app that
DEMOTED the app out of the 99999999+ stay-on-top band it was created in,
burying app and dialog under every other open app window — opening a
file picker from an app stacked over another (e.g. an app launched from
Dev Center) made both vanish beneath the window below. The dialog itself
had the same flaw: raised into the plain counter band, under every app.
Raise each window within its own stacking band instead: stay-on-top
windows (and windows hanging off one through parent_uuid, walked up the
chain) get 99999999 + counter; everything else keeps the bare counter —
so desktop stacking arithmetic is unchanged. Same demotion family as the
showWindow restore fix in #3427.
Every path rendered as "> Puter > user > ...", but the root crumb is
noise on anything beneath it. Render it only at the root itself, where
it's all there is to show (the Up button can still reach /, and an
empty breadcrumb bar there would strand the user). Each crumb keeps
its leading caret, including the first.
The global Enter handler preventDefault'ed unconditionally, then
returned without acting unless a launch-menu item, context-menu item,
or selected item existed. That suppressed the browser's native Enter
activation on whatever was focused — a focused link or button (e.g. a
dashboard sidebar item, a dialog button) did nothing on Enter.
Move preventDefault/stopPropagation into the branches that actually
handle the key; otherwise fall through so native activation runs. The
handled paths are unchanged: Enter still opens the selected item,
launch-menu entry, and context-menu entry.
Sidebar items (and the user/collapse/close buttons) had no
:focus-visible style, so keyboard focus painted the UA's default blue
ring — hard-edged and bleeding outside the item. Use the dashboard's
own selection ring instead, tucked inside the control so it hugs the
rounded corners. :focus-visible only, so mouse clicks never paint it.
In dashboard mode there is no taskbar, so a minimized app window's only
switcher was its Apps-tab tile — invisible from the Files tab. Opening a
file, minimizing, and clicking the file again launched a second instance
of the app, stranding the first (and any unsaved edits) somewhere
unreachable.
Make the file row itself the switcher:
- launch_app stamps the opened file's uid on the window (data-file_uid;
the signature's uid wins so shortcuts resolve to their target)
- open_item (dashboard mode) restores/focuses an existing window that
has the file open instead of launching a duplicate, mirroring the
Apps-tab tile's single-instance behavior — keyed by file, not app, so
opening a different file still gets its own instance
- re-clicks while a launch's fetches are still in flight are swallowed
(same idea as TabApps._launchingApps, keyed by file uid, TTL'd so a
failed launch can't swallow clicks forever)
- dashboard file opens now default to maximized, so they get the same
headless full-tab chrome + control drawer as tile launches instead of
a floating titlebar window
- rows show a dot while their file is open in a (possibly minimized)
window — under the name in grid view, inline after it in list view —
driven by the existing dashboard-app-windows-changed event
Desktop mode is untouched: the reuse branch and maximized default are
gated on is_dashboard_mode, and opening the same file twice there still
creates two windows as before.
createAnswer ran outside the connection null-check, so stale or unknown
offer ids threw on the signalling socket. Guard the whole offer path and
return the #message promise from onmessage so callers can observe errors.
File row overflow menus in the dashboard now open anchored to the ⋯ button (below it with right-edge alignment) instead of pointer position, improving placement consistency. UIContextMenu now supports `position.right` for right-edge pinning, and the ⋯ trigger keeps an active visual state while its menu is open so hover styling doesn’t drop when the cursor moves onto the menu.
Safari ignores user-select: none for cursor styling, showing a text I-beam over file rows. Setting cursor: default explicitly fixes this and is inherited by child elements.
Match speech2txt/speech2speech and reject on decoded payload size, not
data-URI string length. Base64 expands ~4/3, so string-length checks
falsely rejected valid images under the 10MB limit.
Completions-family providers used truthy spreads, so temperature: 0 and
max_tokens: 0 were stripped before the SDK call. Align with Responses
providers that already use !== undefined.
* feat: add Speechify TTS driver
Adds a SpeechifyTTSProvider under the ai-tts driver (mirrors the xAI/
ElevenLabs REST-provider shape), registered in TTSDriver alongside the
existing providers. Wires puter-js txt2speech with provider: "speechify"
support, default voice/model, and the speechify-tts driver alias.
Every outbound request sets Speechify-Caller: puter; base URL is
https://api.speechify.ai only; default model is simba-3.2.
* fix: replace placeholder voice IDs with real simba-3.2/simba-english voices
DEFAULT_VOICE and the starter voice catalog used invalid IDs (henry,
cliff, kristy, george, aria) that the real API rejects with 400. Swapped
in confirmed-real voices from a live GET /v1/voices call: geffen_32,
dominic_32, harper_32, hugh_32, imogen_32, and alec for the
simba-english model-override test.
* fix: purge remaining placeholder voice IDs from client, docs, and types
The previous fix only covered the backend provider — the client-side
default in tts.js, its test, the txt2speech docs page, and the ai.d.ts
type comment all still referenced the invalid henry/cliff/kristy/george/
aria set. All replaced with the live-verified voices (geffen_32 default).
Also corrects the docs link to docs.speechify.ai.
Adds Infron (https://infron.ai) as an aggregator provider for the
puter-chat-completion driver, following the OpenRouter provider as
reference per doc/contributing-apis.md.
- OpenAI-compatible gateway at llm.onerouter.pro/v1
- Dynamic model catalog (kv-cached 15 min, authenticated fetch),
filtered to chat-capable LLM entries
- Bills the gateway-reported authoritative cost when present
(usage: {include: true}); falls back to catalog per-token pricing
- Registered as an aggregator so its aliases never shadow
first-party providers
- Offline unit tests (mocked SDK/axios against a real test server)
plus an env-gated integration test
* 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.
7efc0c0b routed a severed opener to `pollDecision` by treating an
already-closed popup as severed, but that reads `closed` synchronously
after `window.open()` — before the navigation whose response headers cause
the severing has committed. Measured in Chromium: `closed` is false at
0ms and true by 200ms. So a site sending COOP: same-origin without COEP
still took the watch-the-window path, and `requestPermission` resolved
false 1.1s after the click while the dialog was still on screen. The
"Allow" that followed committed a grant the site had been told it did not
get. Tell the two apart by when the close lands, and keep the in-flight
message's grace period on both branches — an answer already on its way
outranks whatever the close is taken to mean.
Correctness: a grant that timed out was not withdrawn.
`grant_may_have_committed` was only set in the fetch's `catch`, which
cannot run until the timeout's timer callback returns — and that callback
already calls `fail_grant`, which settles the dialog as a denial outright
when the dialog was force-closed mid-grant. The reconciliation was
skipped in exactly the case it exists for. Record the unknown outcome in
the timer, where the timeout already means the request left the browser.
Also require a `token` from the user-app exchange rather than just a
non-null body: an HTTP failure (a blocked origin, a 5xx) returns the
parsed *error* body, which is truthy, so the guard added for this missed
it — handing the opener an `undefined` token, and prompting for a grant
whose app row was never bootstrapped.
Known limitation, now more reachable: a severed opener cannot signal a
denial at all, since nothing is written for one, so those sites wait out
the poll timeout before receiving false. A grant still resolves promptly.
* Deliver the uncertain-grant withdrawal from a closing popup
The permission dialog reconciles a denial after an uncertain grant by
firing a revoke in the background, but the popup flow posts the answer
and closes the window right after settling — and a plain fetch is
cancelled with its document, so the withdrawal never reached the server
and the user was told "denied" while the grant stayed live. Send it
with keepalive so the browser delivers it independently of the popup,
and cover the popup flow with a regression test (the existing
withdrawal tests only exercise the desktop flow, where the GUI
outlives the dialog).
Also make the popup boot's getAppUIDFromOrigin guard functional: the
helper reports failure by resolving to a null/undefined uid, not by
throwing, so the catch never engaged and a failed lookup clobbered
window.host_app_uid with undefined despite the comment claiming the
token exchange's value was kept.
* Keep the ai-chat model-map build inside the server lifecycle
onServerStart fired #buildModelMap without awaiting or tracking it, so
the network fetches it does (notably Ollama auto-discovery, which is
enabled by default and doomed on any machine without a local Ollama)
kept running after server.shutdown() resolved. In vitest that let the
provider's console.error land during worker teardown, which surfaces as
"Closing rpc while onUserConsoleLog was pending" — the unhandled error
that intermittently fails CI (last seen attributed to
WispController.test.ts). A rejection in the detached chain would also
have been an unhandled rejection.
Track the promise, catch and log rejections, and await it from
onServerShutdown so no provider I/O or logging outlives the server.
Also disable Ollama auto-discovery in setupTestServer's defaults —
every test server was firing a pointless model-list fetch at localhost.
* Settle requestPermission on the launch paths that could still throw
Three gaps left by the permission-request rework, each verified against a
live stack before and after the fix.
The IPC handler normalises a non-object `options` so it can always reply,
but `typeof null === 'object'` let null through the guard; reading
`.permission` off it threw out of the message listener before any reply,
so `puter.ui.requestPermission(null)` hung forever in env=app while
env=web answered false for the same input.
In the env=web branch only the consent-dialog path was wrapped, even
though its own catch reasons that this resolves to a boolean for every
other caller. A `window.open` refused by throwing rather than by
returning null escaped the launch branch and rejected instead.
The perms docs were flipped to platforms: [websites, apps], but every
entry point except `request()` reads the signed-in user's identity
first, so on a signed-out site they reject with Unauthorized and never
prompt — the permission popup deliberately does not sign the site in.
Document the sign-in precondition on those pages.
* Measure a permission's width after the rewrite that decides it
The new grant validation capped `permission` at 255 to match the column
it lands in, but it measured the caller's raw string. `fs:/path:mode` is
rewritten to `fs:<uuid>:mode` before storage, so what lands in the column
is ~44 characters however deep the path is. Granting access to a deeply
nested file therefore returned 400 even though the identical target
granted by uuid returned 200 and stored 44 characters — and because a 4xx
is read as an outright refusal, the permission dialog showed its
retryable error and could never succeed on retry.
Bound the request body only against absurd input, and enforce the column
width in the permission service on the rewritten string, before the app
is resolved so an oversized permission still refuses ahead of a missing
app. Covered both ways: a rewritten-short permission is accepted, and one
that no rewriter shortens is still refused.
* Require a registered app when a dev-app grant names an origin
The user-app handlers resolve a caller-supplied origin through
#registeredAppUidFromOrigin because appUidFromOrigin synthesises
app-<uuidv5(origin)> for an origin with no app row, and the permission
services resolve their identifier as uid-or-name — so the synthetic uid,
derived from a published namespace constant and computable offline,
lands on whoever registered an app under that literal name. The dev-app
handlers were left resolving the raw synthetic uid.
That leg matters at least as much: a dev-app grant is scanned with the
issuer's authority for anyone running as that app, so a squatted grant
hands over the granting user's permission. Verified against the store —
the grant landed on the squatter rather than rejecting.
Without a squatter the synthetic uid resolves to nothing and these
already 404, so the guard costs the legitimate case nothing; a test
covers a registered origin still resolving to its app.
Also assert that revoke accepts the same oversized-but-rewritten
permission grant does, since the dialog's withdrawal of an uncertain
grant depends on that symmetry.
* Revoke the row a user-app grant actually wrote
`app-root-dir:<uid>:<mode>` is a pseudo-permission: its rewriter resolves
it to a real `fs:<root_uid>:<mode>` only while a user-app permission row
is being written, and resolves to a match-nothing sentinel at all other
times so a scan can't match through the fs path.
Revoke shared that rewrite but not the flag, so it aimed the DELETE at
the sentinel: it removed nothing and reported success while the fs
permission stayed live. The permission dialog withdraws a grant whose
outcome it couldn't confirm through exactly this path, so a user who
answered "Don't Allow" after a dropped grant response kept the access
they had just refused.
Grant and revoke now share one rewrite helper. It also has to work for a
caller outside a request scope — an internal job, or a direct unit test —
where `Context.set` has nothing to set the flag on; an empty scope reads
the same as no scope, so it only makes the flag settable.
* Elide a long host from the left, as its own rule intends
The identity line is the only thing on the permission dialog naming the
requester, so a host too long for the dialog has to lose its front, not
its tail: the registrable domain is the part that says who is asking.
`direction: rtl` was there for that, but paired with
`unicode-bidi: plaintext` it does nothing — plaintext takes the base
direction from the content's own first strong character, which for any
Latin host is LTR, so the ellipsis went back on the right. Measured in
the real dialog, `account-security.paypal.com.verify-login.example`
rendered as `account-security.paypal.com.verify-l…`, reading as PayPal.
Isolating instead keeps the box anchored to the end of the text, and
still stops a bidi control character in the host from reordering
anything around it.
The test that covers this asserted the computed `direction` — the
property, not the outcome — so it passed throughout. It now measures
which characters are actually on screen, and that the host still reads
in source order.
* Tell a severed opener from a closed one by whether it can answer
The web popup flow decided which it was looking at by timing: a
`popup.closed` flip within 3s of `window.open()` was COOP severing the
opener, anything later was the user closing the window. Both halves
misfire, and both were reproduced against a real popup.
A COOP-only site whose popup navigation commits after the cutoff had its
severing read as a close, so the site was told "denied" about a second
later — while the prompt was still coming up. The Allow the user went on
to click then committed a grant the site had been told it did not get,
which is the failure the cutoff was introduced to prevent: how long a
navigation takes says nothing about whether the opener survived it.
The other way round, a signed-in site whose user dismissed the popup on
sight had that close read as severing, and fell back to polling for a
decision. A denial writes nothing to poll for, so the caller waited out
the full five-minute timeout instead of being answered.
So ask the question directly: the popup now announces itself to its
opener, which it can only do while the relationship is intact. Having
heard from it proves a later close is a real close and the answer is now;
never hearing from it means the prompt may be live in a window that
cannot answer, and the decision is read back from the server as before.
The announcement carries no token, and goes out before any sign-in gate —
a gate that delayed it would make abandoning sign-in look severed.
Measured: the popup announces itself ~290ms after opening, and a close
just after that is answered in ~1.3s rather than five minutes.
* Stop revoking a literal `*` after a dev-app revoke-all
The `*` arm of /auth/revoke-dev-app fell through: after
`revokeDevAppAll` it also ran `revokeDevAppPermission(…, '*')`, a
DELETE naming a row called literally `*` — which matches nothing —
plus a second `revoke` audit entry for the same action. The user-app
twin already if/elses its two arms; the dev-app handler now matches it,
and a test pins the parity: everything revoked, one audit row.
* Match the popup's messages against a canonical GUI origin
The web flow compared `event.origin` to `puter.defaultGUIOrigin` as raw
strings, but they are different kinds of value: the event carries the
browser's canonical origin serialization, while the configured origin is
whatever text was supplied — a trailing slash, an explicit default port,
or a stray path all name the same origin and all fail the comparison.
The mismatch doesn't read as a config error, it reads as the user's
answer: with every message from the popup dropped, the missing
`permissionPromptReady` makes the popup's close look like a severed
opener, and the missing decision leaves that path to answer on its own —
"denied", for a guest, moments after the user clicked Allow and the
grant committed.
Parse the configured origin once and compare canonical-to-canonical; the
popup URL is built from the same parsed origin, so a trailing slash no
longer yields a `//action/...` path either. A configured origin that
cannot parse could never have hosted the prompt, so it now denies up
front instead of opening a broken window. Pinned by an e2e test that
re-points the SDK at the same GUI through a trailing-slash origin and
expects the grant to be heard; it fails against the raw comparison.
* Take a permission popup's requester from the browser, not the link
`app_uid` was removed from the request-permission URL because the uid
names who receives the grant, so it has to come from the requesting
origin rather than from whoever built the link. Two other parameters
still carried exactly that identity, and the origin is the identity
twice over: it is the name the dialog attributes the request to, and it
is what the server resolves into the app the grant is written against.
`opener_origin` is believed on every popup boot, ahead of the referrer.
`origin` was the fallback in the request-permission block itself,
reached whenever there is no opener at all. Either one lets a bare link
raise a consent prompt in some other app's name — the token exchange
bootstraps an app row for whatever origin was typed, so the grant then
commits against it — while the user is looking at a domain the requester
does not control. That is the whole dialog defeated: a site can name
`docs.google.com` and have the Allow land on Google's app row.
So a permission popup now takes only an origin the browser vouches for:
`document.referrer`, or the opener's own reply to the `requestOrigin`
handshake. Neither can be forged to another origin. Nothing legitimate
relied on the parameters — the SDK sends neither, and the OIDC redirect
`opener_origin` exists for drops `action` too, so no permission flow can
arrive through one. The `origin` fallback only ever fired when there was
no opener, which is to say when there was no requester either; that path
now reports its denial as usual instead of prompting.
The referrer is safe here precisely because `action` is only
`request-permission` on the popup's first load, so it is always the
opener's. Restoring the action across an OIDC hop would break that — the
returning navigation's referrer is the identity provider — which is why
the round trip is withheld rather than repaired:
A popup that signs in through OIDC comes back to a redirect URI the
server hard-codes to `/action/sign-in`, so it returns believing it is a
plain sign-in popup. It posts `puter.token`, which the SDK's global
listener feeds straight into `setAuthToken()`, and never runs the action
it was opened for. For a permission prompt that is the exact outcome
`deliversTokenToOpener` exists to prevent: the site is signed in without
ever asking, the user is never shown the permission they were brought
there to decide, and the request resolves as a denial. Nothing in the
returned URL says what the popup was for, so it cannot recover on its
own. Until the redirect can carry the action back — and the opener
origin can be re-established from the handshake on return — a popup
whose purpose cannot survive the hop does not offer the hop. Email
sign-in stays in the window and is unaffected.
Pinned by e2e tests that spoof each parameter and expect the prompt to
name the real opener, or not to appear at all. The long-hostname test
drove the dialog through `origin=`, which no longer produces one, so it
now asserts the CSS elision contract directly while the popup test
asserts the real flow applies the `perm-dialog-entity-host` class that
contract keys on.
* Refuse an origin the grant could never have named
The identity line elides a long host from the left, keeping the
registrable domain visible, which it does with `direction: rtl`. That is
sound for a host — every character in one resolves left-to-right, so the
string reads in source order — but it is not sound for arbitrary text,
whose neutral and RTL runs can render in an order they were not written
in. On the one line of this dialog whose whole job is saying who is
asking, that is the wrong thing to be lenient about.
The entity resolver reached that state through its own fallback: when
`new URL(origin)` threw it displayed the unparsed string and still marked
it as a host. An origin the server cannot parse cannot name a grant
target either — `AuthService#originFromUrl` rejects it, and rejects
non-http(s) schemes with it — so there was never anything to prompt
about. Deny at the gate, next to the existing "requester the grant can't
name" check, on the same test the server applies.
* Give the decision poll a deadline it can actually reach
`pollDecision` bounds itself with a five-minute budget, but it only reads
the clock between iterations and its `fetch` had no timeout of its own. A
request that never settles — a stalled connection, a proxy that accepts
and never answers — parks that `await` forever: the loop never comes
back round to check, `settle` is never called, and the caller's promise
stays pending for the life of the page with the message listener and
interval still attached. It is the only unconditional hang left in the
flow, and the least recoverable one, because the popup is already closed
on this branch and nothing the user does can rescue it. Each attempt now
gets ten seconds — long enough that a slow-but-working connection is
still heard, short enough that the deadline means something.
The request id changes for a related reason. It was `#messageID++`, a
small integer restarting at 1 on every page load, and `event.source` is
not pinned for as long as the no-gesture consent dialog waits for its
Continue click — a stretch of time the user paces. A permission popup
left open from before a reload posts this exact message shape to its
opener on the way out, and its counter value collides with a fresh
request's, settling it with the decision the user made about a different
permission. A random suffix makes the two impossible to confuse; the GUI
echoes the value back verbatim, which the loose comparison still handles.
* Answer the app when the signup gate throws
The requestPermission branch grew a `respond` helper so every exit tells
the app something and its promise settles instead of hanging. One exit
still doesn't: `await UIWindowSignup(...)` is outside the guarded region,
and `ipc_listener` has no outer catch, so a throw from the signup window
escapes the listener entirely — before any reply — and leaves the app
waiting forever. That is the precise failure the helper was added to rule
out. Treat it as the refusal it amounts to.
* Revert "Keep the ai-chat model-map build inside the server lifecycle"
This reverts commit 1b2482dcb8.
When dragging is aborted by returning false from the sortable 'start' handler, the cursor plugin never fires its cleanup, leaving the body cursor stuck on 'grabbing'. Reset the body cursor explicitly on each early-return path.
Trash is accessible via the sidebar and should not appear as a row in the file explorer. Filters it out from directory listings and prevents socket events from re-adding it to the view.
Add a `rubberband-selecting` state on the dashboard while marquee selection is active in `TabFiles`, and remove it when drag ends. CSS now disables pointer events on surrounding files chrome (sidebar, directory tree, and header) during that state so hover highlights/tooltips don’t trigger when the cursor leaves the clamped selection area.
Make Home consistently visible and selected in file navigation. The file tab now shows the Home directory entry and auto-selects Home (instead of Desktop) when no initial path is provided. In `UIWindow`, saved sidebar layouts are backfilled to include Home if older preferences are missing that entry, preventing users from losing quick access after upgrades.
Extract duplicated trash icon update code into two shared helpers:
- `window.update_trash_icons(is_empty)` updates all trash icon surfaces (taskbar, desktop items, shortcuts, open window headers, Dashboard sidebar) in one place.
- `window.refresh_trash_state()` stats the trash, emits the socket event, and calls `update_trash_icons`.
Replace all scattered inline stat+emit+icon-update blocks across UIItem, UIWindow, UIDesktop, UIDashboard, keyboard, helpers, and generate_file_context_menu with calls to these helpers. Also syncs the Dashboard sidebar trash icon on load and after every destructive operation.
Uploads now land selected wherever they finish: the desktop, explorer
windows, file dialogs, and the dashboard Files tab. Selection is applied
from both completion channels — the batch response selects elements
already in the DOM, and items whose item.added socket event arrives
later are remembered (with a TTL) and selected on creation. The explorer
footer count and the open-file dialog's Open button are updated to
match, and disabled items (e.g. filtered out by a dialog's file-type
filter) are never selected.
Landing on /app/<name> logged-out and authenticating used to dump the
user at the root dashboard on several paths, losing the app they came
for:
- OIDC login/signup only sent return_to for /desktop and /dashboard
(and the backend whitelist only accepted those two), so OIDC from an
app landing redirected to /.
- UIWindowSignup defaulted its post-success redirect to /, so password
signup reached via the session list, ?action=signup, or in-app signup
prompts (IPC.js) lost the app.
- OIDC error redirects always went to /?action=..., so a recovered
attempt (e.g. account-not-found bounced to signup) also lost the app.
New helpers in src/gui/src/helpers/auth_redirect.js:
- get_auth_redirect_url(): stay on the page auth started from;
/action/* pages go to /; strips action/auth_error/message/
request_code so the reload doesn't re-open the auth window or pass
auth params through to the app as launch args.
- get_oidc_return_to(): pathname when whitelistable, now including
/app/<name> (trailing slash normalized).
Backend (OIDCController):
- Shared isWhitelistedReturnPath() accepts /desktop, /dashboard, and
/app/<name> (charset mirrors APP_NAME_REGEX — no open redirect).
- buildErrorRedirectUrl() lands on the whitelisted originating page
from the signed state's redirect_uri instead of always /.
Tested: 47/47 OIDC controller tests pass (4 new: return_to accepted/
rejected on start, success redirect to /app/<name>, error redirect
keeping /app/<name>); verified live on local dev for first-visit temp
user, password signup (incl. email-confirmation gate), and password
login — all land on /app/camera with the app open.
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.
The cover-page rule added for the auth windows forced `width: 100%` on
every `.window-cover-page .window-body`, including the popup file,
directory, and save pickers. That body is an item-container with
`overflow: auto`, so it establishes its own formatting context and may
not overlap the floated `.window-sidebar`. Unable to shrink to fit, it
dropped below the sidebar, pushing the file list and the Cancel/Open bar
outside the 700x400 popup and leaving the page background showing
through as grey.
`.window-body-filedialog { width: auto !important }` was meant to cover
this but loses on specificity, one class against two. Scope the auth
rule with `:not(.window-filedialog)` instead; that class is only set for
the three dialog kinds, so no auth or session cover page changes.
Extracted desktop `item.added` container update logic into a shared `apply_item_added_to_containers` helper and wired it into both `UIDesktop` and `UIDashboard`. The helper handles overwrite updates, new item insertion, icon refresh, and resorting for matching item containers. This fixes dashboard-hosted explorer/file dialogs not reflecting uploads until navigation.
Replace reuse of `#upload-file-dialog` with a dedicated hidden file input owned by `init_upload_using_dialog`. This avoids collisions with the dashboard Files tab’s `onchange` property handler, which could trigger duplicate concurrent uploads and "Entry already exists" races. The new flow stores the current target path once per dialog open, clears input value before click (so re-selecting the same file still fires `change`), and snapshots `FileList` to an array before clearing to keep async uploads reliable.
- Replace the full-tile drag ghost with a compact icon+name chip:
stacked sheets and a count badge for multi-selections, dimmed
source rows, same design in list and grid views.
- Holding a dragged item over back/forward/up or a breadcrumb
segment now spring-navigates there (700ms dwell); cancelling
the drag returns to the original directory.
Selected tiles/rows now use a subtle accent tint plus hairline ring
instead of a solid --select-color fill, so icons and labels keep their
natural colors in both themes. Also refines the grid tile's hover
'...' button (inset, hairline border, hover state), fixes the
light-mode selected-row text color that referenced a dark-mode-only
variable, and drops the opaque icon chip visible on tinted list rows.
Refactors the dashboard’s mobile sidebar into a modal-style drawer with a coordinated state helper, scrim overlay, and dedicated close button. Adds accessibility improvements (`aria-label`, `aria-expanded`) and stronger dismissal behavior (scrim tap, close button, Escape, and swipe-left with velocity/threshold handling). Updates mobile CSS for safe-area-aware positioning, improved touch targets/pressed states, frosted toggle styling, drawer shadows/transitions, and drag-state handling for smoother interactions.
The minimize controls consumed the app's /app/<name> history entry
first and let the popstate handler do the visible hide. When the app's
iframe had stacked joint session-history entries, the pop only settles
at pop_dashboard_app_url's 400ms watchdog — so clicking minimize sat
still for ~half a second before the morph began.
Hide the window in the click frame instead (minimize_window, shared by
the head button, the context menu, and the control drawer) and demote
the pop to pure URL bookkeeping. The eager hide is safe against both
ways the pop settles: every hideWindow branch marks data-is_minimized
synchronously, and the popstate handler and the watchdog both already
skip minimized windows. The browser's Back button still minimizes
through the popstate handler as before.
Minimize (and close's URL cleanup) on headless dashboard app windows
work by consuming the app's /app/<name> history entry with
history.back() and letting the popstate handler do the actual hide.
But back() traverses the JOINT session history, which the app's iframe
shares: if the app navigated internally after load (SPA router,
redirects — and browsers differ on which iframe navigations stack
joint entries), the back() consumes the IFRAME's entry instead. The
parent's URL never changes, no popstate arrives, and the
dashboard_url_pop_pending latch stays stuck — every later minimize and
close becomes a silent no-op, and the stranded /app/<name> entry makes
later traversals resurrect windows the user already dismissed.
Fix with a watchdog after each back(): a successful pop is a
same-document traversal of the parent (app entries are pushState over
the dashboard route), so its popstate lands well inside 400ms and the
watchdog no-ops. If the URL still names the app by then, do what the
popstate would have done — minimize through the same lookup and
minimized-guard — and replaceState the stranded entry back to the
dashboard's own route (captured at push time, tab hash included) so no
stale entry survives.
* Dashboard: redesign app control pill as a top-edge drawer
The floating control capsule over headless dashboard apps consumed
noticeable space, especially on mobile. Replace it with a drawer: a
slim glass tray flush with the top edge (icon + title + minimize +
close) whose tongue-shaped handle hangs from its bottom center. At
rest only the tongue peeks in from the edge; hovering it (mouse),
tapping it, or keyboard-focusing the drawer slides the tray down.
The collapse is a single GPU transform instead of the pill's
max-width squeezes, and the open-then-retract intro now visibly
retracts into the tongue, teaching where the controls live.
Hover-expansion is gated on pointerType === 'mouse': touch taps
synthesize a pointerenter right before click, which made the old
pill's toggle see an already-open state and shut it again.
Also fixes two window-stacking bugs surfaced while testing:
- showWindow demoted stay_on_top windows (every fullpage/dashboard
app window, created in the 99999999+ z band) to a plain counter
z-index on restore. focusWindow deliberately never raises
stay_on_top windows, so after minimize/restore a single dashboard
focus buried the app permanently. Restore now re-raises into the
stay-on-top band.
- Pressing the drawer now focuses its window, as the titlebar it
replaces did. The document-level activation handler works off
mouseover_window, which only mousemove refreshes — a tap with no
intervening mousemove (touch, restored windows) would re-raise
the dashboard over the app.
* Dashboard: restored windows come back with the drawer collapsed
The restore re-played the open intro (expand + auto-retract). A restore
should bring back the app, not the chrome — the user already met the
controls on open, and they just USED them to minimize. showWindow now
forces the drawer shut instead, which also covers the Back-button
minimize path, where the window hides without touching drawer state.
* Dashboard drawer: left-align the title against the icon
The tray is asymmetric — one icon on the left, two buttons on the
right — so a title centered in its fixed box landed off the tray's
true center and read as misaligned against the centered tongue below.
Left-aligned, icon + title cluster as the identity on the left and
the controls cluster on the right.
* Dashboard drawer: morph the tongue into the tray instead of sliding
The drawer is now ONE glass surface that changes shape, rather than a
tray that slides in from off-screen with a handle below it. At rest
it's the tongue; opened, the same surface swells into the tray while
the grabber bar rides the morph and settles into a slim strip along
the tray's bottom edge as the dismiss handle — same handle, both
states. Content is revealed from the center outward through an inner
clipping layer (the surface itself can't be overflow:hidden without
clipping the toggle's touch halo) and condenses in/out of focus with
an opacity + blur + scale crossfade. Opening gets a soft overshoot
and settles; closing is quicker and lands without bounce.
The open width is DERIVED in CSS from the same part variables that
lay out the controls row, so the surface always fits its content
exactly and breakpoints (touch sizes, hidden title under 500px) only
override parts.
* Refine dashboard drawer open-state handle
Update the headless dashboard app drawer so the grabber bar disappears when the drawer is open and only the bottom dismiss strip remains interactive. This adjusts tray/strip sizing on desktop and mobile, adds opacity timing for smooth dissolve/reappear transitions during open/close, and aligns in-code docs/comments with the new behavior.
* Dashboard: fix minimized apps getting permanently buried under the dashboard
Fullpage/dashboard app windows are created stay_on_top in the
99999999+ z band, but showWindow's restore path demoted them to a
plain counter z-index — and since focusWindow deliberately never
raises stay_on_top windows, the demotion stuck: one dashboard focus
after a minimize/restore buried the app permanently (URL and tab
title still naming an app the user could no longer see). Restore now
re-raises stay-on-top windows into their band.
Also make the control pill focus its window on mousedown, as the
titlebar it replaces did. The document-level activation handler works
off mouseover_window, which only mousemove refreshes — a tap with no
intervening mousemove (touch, restored windows) would activate the
stale window and could re-raise the dashboard over the app. Deferred
a tick so it runs after that handler and wins.
* Dashboard: restored windows come back with the pill collapsed
The restore re-played the open intro (expand + auto-retract). A restore
should bring back the app, not the chrome — the user already met the
controls on open, and they just USED them to minimize. showWindow now
forces the pill shut instead, which also covers the Back-button
minimize path, where the window hides without touching pill state.
* Dashboard pill: left-align the title against the icon
The pill is asymmetric — one icon on the left, two buttons on the
right — so a title centered in its fixed box landed off the pill's
true center and read as misaligned. Left-aligned, icon + title
cluster as the identity on the left and the controls cluster on
the right.
* Dashboard: headless full-tab apps with a floating control pill
Maximized app windows in dashboard mode no longer render a titlebar —
the app covers the full tab. The head's controls move to lighter-weight
affordances:
- A floating pill overlaid top-center of the app (parent DOM, above the
iframe): expanded it shows the app icon, title, minimize, and close;
it opens expanded so first-time and deep-link users see it, then
collapses to a subtle capsule that re-expands on hover, tap, or
keyboard focus. Minimize routes through the same URL-entry consumption
as the Back button; close tears the window down normally. Dark
translucent glass so it reads over any app content; safe-area aware;
bigger targets on coarse pointers; title hidden under 500px; respects
prefers-reduced-motion.
- Apps-tab tiles double as the app switcher: a macOS-dock-style running
dot marks tiles with a live (visible or minimized) window, and the
tile context menu gains a Quit item for running apps. UIWindow
broadcasts window open/close so the dots stay current.
- puter.ui.setWindowTitle also updates the pill title.
Only maximized, URL-owning app windows in dashboard mode go headless:
dialogs, explorer, non-maximized child windows, and everything on the
desktop keep their titlebars. dashboard.css's 29px head-height
compensation is scoped to windows that still have heads, so headless
app bodies fill the window exactly.
* Fix pill buttons swallowed by their own hover halo
The pill's invisible hover halo (an absolutely-positioned ::after with
negative inset) painted above the pill's static children, so real
pointer clicks hit-tested to the halo and never reached the minimize/
close buttons — while element.click() (which skips hit-testing) worked,
hiding the bug from programmatic checks. Stack the halo at z-index -1
(the pill is its own stacking context, so the outside ring still sits
above the app's iframe) and raise the interactive children explicitly.
* Review round: pill/dashboard edge-case fixes
- Latch pop_dashboard_app_url while its history.back() is in flight: a
double-click on minimize (or a close racing a minimize) issued two
back()s, and the second popped the dashboard's own entry — navigating
clean out of the page. Any popstate or push settles the latch.
- Close a running app's windows when it is uninstalled: the tile is a
headless app's only switcher, so a minimized instance would otherwise
be stranded — impossible to restore or quit.
- puter.ui.setWindowTitle in dashboard mode now also updates the
browser-tab title (while that app owns the URL) and the window's
data-name, so a later restore re-applies the app's current title
instead of its launch-time one. Desktop behavior unchanged.
- Nudge collapsed-pill contrast (opacity 0.55 → 0.65, border alpha
0.16 → 0.22) so the capsule stays findable over dark app content.
* Adjust dashboard tile spacing and label sizing
Tweaks My Apps tile layout constants to match the real icon/label geometry: tile height is increased, label top margin is widened, and the running-status dot is moved slightly lower to avoid overlap during icon hover scaling. It also raises dashboard app pill title line-height to prevent descenders from being clipped when ellipsis overflow is active.
* Pill and running-dot visual polish
- Fixed-width pill: the title gets a fixed 150px centered box instead of
a cap, so every app's pill is identical and the minimize/close buttons
sit at the same screen position in every app — they're operated by
muscle memory, like any titlebar's controls.
- Give the pill title line-height 1.35: overflow:hidden (needed for the
ellipsis) also clips vertically, and at line-height 1 it cut the
descenders off g/p/y.
- More air around the tile running dot: label margin 6px → 10px and the
dot 2px lower (tile row height 78px → 82px to keep the label line in
budget — the pager layout reads the CSS var, so it follows). The
icon's 1.08x hover growth no longer touches the dot.
* Size the pill icon to balance the capsule
16px read as undersized next to the 26px buttons in the 36px capsule —
bump the expanded icon to 20px with a proportional (25%) radius, and
object-fit: cover in case an app icon isn't square. Collapsed size is
unchanged.
- Opening an app in dashboard mode pushes a real history entry at
/app/<name>; Back minimizes the app (it keeps running), Forward
restores it — or relaunches it if it was closed
- The minimize/close buttons consume the entry via history.back() so
the address bar never names an app that is no longer on screen
- Landing directly on /app/<name> now boots the dashboard with the app
open maximized in-page instead of the desktop; embeds, popups, and
explicit fullpage boots keep the desktop flow, and fullpage_on_landing
no longer opts a landing out of the dashboard
- Desktop mode no longer rewrites the URL at all (no /app/<name> or
folder-path replaceState on focus, no /desktop resets on close or
minimize); tab titles still track the focused window
Update `showWindow` to use a shorter, window-only morph when restoring a minimized app from a dashboard tile. `morph_window_from_tile` now accepts options for `icon_half` and `duration`, gates icon ghost creation accordingly, and derives transition/cleanup timing from the configured duration so un-minimizing feels distinct from a full app launch.
Update the dashboard app tile click handler so Ctrl/Cmd+click opens apps in a new tab (matching the context menu behavior) while keeping existing target-link/app-name routing. Also switch the default body background to white and add a dark-mode media query to use a dark background color.
Removes `isMobile.phone` checks that previously disabled dashboard tile morph transitions. Window open/close morph selection and tile-launch ghost setup now run on phone the same way they do elsewhere, while still honoring animation-disable and reduced-motion conditions.
Add click-time launch feedback for dashboard app tiles so users get immediate visual response while app startup is still loading. TabApps now starts and settles tile launch state around `launch_app`, logs launch failures, and keeps duplicate-launch protection.
UIWindow now tracks in-flight tile feedback with a WeakMap, introduces `begin_dashboard_tile_launch`/`settle_dashboard_tile_launch`, and updates `morph_window_from_tile` to avoid replaying the icon half when it already ran at click time. Ghost/icon timing and cleanup were adjusted so fallback and failed launches do not leave stale tile state.
A fresh launch has a server round-trip (app info, signing) between the
click and the window's creation, so the open morph used to start late —
on a slow connection the click gave no feedback at all. The two halves
of the morph are now decoupled:
- On click, begin_dashboard_tile_launch starts the icon's half
immediately: the ghost enlarges in place and dissolves exactly as
before, and the real icon stays hidden — the empty slot reads as
'launching' — while the fetches run.
- When the window opens, morph_window_from_tile claims the click-time
ghost instead of spawning a second one, runs the window half alone,
and restores the icon when it lands. On a fast launch the halves
overlap into one motion; on a slow one the window zooms out of the
empty slot.
- settle_dashboard_tile_launch (in the launch's finally) puts the icon
back whenever the window never claims it: launch failed, app opened
in background, or the morph fell back to the plain fade. The launch
also gained a .catch, so a failed launch logs instead of leaking an
unhandled rejection.
The un-minimize path has no fetch and is untouched: it still spawns its
own ghost inside the morph.
* feat(gui): open dashboard apps maximized in-page with minimize + single instance
Apps launched from the dashboard's Apps tab now open as maximized Puter
windows in the same page (via launch_app) instead of a new browser tab.
External website shortcuts still open in a new tab since arbitrary sites
can't reliably be iframed.
Dashboard app windows keep their minimize button (fullpage mode normally
hides it since there's no taskbar). With no taskbar item to animate
toward, hideWindow now hides the window in place and flags it with
data-minimized_in_place; showWindow un-hides it via that flag. An
explicit flag is used because data-orig-* can't distinguish the two
minimize paths - drag/maximize handlers set those attrs too.
Tiles are single-instance: clicking a tile un-hides a minimized
instance or focuses a visible one instead of launching a duplicate, and
launches in flight swallow repeat clicks.
Also fixes .window-body-app height in dashboard mode: fullpage mode
sizes it to 100% assuming no titlebar, which clipped the bottom 29px of
every app.
* feat(gui): iOS-style minimize-to-icon morph in dashboard mode
Minimizing a window in dashboard mode now zooms it into its app's tile
on the Apps tab — but only when the user can see that tile (Apps section
active AND the tile on the pager page currently in view); otherwise the
existing hide-in-place fade is kept, as on phones and with
prefers-reduced-motion.
The animation is a two-layer morph on one 450ms cubic-bezier(0.32,
0.72, 0, 1) path: the live window scales onto the tile's icon box
(transform + opacity only — no per-frame iframe reflow) while its corner
radius grows toward the icon squircle, and an enlarged clone of the icon
flies congruently with it, cross-fading in as the window fades out and
landing exactly on the real icon's slot (hidden until the handoff), so
the window reads as becoming the icon.
Window geometry is never touched: only inline transform/opacity/radius
are set and restored byte-identical when the animation ends, and
showWindow()'s existing minimized-in-place path restores as before —
including a reopen mid-animation.
* fix(gui): keep the minimize-morph ghost icon square (contain-fit)
The icon clone previously mapped rect-onto-rect over the window, which
stretched the square icon anamorphically. A covering square was tried
and jittered: it protruded past the card's short edge and the halo
popped in mid-crossfade.
Contain-fit resolves both: the ghost scales uniformly (always square),
sized to the window's short edge and centered — its side tracks the
card's short edge exactly for the whole flight (both interpolate
linearly between the same endpoints on the same curve), so the card's
rectangular flanks melt away around a steady, undistorted icon.
* feat(gui): add 'Open in new tab' to app tile context menu
Every tile's right-click menu now leads with Open in new tab, which
opens the app the way tiles did before in-page windows: /app/<name> in
a new browser tab (noopener), or the site link for external tiles.
Non-uninstallable apps previously had no menu at all; they now get this
single item, and uninstallable ones show it above a divider + Uninstall.
* feat(gui): reverse morph — dashboard tile icons zoom into their opening window
Opening an app in dashboard mode now runs the minimize morph backwards:
the window grows out of its tile's icon box (transform + opacity only,
radius relaxing from the 22% squircle to the resting value) while a clone
of the icon enlarges IN PLACE on its slot — capped at 2.5x, fully dissolved
by ~250% growth — as the window fades in underneath on the same 450ms
cubic-bezier(0.32, 0.72, 0, 1) path with the crossfade handoff mirrored
(window in at 80-240ms, ghost out at 120-320ms).
Both open paths get the morph, with the same visibility conditions as
minimize (Apps section active, tile on the pager page in view, not phone,
no reduced motion), falling back to the existing fades otherwise:
- fresh launch: TabApps passes window_options.morph_from_dashboard_tile,
and UIWindow morphs instead of the 70ms opening fade. Since .window is
display:none from the stylesheet until later in the pipeline, the hook
shows it before measuring (nothing has painted yet, so neither the early
show nor the hide-back on a declined morph can flash).
- un-minimize: showWindow's minimized-in-place branch morphs instead of
fadeIn(150), keeping the z-index bump and 80ms focus timing.
A data-window_morphing flag guards against opposing morphs fighting over
the window's inline styles: minimize clicked mid-open-zoom (or a tile
click mid-minimize-zoom) falls back to the pre-morph fade behavior, and
each animation still restores every inline property byte-identical when
it ends.
* Update dashboard.css
* feat(gui): cap the open-morph ghost icon at 2x, dissolving in place
The tile icon no longer flies toward the window center nor grows to full
contain-fit (10x+ reads as a giant blurry sticker): it enlarges in place
on its own slot and its growth is capped so the fade-out completes right
around 200% of its size — the window alone carries the motion and the
rest of the growth.
* fix(gui): drop the tile :active press-shrink that stuttered the open morph
Clicking a tile launches the app and the open morph grows the icon in
place — the 0.95 press-shrink made every launch read as shrink-then-grow.
The icon now holds its hover scale through the click and enlarges
straight from there (the morph ghost is measured from the rendered rect,
so it picks up the hovered size seamlessly).
Adds a 'View Users' link on the app Analytics tab that opens a modal
listing users who have authenticated into the app (username + email,
where the user has granted email:read to the app).
Consumes the existing puter.apps.get(...).users()/.getUsers() client
SDK method, backed by the app-telemetry driver's get_users - no new
backend work needed, per discussion on the issue.
Relates to #3349
* Update docs meta tags and description
* Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
---------
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
* feat(email): accept custom message headers in sendRaw
Lets callers set transport-level headers such as List-Unsubscribe /
List-Unsubscribe-Post. Also documents the puter-email driver's automatic
unsubscribe / report-abuse footer and the new `suppressed` result field
in the puter.js email module.
* feat(email): envelope override in sendRaw + suppressed in EmailSendResult type
Adds an optional `envelope` field to SendMailOptions so callers can set
transport recipients independently of the visible To/Cc headers (used by
the email-send driver for per-recipient deliveries), and adds the
`suppressed: string[]` field to the public EmailSendResult typing to
match the driver's documented result.
Co-Authored-By: Claude Fable 5 <[email protected]>
* feat(email): document per-recipient best-effort delivery via `failed`
The email-send driver now attempts every recipient's private delivery
even when one fails, returning failed addresses in the result's
`failed` array instead of failing the whole call — so callers retry
only the failed subset and never re-mail delivered recipients. Adds
the field to the public EmailSendResult typing and module docs.
Reworked the Files view toggle into a context menu with List, Compact Grid (`grid-sm`), and Grid modes, and refactored view handling into shared helpers (`isGridView`, `viewClass`, `setView`, `applyViewMode`) so grid behavior is applied consistently across rendering and drag previews. Added compact-grid CSS overrides to reduce tile, icon, text, and badge sizing while reusing existing grid layout behavior.
Switches the Files tab header navigation buttons to new thin arrow SVG assets and aligns icon styling with a lighter visual weight. It also updates several header action SVG paths (new folder, upload, list/grid, sort, select) and sets path action icon color to #5c5c5c so the controls look consistent.
Apps tab: tile rows only budget one label line, so two-line app names
made flexbox shrink the icon box into a landscape crop and clip the
label mid-glyph. Clamp labels to a single line and exempt the icon box
from flex shrinking.
Files tab: header hover states (nav arrows, breadcrumbs, action
buttons) now use a neutral #EEE chip and no longer recolor the glyph;
nav arrows also render at full opacity when enabled.
Prevent app tile icons from shrinking when labels overflow, and clamp tile labels to a single line so they fit within the fixed tile height. This keeps the grid layout stable while preserving full app names in the existing tooltip.
* fix: don't double-refresh Dashboard tabs on browser back/forward
A single back/forward navigation fires both popstate and hashchange, so
handleRouteChange ran twice, calling the tab's onActivate twice — every
back/forward to the Apps tab issued duplicate /installedApps and
/get-launch-apps requests and re-rendered the tab twice. Track the last
handled URL and skip the second event.
* fix: store raw values in Dashboard file-row data attributes
renderItem passed html_encode()d strings to setAttribute, which stores
them literally — a file named "Tom & Jerry.txt" got
data-path="...Tom & Jerry.txt". Every flow that reads the attribute
back (keyboard cut/copy/paste, delete-to-trash, drag-and-drop, selection
actions) then hit the server with the mangled path and failed with
"Entry not found". The socket item.renamed/item.updated handlers had the
same problem via jQuery .attr(), and item.moved/item.removed matched rows
with html_encode()d selector needles that can't match raw attributes.
Store raw values everywhere (matching the desktop convention and the
raw readers throughout TabFiles), match paths by comparison instead of
selector interpolation, and repair the html_encode(x) ?? fallback
expressions that could never take their right-hand side. Verified live:
copy/paste of a name containing '&' now succeeds.
* fix: escape folder names in Dashboard nav-history menu (stored XSS)
The back/forward taphold menus interpolated path.basename(history_item)
straight into UIContextMenu item html, which renders it verbatim. A
folder named with an HTML/script payload executed when the user opened
the history menu after visiting it. The desktop file manager already
encodes this value; match it with html_encode(). Verified live: the
payload now renders as inert text and its onerror never fires.
* fix: harden Dashboard hash routing against malformed and unknown tabs
Three routing defects, all reachable from a shareable URL:
- A malformed percent-sequence (e.g. `#100%`) made parseDashboardRoute's
decodeURIComponent throw at module load, blanking the entire GUI. Guard
the decode and fall back to the raw hash. Verified: `#100%` now boots.
- A hash whose tab segment contained a quote (`#foo"bar`) was interpolated
into a jQuery selector that throws, leaving dashboard event handlers
unbound. Resolve the route tab against the known tab set (falling back to
Apps) before it ever reaches a selector. This also fixes an unknown hash
activating the Apps section without its `.dashboard-content.apps` styling.
- Re-clicking the already-active sidebar tab pushed duplicate history
entries, so Back became a no-op until pressed repeatedly. Only pushState
when the hash actually changes.
Verified live for all three.
* fix: keep Dashboard file list footer and size cells in sync on live updates
Incremental updates only touched hidden data attributes, so the visible
UI drifted from the data:
- Adding a file via the socket (item.added) or creating one inserted a
row but never called updateFooterStats, so the "N items · size" footer
stayed frozen at the old count. Removals and moves had the same gap.
- item.updated (and the in-place update in UIDashboardFileItem) rewrote
data-size/data-modified but not the on-screen .item-size/.item-modified
cells, so a remote overwrite left a stale size (e.g. "5 B" for a file
that had grown to 2 KB).
Refresh the footer after add/remove/move and repaint the size/modified
cells on update. Verified live: add, overwrite, and delete now all
reflect immediately.
* fix: Dashboard Files shift-click selection after navigation and byte formatting
Shift-click selection:
- renderDirectory cleared the .selected class but left
window.latest_selected_item pointing at a now-detached row from the
previous directory. The shift handler set shift_clicked=true, found the
anchor's index as -1, skipped the range select, and onclick then
early-returned on shift_clicked — so the first shift-click in a new
directory selected nothing. Reset the anchor when it detaches, only take
the range path when the anchor is still in the list, and treat a
shift-click with no valid anchor as a plain select that becomes the
anchor (onclick otherwise ignores clicks while Shift is held).
formatFileSize:
- Returned "NaN undefined" for missing/invalid sizes and "1.5 undefined"
for terabyte-plus files (sizes array stopped at GB). Guard non-finite
and non-positive input, clamp the unit index, and add TB/PB.
Verified live: first shift-click selects the item, a second shift-click
extends the range, and formatFileSize returns 0 B / 1.5 TB / 2 PB.
* fix: show a message instead of a blank pane when a Dashboard folder fails to open
renderDirectory empties the file list before awaiting readdir, so a
readdir rejection (permission lost, folder deleted from another session,
network error) left a completely blank pane with only a console error.
Render a centered "This folder couldn't be opened." message on the catch
path. Verified live: a simulated readdir failure shows the message,
clears the spinner, and leaves navigation working (next folder loads).
* fix: Dashboard Usage/Home tab data freshness, errors, and dead controls
TabUsage:
- The Upgrade/Manage button was shown unconditionally and its click did
`new window.UIUpgradeAccount()`, which only exists on hosted puter.com —
on self-hosted it threw a TypeError from a dead button. Hide the button
when UIUpgradeAccount is absent and guard the click.
- The tab had no onActivate and bound its refresh to a nonexistent
element, so usage numbers were frozen at page-load for the whole
session. Add onActivate to refetch (verified: reactivating refetches).
- Resource names rendered the backend's `_dot_` escaping literally
("gemini-2_dot_5-flash"); un-escape for display and html_encode the cell.
- The two loaders had no error handling; a failed fetch left the tab
blank with an unhandled rejection. Catch each and show a fallback.
- Guard capacity 0 so storage no longer renders "NaN%".
TabHome:
- Plan button kept saying "Manage →" after a subscription lapsed; reset it
to "Upgrade →" in the free branch.
- Guard capacity 0 ("NaN%") on the storage card.
- "Your Plan ›" card header had the arrow affordance but no target and did
nothing on click; point it at the Usage tab like its siblings.
- Returning to the tab fired both focus and visibilitychange, and
refreshAndBroadcast both called refresh() and dispatched the event its
own listener handles — up to 4 duplicate reloads per focus. Drop the
direct call and coalesce the focus/visibility pair.
TabHome + TabApps:
- window.open(externalAppUrl, '_blank') lacked noopener, exposing the
dashboard tab to reverse tabnabbing from an external app site. Add
noopener,noreferrer.
Verified live: Usage and Home render without NaN or console errors, the
upgrade button is hidden on self-hosted, and 'Your Plan' now navigates.
* fix: multiple Dashboard CSS defects (dead rules, contrast, responsive)
- The desktop rule that hides the row ⋮ button ended two selectors with a
comma before an @media block, so the whole construct was invalid and
discarded — the ⋮ showed on every list-view row on desktop instead of
deferring to right-click. Verified: it's now hidden (display:none).
- Native-drop dark-mode styles keyed off .window[data-color-scheme="dark"],
an attribute never set anywhere; converted to @media (prefers-color-scheme:
dark) like every other dark rule so they actually apply.
- .myapps-tile-label set white-space:nowrap after a 2-line -webkit-line-clamp,
defeating the clamp so long app names clipped mid-glyph on one line;
removed it.
- .dashboard-sidebar-separator was defined a second time with a contradicting
box model (background line + full-width margin), rendering a doubled,
edge-to-edge divider; removed the duplicate so the inset border rule stands.
- Grid-view .item-icon had a hardcoded background:white and border-radius:2px
overriding its own border-radius:8px (glaring white tiles in dark mode,
square-ish corners); use var(--dashboard-background) and keep 8px.
- .files-footer used hardcoded #666/#CCC on theme-variable backgrounds
(dim in dark, near-invisible separator in light); use the text/muted vars.
- Fixed the .ui-droppable-over typo (jQuery UI emits ui-droppable-hover).
- The context-menu backdrop's "desktop transparent" rule used min-width:768px
while every mobile rule uses max-width:768px, overlapping at exactly 768px
(a common tablet width) — a non-dimming, invisible modal shield; bumped to
769px.
- At 481-768px the fixed hamburger toggle sat on top of the Files directories
column's first folder because .dashboard-content.files padding wins over the
media-query padding; add top clearance to the directories column there.
Verified light-mode desktop is unchanged (directories padding still 16px, ⋮
hidden, footer colors resolve).
* fix: only offer Uninstall for Dashboard apps where it actually sticks
Uninstall was suppressed via a hardcoded 8-name allowlist that had drifted
out of sync with the backend's ~26 recommended apps. For the ~18 unlisted
recommended apps (Calculator, Code, the games, …) the menu offered
Uninstall, revokeApp resolved, the tile vanished — then get-launch-apps
re-added it on the next load and it reappeared, so the uninstall silently
reverted.
Compute uninstallability from the actual lists: an app is uninstallable
only if it's in the user's installedApps AND not in the recommended list
(and not a protected core app). Recommended apps — installed or not —
resurrect on reload, so Uninstall is hidden for them. Verified live: a
synthetic installed-not-recommended app shows Uninstall; a recommended
app shows none.
* fix: guard Dashboard Apps loads against stale overwrites and drag/error clobbering
loadApps only checked for an in-progress drag before its await, and its
catch wiped whatever was on screen. Three concurrency issues followed:
- A slow, older load resolving after a newer one could overwrite the
newer app list (and clobber a reorder the user saved while the stale
fetch was in flight). Tag each load with an increasing id and skip
applying one only when a strictly newer load has already applied —
gating on "already applied" (not "latest started") so the first load to
resolve still populates the list for the pager's ResizeObserver.
- A drag that began while a load was awaiting could have the grid rebuilt
out from under it; re-check the drag after the await.
- A transient re-fetch error replaced a working grid with "Failed to load
apps"; only show that placeholder when nothing has loaded yet.
Verified live: a slow older load no longer clobbers a newer one, and the
grid still renders normally on activation.
* fix: page through all installed apps in the Dashboard instead of capping at 100
The /installedApps endpoint clamps limit to 100 and paginates, but the
Apps tab fetched a single page — a user with more than 100 installed apps
silently lost the alphabetically-last ones from both the grid and search,
with no way to launch or uninstall them from the dashboard. Loop pages
until a short one comes back (the common <100-app case still makes a
single request). Verified: the request now carries &page=1 and stops
after one page for a small account; the loop pulls all pages otherwise.
* fix: make the mobile Dashboard context menu handle submenus, disabled items, and positioning
The touch context-menu modal (used whenever maxTouchPoints > 0) had three
defects:
- Items with a submenu and no onClick ("New", "Open With") rendered as
plain buttons that did nothing and didn't even close the menu — the two
submenu-bearing actions were simply unreachable on touch. Drill into the
submenu on tap, with a Back row to return.
- disabled items were rendered as active buttons and executed their
onClick, so a folder's disabled "Paste Into Folder"/"Publish as website"
ran anyway. Render them inert (disabled attribute + dimmed class + a
click-handler guard), mirroring the desktop UIContextMenu.
- Non-touch devices that still route here (touchscreen laptops) got the
modal pinned at a hardcoded left:300px, far from the tapped item on a
wide screen. Center it over the target and clamp to the viewport.
Verified live (with maxTouchPoints forced): "Open With" opens its submenu
and Back returns; disabled "Paste Into Folder" is inert; the modal is
positioned near the item.
* fix: repair Apps-tab regressions from the Dashboard bug-fix pass
- A non-array /installedApps response (an error payload) was read as
end-of-pagination, silently rendering the grid without any installed
apps; fail the load so the explicit error state shows instead.
- A loadApps result that resolved mid-drag was discarded with no retry,
freezing the grid on stale data for the rest of the visit; stash it
and apply it after the drag ends, reconciled against the drag's final
order so it can't undo a reorder whose save is still in flight.
- Uninstall disappeared for recommended/recent apps, removing the only
UI path that revokes their permissions; offer it again and instead set
expectations in the confirm modal (the tile stays for apps that
get-launch-apps re-adds).
- init and the initial-route onActivate both fired a full load on open;
share the in-flight load instead of issuing a duplicate request trio.
* fix: repair Files/routing regressions from the Dashboard bug-fix pass
- The no-anchor shift-click fallback also fired when shift-clicking the
current anchor, collapsing an existing multi-selection to one item,
and it ignored Ctrl/Cmd; only run it when there is genuinely no
anchor, and keep the existing selection when Ctrl/Cmd is held.
- Unknown hash values (a stale bookmark, an in-page anchor) were
coerced to 'apps' and yanked the user off their current tab with a
refetch and autofocus; ignore them outright, both at boot and on
hashchange, while still keeping untrusted hashes out of selectors.
- The item.updated socket handler re-implemented the row-refresh from
TabFiles and had already drifted (trashed items showed their raw UID
instead of metadata.original_name); extract a shared row updater and
delegate to it.
* fix: repair Usage-tab regressions from the Dashboard bug-fix pass
- Hiding the plan button whenever window.UIUpgradeAccount was missing
at check time could hide it for the whole session on hosted
deployments that attach the script after dashboard init; keep
re-checking for a while before concluding the install is self-hosted.
- init and the initial-route onActivate both refreshed usage on a
direct #usage open; share the in-flight refresh instead of issuing
duplicate request pairs.
* fix: repair Dashboard CSS regressions from the bug-fix pass
- Repairing the malformed item-more rule put display:none into effect
for the first time, removing the visible-and-used desktop row '⋯'
button; drop the rule instead (it was never in effect on any release).
- The desktop context-menu backdrop breakpoint (min-width: 769px) left
fractional viewport widths between 768px and 769px with the mobile
dimming; use the exact complement of the mobile max-width: 768px
rules instead.
* fix: make Dashboard app uninstall honest about recents and resilient mid-flight
- Recently-opened apps were classified as sticky-removable, but the
recent list is built from app-open history that a revoke doesn't
touch, so their tiles reappeared on the next load — the very bug the
uninstall gating set out to fix. Treat recents like recommended.
- The confirm modal claimed every staying tile was 'provided by Puter',
which is false for third-party recents; describe the actual reason.
- Uninstall now marks in-flight loads stale so a fetch that started
before the revoke can't resurrect the removed tile.
- A pagination failure after the first page no longer discards the
pages already fetched — one flaky request among N used to turn the
whole grid into 'Failed to load apps'.
* fix: keep the Dashboard Usage tab stable through transient failures
- A failed refresh no longer wipes an already-rendered usage table;
the unavailable note only shows when there is nothing on screen yet
(mirrors the Apps grid's error handling).
- The plan-button check now re-checks indefinitely with backoff instead
of giving up after ~10s, so an arbitrarily late UIUpgradeAccount
attachment can't leave a subscriber without the button all session.
* fix: Dashboard Files footer sync and Home refresh resilience
- The shared row updater now refreshes the footer's item-count/total
line, which is computed from the data-size attributes it just wrote;
a remote overwrite no longer leaves the row and footer disagreeing.
- Size/Modified are only written when the update payload carries them,
so a minimal event can't zero out correct values on screen.
- A failed readdir now resets the footer instead of keeping the
previous directory's counts over an empty pane.
- Home's focus-refresh caps its wait on refresh_user_data, so a whoami
that never settles can't leave the plan/usage cards stale forever.
* fix: contain partial Apps loads and keep the uninstall flag current
- A partial installed-apps list (a page beyond the first failed) now
renders only when the grid is empty — it must never replace a
complete grid already on screen — and saveOrder refuses to persist
while the list is partial, since overwriting the saved order with a
truncated one would drop the missing apps' positions for good.
- Opening an app from the grid now flips its tile's uninstallable flag:
the open puts it in the server-side recent list, so a revoke from
that moment on leaves the tile in place, and the modal must not act
on the stale load-time snapshot.
- The in-flight-load invalidation moved into _invalidateInFlightLoads()
so the seq protocol lives in one place.
* fix: Usage-tab error guard, dead spinner, and poll decay
- The transient-error guard now tracks 'a table has rendered' instead
of data length, so an account with legitimately zero usage doesn't
get its empty table replaced by the unavailable note on a flaky
refresh.
- update_usage_details animated a spinner element that exists nowhere
in this tab and held every refresh open for an artificial 1s minimum;
both halves of the dead control are now gone.
- The plan-button recheck backs off to a 60s heartbeat instead of
polling at 2s forever on installs where UIUpgradeAccount never
appears.
* fix: guard minimal file updates; tighten selection and socket hot paths
- The shared row updater only writes the fields an update payload
actually carries, so a minimal event can't blank a row's name/path —
the same hazard the size/modified guards already covered.
- The all-rows lookup in the selection handler now happens only under
Shift instead of on every click, and the single-select block shared
by the no-anchor shift-click and drag-handle paths lives in one
selectSingle helper.
- item.removed and item.moved narrow by data-uid (O(1) selector) before
comparing paths instead of scanning every row per socket event.
* fix: replace partial-load and uninstall-flag patches with convergent designs
The previous round's containment patches each spawned new corner cases
(a persistently failing page froze all refreshes; the partial-load
saveOrder bail silently discarded reorders; the tile-open flag flip
lost races with in-flight loads, server recording, and popup blockers).
Replace both mechanisms:
- A partial installed-apps fetch (later page failed) now always applies,
supplemented with the apps already known from the previous list — the
grid and search can't shrink from one flaky request, refreshes never
freeze, and an empty grid still gets the fetched pages.
- saveOrder carries over saved names missing from the current list
instead of refusing to persist, so no app's saved position is ever
dropped; stale names are harmless (reconcileAppOrder ignores them)
and preserve the position of apps that return.
- The load-time uninstallable snapshot is no longer patched client-side
on tile open. Uninstall now revokes, optimistically removes the tile
when expected to stick, and refetches — the grid converges to
server-side truth instead of guessing at recents state.
* fix: don't blank filenames on metadata-only updates; unify range selection
- A payload carrying metadata but no name resolved to an empty display
name and blanked the row's visible filename — skip the write when the
resolved name is empty.
- The shift-range branch now goes through the same applySelection
helper as the no-anchor and drag-handle paths (it takes the row set),
removing the last hand-rolled copy of the selection bookkeeping.
* fix: rank-preserving saved-order merge; keep the pager put after uninstall
- The saved-order carryover appended missing apps' names at the tail,
permanently demoting their saved grid positions — the very thing it
claimed to protect. Replace it with mergeSavedOrder in appOrder.js
(beside its tested siblings, with unit tests): each missing name
keeps its rank among surviving names, so a drag during a partial-load
session neither drops hidden apps' positions nor teleports them
behind the dragged tile.
- The post-uninstall convergence refetch rendered without preservePage,
snapping the pager back to page 1 and replaying the load fade; thread
render options through loadApps so the background sync keeps the
user's page.
* fix: keep hidden apps' saved ranks through the mid-drag stash path
- _applyPendingLoad reconciled a stashed load against the visible-only
on-screen order, tail-appending any app returning to the grid and
contradicting the order the drag had just saved — the rank-demotion
defect resurfacing through one more path. It now prefers
_savedOrderNames, the canonical saved list that saveOrder keeps
merged with hidden apps at their ranks, over the possibly pre-drag kv
snapshot the stashed load fetched.
- The post-uninstall optimistic render now also skips the load fade;
it was the one in-place rebuild still hiding the grid behind the
opacity-0 icon gate.
- mergeSavedOrder's rank rescan collapsed to a single forward
insertion pointer (verified output-identical; the 20 unit tests pin
the behavior).
* fix: gate stale kv-order snapshots on the latest local save
A drag that started and ended while a load was in flight escaped the
stash path entirely: the load applied normally and replayed its
pre-drag kv snapshot, visibly reverting the just-saved reorder (and
permanently clobbering it after the next save). Conversely, the stash
path unconditionally preferred the local record, discarding a genuinely
fresher order fetched from another window when the drag committed
nothing.
Record the load-seq boundary at each saveOrder; a resolving load (both
apply paths, via _resolveOrderNames) replays its fetched kv order only
when it was issued after the latest local save — otherwise the
canonical in-memory saved list wins. Also rewrites the _applyPendingLoad
header, which described the pre-rank-merge implementation.
* Simplify app uninstall tile handling
Remove the uninstallability flag and modal warning logic, and treat uninstall as a local UI removal after permission revoke. This avoids an immediate reload that could re-add recommended/recent apps, while stale in-flight loads are still invalidated. Also simplifies app loading by dropping unused render options from `loadApps`/`_fetchAndRenderApps` and related merge comments.
* fix: match raw item paths in move_items cleanup and shortcut re-point
Item rows store data-path/data-shortcut_to_path unencoded, so the
html_encode()d attribute selectors missed names containing & < > " '
and the destination-row exclusion guard could remove a legitimate row
just created by a concurrent item.moved handler. Compare raw values
case-insensitively instead.
* fix: bound the Usage plan-button retry poll
The UIUpgradeAccount retry chain decayed to a 60s heartbeat but never
stopped, leaving self-hosted installs polling forever and pinning the
captured $el_window. Give up after ~30s of decaying retries; onActivate
re-checks on every return to the tab, so late script loads still get
the button.
* fix: drop the recent-opens list from the Apps tab grid
Recents are open history, not installs: they resurrected uninstalled
apps' tiles and showed merely-visited sites as if installed. The grid
is now recommended + installedApps; anything the user actually uses
still appears because opening an app grants it a permission. Recents
still power the Home tab.
On iOS/WebKit the pager's `touch-action: pan-x` made horizontal touchmove
non-cancelable, so the moment a long-press drag moved the finger the
browser started a native page-pan and fired pointercancel, aborting the
reorder before it visibly began. Preventing pointermove doesn't stop
native scrolling on iOS, and the reordering `touch-action: none` rule was
applied only after the drag began — too late, since touch-action is
latched at gesture start.
Drop the explicit pan-x (back to auto) so horizontal touchmove is
cancelable, and install a non-passive touchmove listener that
preventDefaults only once reordering is armed (post long-press). Before
arming it's a no-op, so a quick swipe still flips pages natively.
* feat(dashboard): drag-to-reorder apps in the Apps tab
Let users arrange their My Apps tiles in any order via drag-and-drop.
The order is stored per-user in puter.kv (`dashboard_apps_order`) as a
list of app names and re-applied on load; apps installed since the order
was saved are appended, and uninstalled ones are dropped.
Interaction:
- Mouse/pen: drag past a small threshold to reorder; a plain click still
opens the app.
- Touch: long-press to pick up a tile (a quick swipe still flips pages).
- Dragging to a scroller edge dwells, then flips to the adjacent page so
tiles can move across pages; the floating drag image lives on <body> so
the pager's overflow can't clip it, and displaced tiles reflow with a
FLIP animation.
- Escape (or losing the window) cancels and reverts; reordering is
disabled while a search filter is active; prefers-reduced-motion is
honored.
Also fixes a latent pager bug where programmatic page changes (arrows,
dots, wheel) didn't update the active page indicator, because those
scrolls don't reliably emit 'scroll' events; goToPage now tracks the
page eagerly.
The order-reconciliation logic is extracted into a pure module
(appOrder.js) with unit tests.
* fix(dashboard): ignore stray pointers and no-op drags
- Filter drag pointermove/up/cancel by pointerId so a second finger can't
hijack or prematurely end an in-progress reorder.
- Only persist a new order when the drop actually changed it, so an
accidental long-press or drop-in-place doesn't freeze the default
ordering into a custom one.
* fix(dashboard): address review of app drag-to-reorder
Correctness:
- Preserve the touch long-press → Uninstall path. A long-press now *arms*
reordering instead of immediately grabbing the tile; moving begins the
drag, while holding still lets the native context menu fire (which
cancels the pending pickup). This restores the only touch route to
Uninstall that the first cut had removed.
- Only swallow the post-drag click when the drop actually reordered
something, so a small pointer drift or a drop-in-place still opens the
app like a plain click.
- Guard the pre-start "intent" window: cancel a pending pickup before any
re-render (ResizeObserver / loadApps), and bail out of _beginDrag if the
pressed tile was detached, so a layout change mid-press can't reinsert a
stale node and persist a corrupted order.
Cleanup:
- Reuse reconcileAppOrder for the drop path instead of a duplicate helper.
- Read the saved order in parallel with the app-list fetches.
- Cache the reduced-motion MediaQueryList instead of re-querying per move.
- Fold the touch-scroll cancel into _endDrag(false); drop _abortDragIntent.
* feat(dashboard): smoother, more tolerant app reordering
Make drag-to-reorder feel like the iOS home screen instead of snapping
and flickering:
- Slow the reflow to 320ms with a gentle spring easing.
- Hit-test against each tile's *resting* box, not its live
getBoundingClientRect. Mid-animation a tile sits between slots, so
testing its live box was dropping it under the pointer and swapping it
straight back — the source of the back-and-forth jitter.
- Require the dragged icon's centre to be well inside a tile before it
becomes the drop target (a deadzone around each tile), so hovering a
boundary no longer thrashes.
- Probe with the dragged icon's centre rather than the fingertip, and
batch the FLIP into a single reflow so large grids stay smooth.
* redesign: compact, scannable Dashboard Session Manager
The session list rendered each entry as a bulky card with a 5-row
Created/Last active/Expires/IP/Client key-value table and a large filled-red
Revoke button, so only a couple of sessions fit on screen and destructive
actions dominated the layout.
Reworked it into an icon-led, compact list:
- One row per session: a device/kind icon tile (laptop / phone / globe by OS,
bolt for workers, key for API tokens, the real app icon for app sessions),
the title + badges, and two tight meta lines (client · IP, then a fainter
last-active · created · expires with absolute-time tooltips).
- Destructive actions are calm: per-row Revoke and the rename pencil are quiet
and reveal on hover (always shown on touch); "Revoke all other sessions" is a
subtle ghost button instead of a filled block competing with search.
- Current session gets a green accent, tint, and pill, and has no revoke button.
- Toolbar search gains an inline icon + focus ring; added a session count line;
restyled kind badges; nested child sessions get a proper tree line.
Styling uses the existing dashboard design tokens. All behavior is preserved:
search filtering, parent/child tree with expand/collapse, inline rename
(optimistic + rollback), per-session revoke (access-token vs session routing),
revoke-all, current-session guard, and focus/interval refresh.
Adds ui_session_count_one / ui_session_count_other to en.js (other locales
fall back to en).
* refactor: render Session Manager as a responsive modal, not a UIWindow
Replaces the draggable UIWindow shell with a self-contained DOM modal:
- Backdrop + centered card on desktop (max 680px / 90vh); full-screen sheet
on phones (<=640px). Fade/scale in, respects prefers-reduced-motion.
- Adds a header bar (title + close), backdrop-click and Escape to close, and
a scroll-contained body.
- Confirmations no longer use UIAlert (itself a UIWindow). Revoke, revoke-all,
and the rename-error path now use in-modal confirm/alert sheets, so there is
no cross-window z-index juggling — important because this can be opened from
a stay_on_top window (UIWindowCopyToken).
Drops the UIWindow and UIAlert imports entirely. All behavior is preserved:
search, parent/child tree + expand/collapse, inline rename (optimistic +
rollback), per-session revoke (access-token vs session routing), revoke-all,
current-session guard, and the focus/60s-interval refresh (cleaned up on close).
* fix: prevent crash rendering website files without a workers array in Dashboard Files
TabFiles.renderItem accessed `file.workers.length` unguarded when deciding
whether to show the website badge. Directory entries that have a published
website but no `workers` array (the common case) made this throw
`Cannot read properties of undefined (reading 'length')`, so the row failed
to render — the file silently disappeared from the list (swallowed by the
Promise.allSettled batch) or was never added on socket-driven updates.
The sibling `is_worker` derivation on the same entry already guards with
`file.workers?.length`, and the desktop reference (UIItem.js) normalizes
`workers` to an array and shows the badge via `!is_worker`. Reuse the safe
`is_worker` flag here instead of touching `file.workers.length` directly.
* fix: correct Dashboard usage that showed negative spend for users with credits
The Usage tab and Home usage card derived month-to-date spend as
`monthUsageAllowance - allowanceInfo.remaining`. But the metering service
computes `remaining = remainingAllowance + remainingPurchasedCredits`
(MeteringService.getAllowedUsage), so `remaining` exceeds the monthly
allowance whenever a user holds purchased credits — making the displayed
spend and percentage negative (e.g. "-$9.90 used of $0.25", "-3960%").
Use the actual reported spend (`res.usage.total`, already returned by the
same endpoint) and clamp the percentage/bar to 100%. For a free user with no
credits and no overage this yields the identical value as before.
* fix: make Dashboard Files Download button work for a single selection
In mobile select mode the floating action bar appears with one item selected
(minCountForActionBar is 1) and shows an enabled Download button, but the
click handler only acted when 2+ rows were selected — so downloading a single
file did nothing.
Gate on `> 0` instead. window.zipItems already normalizes a single item to an
array, so the one-file case works. On desktop the bar only appears at 2+
selections, so this changes nothing there.
* fix: auto-fit column width in Dashboard Files no longer scales with row count
Double-clicking a column resize handle is meant to fit the column to its
longest cell. The per-row loop did `maxWidth = Math.max(maxWidth + 10, textWidth)`,
which forces `maxWidth` to grow by at least 10px on every row regardless of
content — so a folder with N files produced a column ~10*N px wide and
persisted that runaway width to KV storage.
Compare against the running max only (`Math.max(maxWidth, textWidth)`);
`textWidth` already includes cell padding.
* fix: clear empty-directory placeholder when a file is added incrementally
When a directory is empty, renderDirectory appends an absolutely-positioned
"No files in this directory." placeholder. If a file then appears via an
incremental path (socket item.added/item.moved -> UIDashboardFileItem), the
new row was inserted but the placeholder was never removed, so the "No files"
text overlapped the real item. insertAtSortedPosition can't clear it because
the placeholder has no `.item.row` class.
Remove the placeholder in UIDashboardFileItem before inserting the row,
matching what the in-app New Folder / New File flows already do.
* fix: stop leaking a document keydown handler per uninstall-modal dismissal
showUninstallModal bound `keydown.uninstall-modal` on document but only
detached it on the Escape and Confirm paths. Dismissing via Cancel or a
backdrop click called `close()`, which just removed the overlay and left the
handler attached (closing over a now-detached overlay). Each open→cancel
cycle stacked another document-level listener for the page lifetime.
Move the `.off('keydown.uninstall-modal')` into `close()` so every dismissal
path cleans up, and drop the now-redundant explicit detaches.
* fix: resolve 2FA setup promise on every close so Dashboard toggle can't freeze
UIWindow2FASetup only resolved its promise from the Done button and, in
on_before_exit, when setup had NOT succeeded. Clicking "Enable" sets
setup_succeeded=true and advances to the recovery-codes screen without
resolving; if the user then closes that screen via the backdrop or Escape
instead of Done, the promise never settles.
The Dashboard Security tab is the only caller: it disables the 2FA toggle and
`await`s this promise, so the hang leaves the toggle permanently disabled and
stuck in the pending state for the rest of the session. Resolve with
setup_succeeded on any exit; resolve_promise is idempotent so a prior
Done-resolve is unaffected.
* fix: refresh Dashboard tabs when navigating via browser back/forward
The sidebar-click and initial-route handlers both call the target tab's
onActivate (its refresh hook), but handleRouteChange — wired to hashchange
and popstate — only swapped the active classes. Since tab switches use
history.pushState (which doesn't fire popstate/hashchange), the only way to
reach handleRouteChange is browser back/forward, and doing so left tabs like
Home/Apps showing stale data.
Look up the tab by id and call onActivate after switching, matching the
sidebar-click path.
* fix: refresh Home "Recently used" apps instead of freezing them for the session
loadRecentApps only hit /get-launch-apps when window.launch_apps.recent was
empty. Once populated (typically at page load), every subsequent init/onActivate
/focus just re-rendered the same cached array, so apps launched during the
session never showed up under "Recently used" until a hard reload.
Drop the empty-list guard so the list refetches on the same triggers that
already refresh the usage cards. The fetch can only make the list fresher.
* fix: sync window.user.otp when enabling 2FA from the Dashboard
The disable path clears window.user.otp (in UIWindowDisable2FA), but the
enable path updated only the card's DOM, never the in-memory flag. TabSecurity
html() reads user.otp to decide the toggle/badge state, so after enabling 2FA
a re-render of the Security tab from the cached window.user showed it as
disabled for an account that actually had 2FA on.
Set window.user.otp = enabling on success so both directions stay consistent.
renderItem() showed the shortcut badge whenever file.is_shortcut !== 0.
Directory listings return is_shortcut as 0, but the puter.fs.upload()
result used to incrementally render newly-created items omits the field,
so undefined !== 0 was true and every new file got a phantom shortcut
badge. Normalize is_shortcut to 0/1 before using it.
* Restore Files tab in dashboard
Reinstates the Files tab (TabFiles) that was removed in 973d046d, bringing
back the dashboard file browser: directory navigation, list/grid views,
sorting, drag-and-drop, context menus, upload, and trash support.
- Restore src/gui/src/UI/Dashboard/TabFiles.js (identical to pre-removal)
- Re-wire UIDashboard.js: tab registration, initial file path, item socket
handlers, and route-based file navigation
- Re-add file-tab CSS in dashboard.css and the selection-area rule in style.css
- Re-add dashboard-mode item creation hooks in helpers.js move_items
- Re-add #files/<path> deep-link parsing in initgui.js parseDashboardRoute,
while preserving Apps as the default dashboard tab
* Only remove stale rows when moving items
* Fix Dashboard Files tab bugs
- Escape filenames in renderItem to prevent stored XSS via HTML in file
names (matches UIItem); raw name is still preserved for display/rename.
- Read clone data-id from the nested .row in drag drop handlers so
multi-select drag moves all selected items, not just the grabbed one.
- Resolve sidebar drop target from the element's data-path so dragging
onto Public/Home works (they aren't keyed in user.directories); fix the
matching UID-vs-path comparison in the drag-out handler.
- Set data-is_worker/data-worker_url from is_worker itself instead of an
always-true "!== undefined" check, so worker context-menu behavior only
applies to actual workers.
- Give delete-confirmation buttons explicit values so permanent delete
works in non-English locales.
- Refresh the view after a context-menu move-paste, mirroring Ctrl+V.
- Guard the item.updated socket handler against the client's own echo,
matching the other handlers and UIDesktop.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
* Prevent Files tab lockup when readdir fails
renderDirectory sets renderingDirectory=true then awaits puter.fs.readdir
with no error handling. readdir rejects on any backend error (permission,
deleted directory, network), leaving renderingDirectory stuck true so the
re-entry guard blocks all future navigation and the spinner never clears.
Catch the rejection and reset state.
* Isolate render failures so one bad entry can't lock the Files tab
renderItem parsed file.metadata with an unguarded JSON.parse, but metadata
is client-writable and stored verbatim, so '', undefined, or malformed
values throw (item_icon.js guards the identical call). Combined with
Promise.all, one bad entry rejected the whole render and left the tab
frozen. Guard the parse and use Promise.allSettled for per-item isolation.
* Clear selection when clicking empty area in Files tab
The empty-area click handler is an arrow function, so `this` was the
TabFiles object rather than the container element, making `e.target === this`
always false. Only clicks landing exactly on `.files` deselected. Use
e.currentTarget so clicks on the container background deselect as intended.
* Avoid stacking image-preview document click handlers
showImagePreview bound a click.imagepreview handler without removing a
prior one. It is re-invoked during image arrow-navigation while a preview
is open, so identical document handlers accumulated until close. Remove
any existing handler before binding.
* Sort live-inserted items by display name
insertAtSortedPosition compared the new item's raw file.name against
existing rows' data-name (the display name). For trashed items, name is
the UID while the real name is in metadata.original_name, so newly trashed
items landed in the wrong position in Trash until a full re-render. Compare
on the new row's data-name to match sortFiles and the existing rows.
* Use display name in in-place item update
UIDashboardFileItem's in-place update path set data-name and the shown
name from raw file.name, unlike renderItem which uses
metadata.original_name || file.name. An update to an already-visible
trashed item replaced its name with the UID and corrupted data-name
(which sort and type-to-search read). Compute the display name to match.
---------
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
* test: tests for puter.js
* fix: ship lockfile for coverage devDeps; tolerate missing base coverage
npm ci failed on CI because package.json gained the babel/istanbul
devDependencies without the matching package-lock.json update. Also make
the coverage workflow's base leg best-effort so a base ref that predates
the coverage script reports without the comparison column instead of
failing the run.
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: Claude Fable 5 <[email protected]>
* typeify subdomains wip
* initial (untested) logic for LocalWorkerService
* Make it work, add lifecycle expiry since workers are process heavy in current implementation
* fix type errors
---------
Co-authored-by: Daniel Salazar <[email protected]>
Show a subtle centered loading spinner for the Apps dashboard when fetching apps if the request is slow. Implements a 1.5s delay before showing the spinner and guarantees a minimum 1.5s visible time to avoid blinking; the spinner is only shown when the container is empty (so background refreshes keep existing tiles). Adds a finish helper to clear the spinner timer and await the minimum visible time before rendering results or errors. Also adds CSS for the spinner element and its animations.
Move logic that determines a tile's displayed title and target link into resolveTileDisplay (use hostname/index_url for external apps) and have buildTileHtml consume it. Expose data-target-link on tiles. Update app search to match the displayed title, raw title, name, and uid so queries find website-shortcut tiles and external apps consistently (matching Home tab behavior).
* update user pays model docs
* Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
---------
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
Add a negative top margin and matching top padding to the horizontal scroller to prevent first-row icons from being clipped when they scale on hover. Overflow-x:auto previously caused vertical clipping of scaled icons; margin-top:-8px and padding-top:8px bleed the clip edge upward so scaled icons remain visible without changing scroll behavior.
Previously the dashboard used a clean root URL for the 'apps' tab and hashes for other tabs. This change makes tab links and history entries consistently use a hash (e.g. #apps, #home) by always generating href as `#<id>` and pushing `#<section>` to history. Simplifies URL handling and ensures the Apps tab also reflects its hash in the address bar.
* feat: add custom web link icon support and context menu option
Introduce the ability to change and display custom icons for .weblink files. Added a "Change Icon" context menu item that invokes the changeWeblinkIcon helper, and updated the icon rendering logic to use getWeblinkIcon instead of the default link icon. This allows users to personalize web link appearance in the file manager.
* Fix security and robustness issues in web link custom icons
# UIWindowSearch and require the icon data URL body to be pure base64 in
#isValidWeblinkIcon. A shared/downloaded .weblink is untrusted input and
# the previous prefix-only check let a crafted icon break out of the
# <img src> attribute.
# byte-signature MIME sniffing and capping stored icon size (was storing
# full-resolution images as base64).
# without picking a file.
# weblinks no longer fetches every file's contents on each render.
# path instead of the target weblink).
# label, add the AGPL license header, and drop redundant stored icon
# copies.
* Keep SVG weblink icons as vectors instead of rasterizing
Rasterizing a picked SVG to a 256px PNG threw away its scalability for no
real benefit: an SVG rendered in <img> runs in script-free static mode, and
the base64-body validation already prevents attribute injection. Store SVGs
as data:image/svg+xml;base64 (content-sniffed, size-capped), and keep
rasterizing raster formats and oversized SVGs to bound stored size.
Co-Authored-By: Claude Fable 5 <[email protected]>
---------
Co-authored-by: Daniel Salazar <[email protected]>
Co-authored-by: Claude Fable 5 <[email protected]>
The global ctrl/cmd+f shortcut opened UIWindowSearch and preventDefault'd
the keystroke, blocking the browser's native find. Skip that interception
while a dashboard window is open so ctrl/cmd+f falls through to the browser.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Treat the Apps tab as the default (root URL) instead of Home. UIDashboard now reads an initial route from window.dashboard_initial_route?.tab and falls back to 'apps' for unknown/absent routes. Sidebar and section active class logic and tab href/hash generation were updated so 'apps' uses the clean root URL while all other tabs (including home) use #tab. The history.pushState logic was adjusted accordingly. parseDashboardRoute in initgui.js was updated to default to 'apps' and its comment clarified the new behavior.
Update .myapps-tile-icon in src/gui/src/css/dashboard.css to change width and height from 60px to 56px, adjusting tile icon sizing for improved layout and spacing.
Call focusSearch when the Apps dashboard is activated and add a focusSearch helper. The helper imports isTouchPrimaryDevice and skips focusing on touch-primary devices to avoid showing the on-screen keyboard. It retries up to 12 times (30ms interval) until the search input is visible to handle cases where the dashboard is briefly hidden while entering full-page mode.
Update .myapps-tile-icon width and height from 52px to 60px to improve icon visibility and better match the updated UI design. Other styles (overflow, background, transitions) are left unchanged.
When an app is external and provides an index_url, use its hostname as the tile title and store the index_url in a new data-target-link attribute. Update the click handler to open the target link in a new tab for external apps, and fall back to opening the internal /app/{name} page for others. This aligns Apps tab behavior with the Home tab and ensures external apps navigate to their website.
* Mark external apps and use the flag for dashboard titles
An app with no owner_user_id (null/empty) isn't owned by a Puter user —
it's an external, origin-bootstrapped app whose uuid/name/title are all the
opaque app-… id. The dashboard used to detect these client-side by comparing
uuid/name/title and a name.startsWith('app-') check.
Expose an authoritative `external` flag from the API instead:
- /installedApps and /get-launch-apps now return `external`, derived from
owner_user_id, and no longer leak the raw owner id.
- The dashboard (Home + Apps tabs) shows the index_url hostname for external
apps based on `external` rather than the uuid/name/title heuristic.
Add/extend extension tests to cover the `external` flag and non-leak.
* Use hostname for opaque external app titles
Detect opaque external app IDs (when name === title === uid/uuid) and replace the displayed title with the hostname from index_url. Adds a uid/uuid fallback and tightens the external-app check in both the Apps and Home dashboard tabs so they behave consistently; preserves target_link for Home entries.
* Add external flag for apps without owner
Expose an `external` property in AppSummary (toAppSummary in RecommendedAppsService.ts) to mark apps that are not owned by a Puter user. The flag is set when `owner_user_id` is null or an empty string, allowing clients to identify origin-bootstrapped/external apps.
The dashboard derives a hostname title for anonymous (app-…) apps whose
uuid/name/title are all identical, falling back to the host of index_url.
That worked for launch/recent apps but not for apps that come only from
/installedApps, because the endpoint didn't return index_url.
Add apps.index_url to the SELECT (alongside apps.icon, relying on the
functional dependency on the apps.id primary key) and assert it in the
extension test.
Detect anonymous apps whose name/title/uid are identical opaque ids (starting with 'app-') and, when index_url is available, replace the displayed title with the index_url hostname (matching the Home tab behavior). Also persist index_url into the mapped app object and make title a mutable variable to support the change.
* feat: make the dashboard the default interface at root
The dashboard now loads at / (with /dashboard kept as an alias), and the
desktop moves to a new /desktop route. An "Open Desktop" button pinned at
the bottom of the dashboard sidebar switches to the desktop.
Root URLs that carry a desktop-only flow keep booting the desktop:
auth popups (?embedded_in_popup=), app deep links (?app=), direct
downloads (?download=), fullpage mode (?puter.fullpage=), and iframe
embeds.
Desktop-context redirects are retargeted so desktop users stay on the
desktop: logout, the QR "continue on phone" link, the auth cover-page
logo click, and window close/hide URL resets now use /desktop. OIDC
login/signup gains a strictly whitelisted return_to param (/desktop or
/dashboard) so social login returns to the interface it started from.
Also: the dashboard strips ?ref= like the desktop does, ?auth_token=
cleanup preserves the path and tab hash, the logged-out session list
gets the wallpaper back, ?download= also works at /desktop, and the GUI
dev server serves /desktop and /dashboard.
* Add Open Desktop bento card and CSS
Replace the previous desktop-switch UI with a full-width "Open Desktop" bento card that links to /desktop. Removes the pinned sidebar Open Desktop button and the old desktop-switch button/handler, and adds new CSS variables and styles for the desktop card (icon background, shadow, surface/header colors), hover shadow and arrow transition, and layout/padding adjustments.
Changed files: TabHome.js (removed old switch, added anchor card), UIDashboard.js (removed sidebar button), dashboard.css (new desktop color vars and styles; removed legacy desktop-switch/button styles).
* Remove box-shadow from myapps search input
Delete the box-shadow declarations from input.myapps-search::placeholder in dashboard.css. This simplifies the placeholder styling (removes subtle and light shadow layers), likely to reduce visual noise or fix rendering inconsistency.
* Show index_url hostname for opaque app IDs
Only replace an app's title with the hostname from index_url when the app appears to use an opaque id. Add a check for names starting with 'app-' so the hostname is used only if title, name and uuid are identical and the name looks like an opaque app id. This avoids overwriting meaningful user-facing names that may coincidentally match the uuid and clarifies the intent in the comment.
* Update dashboard.css
Include 'contacts' in the RECOMMENDED_APP_NAMES array so the Contacts app appears in the recommended apps list. Updated src/backend/services/apps/RecommendedAppsService.ts.
* fix(docs): highlight all code blocks after client-side navigation
The docs site highlights code on full page load via an inline
hljs.highlightAll(), which targets the 'pre code' selector and so
highlights every fenced block, including those without a language tag.
The client-side navigation path (pathchange handler) instead selected
only code[class^="language"], so fenced blocks written without a
language hint were left unhighlighted after in-app navigation while
appearing correctly on a hard reload.
Align the pathchange handler with highlightAll() by selecting 'pre code'
and skipping blocks hljs has already processed (data-highlighted=yes),
keeping the pass idempotent across repeated pathchange events.
Fixes#3287
* fix(docs): hoist hljs.configure and scope highlight to docs content
Address review nits on the pathchange highlight pass:
- Move hljs.configure({ ignoreUnescapedHTML: true }) out of the per-element
loop since it is global config and only needs to run once.
- Scope the selector to '.docs-content pre code' so the re-highlight pass
only touches the swapped-in docs body and leaves code blocks elsewhere
on the page untouched.
Adjust RECOMMENDED_APP_NAMES ordering: place 'editor', 'camera', and 'recorder' immediately after 'builder' and move 'app-center' accordingly. Also add 'vault' to the recommended apps list. This changes the resolved ordering of recommended apps at call time.
* 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 <[email protected]>
Include 'builder' in the RECOMMENDED_APP_NAMES array in src/backend/services/apps/RecommendedAppsService.ts so the RecommendedAppsService treats the builder app as a recommended application and surfaces it accordingly.
* Alpha: compaction support for OpenAI and Anthropic
* update lock
* fix billing for anthropic compactions
* Fix max_tokens bug in together provider
* Fix responses compaction
* feat: meter Gemini thinking tokens and grounding requests
- Thinking tokens: Extracted from standard completion tokens to ensure they are billed accurately at the correct model-specific rate.
- Grounding requests: Added flat-fee metering for Google Search by tracking grounding_metadata across both streaming and non-streaming responses.
- Pricing updates: Corrected stale rates for Gemini 2.5 Flash output, cached tokens, thinking tokens, and grounding requests.
* fix: correct Gemini 2.x metering rates and harden grounding capture
Pricing corrections (verified against ai.google.dev/gemini-api/docs/pricing):
- gemini-2.5-flash output is $2.50/M, not $1.00/M: restore
completion_tokens to 250 and bill thinking_tokens at the same output
rate (250). The previous 100 under-billed output ~60%, and this is the
provider's default model.
- gemini-2.5-flash cache read is $0.03/M: restore cached_tokens to 3
(the 7.5 value over-billed).
- Grounding with Google Search is $35 / 1,000 requests for Gemini 2.x
models and $14 / 1,000 for 3.x. Set grounding_requests to 3_500_000
for gemini-2.0-flash, gemini-2.5-flash, gemini-2.5-flash-lite and
gemini-2.5-pro; 3.x models keep 1_400_000.
Streaming robustness:
- In create_chat_stream_handler, don't let a later extra_content chunk
without grounding_metadata overwrite an earlier one that carried it,
so grounding requests are still metered.
Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
---------
Co-authored-by: Daniel Salazar <[email protected]>
Co-authored-by: Claude Opus 4.8 (1M context) <[email protected]>
Include 'butler' in the RECOMMENDED_APP_NAMES array in RecommendedAppsService so it appears in the recommended apps list. Updates src/backend/services/apps/RecommendedAppsService.ts.
Revamps the phone verification dialog and adds reusable phone helper modules.
- Heavily refactors UIWindowPhoneVerificationRequired.js to add a searchable inline country picker, national-number live formatting, country autodetect for "+" input, WebOTP auto-fill support, resend cooldown timer, accessibility improvements, proper teardown/cleanup, and last-used country persistence.
- Introduces helpers/src/helpers/country_codes.js (get_country_list, flag emoji) which builds a localized list of dialable countries from libphonenumber-js and Intl.DisplayNames.
- Introduces helpers/src/helpers/phone.js with thin wrappers around libphonenumber-js: format_phone_as_you_type, inspect_phone, phone_example_for, detect_country_from_input.
- Adds English i18n strings for the new UI in src/gui/src/i18n/translations/en.js.
These changes keep submission canonicalization using libphonenumber-js (E.164), improve UX for selecting/formatting numbers, and make the country list and phone utilities reusable across the app.
The healthcheck was using 'puter.localhost:4100/test' which fails for
two reasons:
- 'puter.localhost' does not resolve inside the container; the service
listens on all interfaces at port 4100, so 'localhost' works
- '/test' endpoint does not exist; use '/' which serves the shell
This caused the container to be perpetually marked as unhealthy.
Fixes#3191
* ci: add docs build workflow
Add CI workflow that runs npm ci and npm run build in src/docs
whenever files under src/docs change in a pull request.
Closes#3225
* ci: also trigger docs build when workflow file changes
* ci: address docs build workflow review
* fix: coerce Mistral image_url parts from object to string
Mistral's API expects image_url content parts to be a plain string URL,
not the OpenAI-style { url: string } object that process_input_messages
produces. Without this coercion, sending an image to any Mistral model
that supports vision (mistral-small, mistral-medium, mistral-large,
ministral-*) results in a request error from the Mistral SDK.
Add #coerceImageUrls() as a private method on MistralAIProvider that
maps { type: 'image_url', image_url: { url } } -> { type: 'image_url',
image_url: url } for every content part in every message. Messages with
plain string content are left untouched, as are parts whose image_url is
already a string.
Add four unit tests covering: object-to-string coercion, already-flat
strings, plain string message content, and mixed text+image content.
* style: use plain ASCII dashes in comment section divider
* Add docs fixes for deployment and workers pages
* Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
* handle router edge case
* explicit info about wildcard requiring name
---------
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
* Minor fix deployment docs
* Potential fix for pull request finding
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
---------
Co-authored-by: Copilot Autofix powered by AI <[email protected]>
* fix(docs/search): show loading state and replay query when index isn't ready
Previously performSearch rendered "No results found" while the
MiniSearch index was still loading, which is misleading on slow
networks. Track the last query as pendingQuery and replay it from
fetchSearchIndex once the index is ready; render a distinct
"Failed to load search index" message on fetch error so the
loading message doesn't persist indefinitely.
Fixes#3180.
* fix(docs/search): close stale-replay race and consolidate status messages
Drop pendingQuery synchronously in the input handler when the query
falls below the minimum length, so a fetchSearchIndex that resolves
within the 300ms debounce window after the user clears the field can
no longer replay the cleared query into an empty input.
Move the idle, loading, and failed copy into a single
renderSearchStatus(kind) helper so the four .search-no-results
renderings can only drift in one place.
* feat(database): add postgres database client
Adds the PostgreSQL database client, native bootstrap migration, SQL preparation helpers, and database config/factory wiring.\n\nRefs #3165.
* feat(database): make backend queries postgres-aware
Updates runtime SQL call sites for database-specific booleans, identifiers, insert-ignore, upserts, JSON extraction, intervals, and Postgres insert ids.\n\nRefs #3165.
* test(database): cover postgres client behavior
Adds unit coverage for SQL preparation, factory selection, write-result mapping, and transaction rollback/commit ordering, plus an env-gated PostgreSQL integration flow.\n\nRefs #3165.
* docs(self-hosting): document postgres database setup
Adds PostgreSQL configuration examples and migration path guidance for self-hosted deployments.\n\nRefs #3165.
* fix: harden postgres oidc tests
* fix(postgres): normalize query results and SQL prep
* fix(user): preserve normalized cache booleans
* test(postgres): run integration coverage with pgmock
* tests: add way to run all tests with postgres though slow
Also adding note that postgres is not in active use so might not work out the box
---------
Co-authored-by: Daniel Salazar <[email protected]>
* feat: worker sessions get their own kind + per-(user, app, worker_name) row
Schema
------
- mysql_mig_11.sql + sqlite 0054: add idx_sessions_user_worker_active,
a partial unique index over (user_id, app_uid, meta.worker_name) for
kind='worker' rows. Active worker sessions are deduped by that triple
so each named worker gets its own session row and they don't fight
the existing idx_sessions_user_app_active (which still constrains
kind='app' only). app_uid is allowed NULL for user-scoped workers
with no app binding.
SessionStore
------------
- getOrCreateWorker(userId, { appUid, workerName, ... }): mirrors the
getOrCreateApp pattern — cache lookup, partial-unique re-SELECT on
insert-ignore, all keyed on the worker triple. expires_at lands at
WORKER_WINDOW_SECONDS (~99y) so the worker doesn't have to re-mint
on any cadence.
- #cacheKeyWorker + #allCacheKeysForRow worker branch so revoke /
update invalidates the worker cache view alongside the by-uuid one.
AuthService
-----------
- createWorkerSessionToken(user, workerName, meta?) now takes the
workerName explicitly and routes through getOrCreateWorker. Emits
the same { session, token, gui_token } shape but both JWTs carry
{ worker: true, worker_name }.
- createWorkerAppToken(actor, appUid, workerName) likewise — JWT
carries the worker_name claim so a verifier can tell two workers
under the same app apart without a DB round-trip.
- Both methods 400 on empty workerName.
WorkerDriver
------------
- Five auth-mint call sites swapped over: app-bound deploy (3x:
appId branch, actor.app fallback, hot-reload redeploy), user-bound
fallback (2x: cold deploy, hot-reload). All pass `workerName` so
the worker's session row is naturally idempotent across redeploys.
GUI manage-sessions
-------------------
- sessionTitle adds a kind='worker' branch ("name (app)" for
app-scoped workers, just "name" for user-scoped), pulling worker_name
from the meta-spread that listSessions already surfaces.
- en.js adds ui_session_kind_worker.
* fix(workers): MySQL JSON_EXTRACT quoting + revoke cache invalidation +
SQLite NULL-distinct in worker index
Three real bugs in the worker session plumbing from the prior commit,
plus a misleading comment. Schema design kept (worker_name lives in
`meta` rather than a dedicated column) per offline review:
1. `#selectWorkerRow` compared `JSON_EXTRACT(meta, '$.worker_name')`
directly to a bind parameter. MySQL's `JSON_EXTRACT` returns a
JSON-typed value with embedded quotes (`"name"`, not `name`), so
the comparison never matched. After the first INSERT, every
follow-up getOrCreateWorker call missed the existing row in the
SELECT, hit INSERT-IGNORE, then missed again in the re-SELECT —
the caller would receive whatever the INSERT-IGNORE returned (a
no-op row in conflict cases). Wrap with `JSON_UNQUOTE` on MySQL
via `db.case`; SQLite's `json_extract` already returns the
unwrapped scalar so it keeps the literal form.
2. mig_11's generated `worker_unique_key` had the same JSON-quoting
bug. Mirror the fix: `IFNULL(JSON_UNQUOTE(JSON_EXTRACT(...)), '')`
so the concatenated unique key is a plain string that lines up
with what `#selectWorkerRow` now binds against.
3. SQLite UNIQUE indexes treat NULL columns as distinct (per the SQL
standard), so two user-scoped workers (app_uid NULL) with the same
worker_name would both insert. Wrap the index expression with
`IFNULL(app_uid, '')` so they correctly conflict — matches the
MySQL side's `IFNULL` in the generated column.
4. `removeByUuid` / `revokeCascade` SELECTed only the identity
columns (no `meta`), so `#allCacheKeysForRow`'s worker branch
couldn't read `meta.worker_name` and the composite
`sessions:v2:worker:<user>:<app>:<name>` cache key survived
revocation. Up to CACHE_TTL_SECONDS (15min) afterwards,
getOrCreateWorker would short-circuit to the cached (revoked) row.
Add `meta` to both SELECTs; the existing meta-parsing logic in
`#allCacheKeysForRow` handles the rest.
* feat (put-1019 put-1021): v2 auth revoke endpoints + silent v1->v2 token migration
PUT-1019 (AUTH-5): full revoke-endpoint coverage
- /logout: soft-revoke web session + its asset cookies via revokeCascade
(app sessions and access tokens survive)
- POST /auth/revoke-session: cascade per row kind (web/app/access_token/asset)
- POST /auth/revoke-all-sessions: revoke all web rows for user; optional
include_apps=true nuclear option; gated by userProtected (cookie-only)
- revokeAccessToken: soft-revoke matching access_token row in addition to
removing access_token_permissions
- All revokes are UPDATE revoked_at = now(); no DELETE statements remain
PUT-1021 (SDK-1): backend POST /auth/migrate-token
- v1 access_token/app -> mint matching-kind v2 token, idempotent on
(auth_id, kind, token_uid)
- v1 web/session -> 409 { code: "reauth_required" } (interactive relogin only)
- Same-origin / signed-referer hardening; rate-limited per IP and auth_id
- Gated by auth.allow_v1_tokens; emits puter_token_v2 cookie for app-in-browser
DB migrations: mysql_mig_10, sqlite 0053 (sessions.access_token_uid column)
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
* fix(put-1019): reject self-revoke; enrich list-sessions response
- handleRevokeSession refuses uuid === req.actor.session.uid (use /logout
instead). The cookie that authenticated the call should never be the
target of a self-revoke — the response can't write fresh auth state
and the client ends up with an ambiguous identity. revoke-all-sessions
still has the explicit include_current opt-in for the nuclear case.
- AuthService.listSessions now joins the apps table for kind='app' rows
(returning { uid, name, title, icon } so the manage-sessions UI can
render the authorizing app without a second round trip), surfaces
kind / expires_at / label / last_ip / created_via, and filters out
asset rows (per-cookie children of web rows, revoked transitively via
cascade — surfacing them as standalone entries would be confusing).
- Sort order: current session first, then most-recently-active. UI
relies on this to anchor "you are here" at the top.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
* fix(put-1019 put-1021): review nits — origin normalization, cookie fallback, types, migration order
B1: createAccessToken.options.expiresIn widened to string | number.
The impl (#hardExpiryFromExpiresIn) and existing callers/tests use
jsonwebtoken-style strings ('1h', '30d'); narrowing to number forced
unsafe casts at every call site. Cast at the single sign() boundary
where jsonwebtoken's typed template-literal SignOptions clashes
with the wider runtime contract.
B2: Inline comment on the DELETE in access_token_permissions. The
AUTH-5 "no DELETE on revoke" rule scoped to the `sessions` table
(where the cascade graph + audit trail matter). Permissions rows
are the grant manifest for an active token — once its session is
soft-revoked they're dead-weight cache entries. A future audit
requirement would land as a `revoked_at` column on this table,
not a behavior change in this PR.
B3: handleMigrateToken now sets the puter_token_v2 cookie (with the
shared sessionCookieFlags + httpOnly) when the migration result
is kind='app'. The endpoint is already gated on Origin so the
caller is by definition in a browser; access tokens deliberately
skip the cookie since they're programmatic.
B4: #isMigrateTokenOriginAllowed normalizes both incoming origin and
config.origin / allowlist entries (trim + strip trailing slash +
lowercase) before equality. A misconfigured `config.origin =
"https://puter.com/"` would otherwise reject every same-origin
browser call.
B5: Replaced 4x `this.config.cookie_name!` non-null assertions in
AuthController with `(this.config.cookie_name ?? 'puter_token')`.
IConfig is `Partial<IConfigOptional>` so cookie_name is undefined
at runtime in some deployments / test setups; the fallback matches
the pattern in userProtected / OIDCController / puterSite.
B6: MySQLDatabaseClient sorts migrations numerically by trailing
integer instead of lexically. Existing files use unpadded names
(`mysql_mig_<N>.sql`), so plain `.sort()` ran mysql_mig_10 before
mysql_mig_2 — a future migration that depended on _2..9 running
first would break. Non-numeric filenames fall through to
localeCompare for determinism.
B7: Restored the docstring for SessionStore.getOrCreateApp's
`opts.auth_id` ("Stable per-user identity (survives re-login);
carried on every v2 JWT so manage-sessions can group by identity")
— the previous edit truncated it to a fragment.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
* test + feat: backend test coverage for PUT-1019/1021 review fixes + worker session methods
Tests
-----
- compareMigrationFilenames (new) covering B6 numeric-sort: confirms
mysql_mig_10.sql lands after mysql_mig_9.sql; non-numeric files sort
after numbered ones; stable for already-ordered input.
- listSessions (AuthService.test.ts): excludes kind="asset" rows;
enriches with kind/expires_at/last_ip/created_via; joins kind="app"
rows with the apps table; sorts current first then by last_activity
desc.
- handleRevokeSession (AuthController.test.ts): refuses self-revoke
with 400; still allows revoking a sibling session.
- handleMigrateToken (AuthController.test.ts): rejects missing/
disallowed Origin; tolerates trailing slash and uppercase Origin
(B4 normalization); returns 409 reauth_required for v1 web tokens;
does NOT set the cookie for access-token migration; DOES set the
puter_token_v2 cookie (httpOnly + sessionCookieFlags) for
app-under-user migration.
- SessionStore tests updated to import APP_WINDOW_SECONDS /
WEB_WINDOW_SECONDS rather than hardcoded 30/90 day values — the
windows just got bumped to 1y and the assertions need to follow
the constant.
Refactor
--------
- MySQLDatabaseClient exports compareMigrationFilenames so the sort
logic is unit-testable in isolation.
Worker tokens
-------------
- AuthService.createWorkerSessionToken(user, meta?): mints a new
kind="web" row tagged meta.worker=true, expires_at =
WORKER_WINDOW_SECONDS, returns { session, token, gui_token }
with worker: true on each JWT.
- AuthService.createWorkerAppToken(actor, appUid): mints a new
kind="app" row tagged meta.worker=true, expires_at =
WORKER_WINDOW_SECONDS, returns an app-under-user JWT with
worker: true. Note the existing idx_sessions_user_app_active
unique index will collide with an existing non-worker app
session for the same (user, app) — future schema work can
carve workers out of that uniqueness.
SessionStore.js: WEB/APP_WINDOW_SECONDS now 1y;
WORKER_WINDOW_SECONDS = 99y added for the worker path.
Full backend suite: 2172 passed / 16 skipped / 0 failed.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
* feat (put-1022 put-1023 put-1024): puter.js reauth handling + GUI v2 modal + silent v1 migration
PUT-1022 (PJS-1): puter.js handles 401 reauth_required
- Single apiCall wrapper intercepts 401 { code: "reauth_required" }
- Clears local token, emits puter.auth.reauth_required { reason, auth_id }
- Queues in-flight requests, re-issues after re-auth completes
- web/app: opens puter.com login popup forwarding auth_id
- gui: no-op (handled by GUI-1 modal)
- workers: surfaces as structured exception
- All token writes go through setAuthToken() (future-proofed for PJS-2)
PUT-1023 (GUI-1): web GUI reauth modal + v2 token storage
- Detects 401 reauth_required across http + websocket connect paths
- Soft modal preserves URL/window state; auth_id forwarded into login form
- Storage key migrated: auth_token -> auth_token_v2
- Cookie cleared on logout: puter_token (legacy) cleared, backend writes puter_token_v2
- Cross-tab propagation via localStorage storage events
- WebSocket revocation surfaces as same modal (no silent failure)
PUT-1024 (PJS-2): puter.js storage versioning + silent v1->v2 migration
- New storage key puter.auth.token.v2
- On SDK init: prefer v2 key; else legacy v1 -> silent POST /auth/migrate-token (SDK-1)
- On success: store v2, clear v1. On failure: fall back to PJS-1 reauth flow
- URL-param tokens (?puter.auth.token=, ?auth_token=) also run through silent migration
- Logout clears both keys; setAuthToken writes v2 only
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
* fix(put-1023): manage-sessions polish — hide self-revoke, fix modal stack, richer rows
Three issues surfaced during local testing of the manage-sessions UI:
1. The session row representing the caller's own cookie had a Revoke
button that, if used, left the client in an ambiguous identity state
(backend now rejects it too — see put-1019 backend fix). The button
is now omitted entirely when session.current=true; /logout remains
the right path for ending the active session.
2. The confirm-revoke prompt rendered behind the manage-sessions
window because both share the dominant z-index pool. UIAlert calls
now pass parent_uuid (the manage-sessions window's data-element_uuid)
plus stay_on_top, so the prompt stacks above its parent.
3. Each row only showed the bare uuid. Now renders: title (app
title for app sessions, label / "Browser session" / "Access token"
otherwise), app icon when applicable, kind / current badges,
created / last-active / expires (timeago, with absolute on hover),
and last_ip. App metadata is joined server-side per the backend
listSessions change.
Adds en.js strings for the new labels (ui_session_*).
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
* fix(put-1022): tighten reauth replay + iframe postMessage hygiene
driverCall_ replay (G1): on 401 reauth_required, re-enter driverCall_
instead of calling replayXhrAfterReauth. The generic helper wires the
retried XHR through setupXhrEventHandlers which resolves with the
parsed response and silently skips driverCall_'s streaming detection,
usage-limit / email-confirmation handling, settings.transform, and
resp.result unwrapping — i.e. it would change the driver call API
contract on retry. One-shot via settings._reauthReplayed so a
fresh-token rejection bubbles up instead of looping.
responseType drift (G2): driverCall_ sets xhr.responseType from
settings AFTER initXhr (which captured the stale value into
xhr._puterReq). Mirror the mutation onto _puterReq so any replay path
builds the retry with the live config rather than the snapshot.
targetOrigin lockdown (G3): triggerReauth's parent.postMessage now
targets this.defaultGUIOrigin instead of '*'. The payload carries
reauth metadata + auth_id and is only meaningful to the GUI; '*'
would leak the signal to whatever frame happened to be embedding us.
event.source pinning (G4): the reauth wait listener also matches
event.source against globalThis.parent, not just event.origin —
origin alone admits any same-origin frame on the GUI domain.
event name alignment (G5): the SDK now emits and listens for
'puter.auth.reauth_required' (matches the documented name in the
PR / commit description). The old 'auth.reauth_required' key isn't
yet consumed externally so this is a safe rename.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
---------
Co-authored-by: Claude Opus 4.7 (1M context) <[email protected]>
Record the 2.4.2 release in the repo. The version was published to npm
(via prepublishOnly's `npm version patch`) but the bump was never
committed, leaving package.json stuck at 2.4.1 on GitHub.
Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
- decrease ai related alerts and make errors not 500
- decreased bad fs controller checks to be 404s instead of 500s
- force bucket regions for now
- validate webdav perms for locking/unlocking
Delete the large TabFiles.js file (file browser tab) and update dashboard, styles, helpers, and init logic accordingly. Related changes in UIDashboard.js, dashboard.css, style.css, helpers.js and initgui.js remove or adapt references to the removed component and adjust UI/initialization and styling to account for the file-tab extraction. This reduces duplicate/legacy code and centralizes file browser functionality; review UIDashboard and helper updates to ensure file management features (navigation, upload, context menus, previews) are migrated or re-implemented as needed.
- When hovering main menu items, it must clear hover effects
- Two submenus under each other and diagonal moving
- separator hovering removes highlight but disabled item should do the same
- better way to remove focus
- when mouse leaves context menu highlight should be removed
* Restrict cross-origin cookie auth
Block the auth probe from using ambient session cookies when a browser Origin does not match the request Host. Explicit bearer, body, and x-api-key tokens continue to work for cross-origin SDK calls.
Co-authored-by: Codex <[email protected]>
* Normalize origin checks for cookie auth
Compare normalized origins for browser cookie authentication so default ports and protocol mismatches are handled consistently. Add coverage for default-port and protocol-mismatch cases.
Co-authored-by: Codex <[email protected]>
---------
Co-authored-by: Codex <[email protected]>
xAI's Voice pricing table lists Text to Speech at $15.00 / 1M characters,
but we were charging $4.20 / 1M characters (420 microcents/char), undercharging
by ~72%. Update the cost constant, listEngines pricing, and test expectation
to 1500 microcents/char.
Guidance for AI coding agents working in this repository. Read this before making changes. FOLLOW GUIDANCE AS CLOSELY AS POSSIBLE. If you think the guidance is wrong, raise an issue or flag a maintainer — don't just do what you think is right. This is the source of truth for how we write code, tests, and docs in this repo.
## Documentation Index
Use these as the source of truth before exploring further:
- [README.md](README.md) — project overview and quickstart.
- [SECURITY.md](SECURITY.md) — how to report vulnerabilities (do not file them publicly).
- [BUG-BOUNTY.md](BUG-BOUNTY.md) — bounty program scope.
- [TRADEMARK.md](TRADEMARK.md) — trademark usage.
---
## Backend
The backend is organized as a layered stack inspired by Controller–Service–Repository with dependency injection. Every layer only depends on the layers beneath it, and `PuterServer` ([src/backend/server.ts](src/backend/server.ts)) wires the whole thing together.
| **Controllers** | [src/backend/controllers/](src/backend/controllers/) | Route handlers. Parse + validate input, apply per-route gates (auth, subdomain, rate limit, body parsers via `RouteOptions`), call services, format responses. |
| **Drivers** | [src/backend/drivers/](src/backend/drivers/) | Optional. RPC-style handlers exposed over `/drivers/*`. Thin shells that validate RPC inputs and call into services/stores. |
| **Services** | [src/backend/services/](src/backend/services/) | Business logic. Assume the caller is already authenticated/authorized — services do not run auth gates themselves. |
| **Stores** | [src/backend/stores/](src/backend/stores/) | Persistence. Wraps clients with the domain shape services consume (rows, entities, KV namespaces). |
| **Config** | `config.*.json` → `IConfig` | Flat, typed config object every layer receives at construction. |
Each layer receives the layers beneath it through its constructor. Dependencies are explicit and traceable from `PuterServer`.
### Cross-layer rules
- **Don't reach across layers.** Controllers do not poke clients directly; services do not register routes. If you want to, the abstraction is wrong — fix the abstraction.
- **Don't call sideways within a layer for code reuse.** If two services need the same logic, lift it into a util/helper. Services should not depend on other services for shared code.
- **Prefer explicit arguments over `Context` (ALS).** Reach for [Context](src/backend/core/context.ts) only when the value is genuinely request-scoped and would otherwise thread through many layers. Today it's used sparingly — mostly for `actor` and `req`.
### Extensions
Extensions live in [extensions/](extensions/) and parallel the layered stack. They are for **non-crucial parts of the system** — things Puter still works without if removed. If a feature is load-bearing for clients, it belongs in core, not in an extension (see [whoami](extensions/whoami.ts) as the cautionary example).
The `extension` global ([src/backend/extensions.ts](src/backend/extensions.ts)) exposes:
-`extension.registerClient/Store/Service/Driver/Controller(name, Class)` for first-class additions.
-`extension.on(event, handler)` and `extension.get/post/put/delete/patch/use(path, opts?, handler)` for the lightweight common case. `opts` is the same `RouteOptions` controllers use.
-`extension.import('client' | 'store' | 'service' | 'controller' | 'driver')` returns a lazy proxy to instantiated objects. `extension.config` exposes live config.
### Language & file conventions
- **Modules:** We transpile and build as needed — write ES modules, not CommonJS.
- **TypeScript preferred for new files.** Existing JS is fine; convert opportunistically when you're already touching a file.
- **Reuse types before inventing them.** Search for an existing type first; extend it if close. Only define a new type when nothing fits.
- **Make new types findable.** Co-locate them with the layer/module that owns them, export from the obvious entry point, and use a descriptive `PascalCase` name. Don't hide types in random files where future readers won't grep them.
- **Naming:** `camelCase` for variables/functions, `PascalCase` for classes and for files containing a class (`AuthService.ts`, `KVStoreDriver.ts`).
### Comments
Keep comments light. Prefer self-documenting code — clear names, small functions, obvious flow. Add a comment **only** when:
- The _why_ is non-obvious (a hidden constraint, a workaround, a subtle invariant).
- A non-trivial usage detail would otherwise trip the next reader.
Don't restate what the code already says. Don't write comments that reference the current task or PR — those rot.
### Tests
When adding new behavior (function, endpoint, driver method, branch of logic), add a test for it.
- Use Vitest for unit and integration tests. Test files go next to the code they test, named `*.test.ts` or `*.test.js`.
- **Mock data, not methods.** Stub the inputs (fixtures, fake rows, sample payloads) rather than mocking the function under test or the layer beneath it. Over-mocking produces tests that pass while production breaks. If you must mock, mock at a real boundary (a client/external service), not within the same layer you're testing.
- Prefer test server over mocking deps. We have existing test utilities for running full test server to boot deps based on config. (see [src/backend/testUtil.ts](src/backend/testUtil.ts)).
- **Hit a real database / real client where reasonable.** Integration shapes catch the things unit tests with mocks miss.
- **Regression tests for bug fixes.** A test that fails before your fix and passes after is the cheapest insurance against the bug coming back.
- If something is genuinely hard to test (UI animation, third-party glue), skip it but say so in the PR.
### Security & privacy
Before opening a PR, scan the diff for:
- Logs, error messages, or responses leaking internal paths, secrets, tokens, env vars, or other users' data.
- Debug routes, test credentials, commented-out auth checks.
- Endpoints returning more than the caller actually needs.
When in doubt, return less. Auth-, permission-, or data-export-related changes deserve an explicit callout in the PR description.
### Working rules of thumb
- **Run it, don't just compile it.** "It type-checks" is not "it works." Exercise the code path end-to-end at least once.
- **Read the neighbors before writing.** Match the shape of similar things already in the tree. If you genuinely think the existing pattern is wrong, raise it — don't quietly diverge.
- **Boy Scout Rule, proportional to the change.** Fix the obvious typo, dead import, or missing typehint in files you're already touching. Don't ride a refactor along with a bug fix — that just makes review harder.
- **Understand what you commit.** AI assistance is fine; shipping code you couldn't defend in review is not.
Guidance for AI coding agents working in this repository. Read this before making changes. FOLLOW GUIDANCE AS CLOSELY AS POSSIBLE. If you think the guidance is wrong, raise an issue or flag a maintainer — don't just do what you think is right. This is the source of truth for how we write code, tests, and docs in this repo.
## Documentation Index
Use these as the source of truth before exploring further:
- [README.md](README.md) — project overview and quickstart.
- [doc/contributing-apis.md](doc/contributing-apis.md) — adding and maintaining public APIs end to end (backend surface → puter.js → types → docs → tests). Follow it for any API work.
- [doc/pagination.md](doc/pagination.md) — the one pagination convention for list APIs (limit/cursor/offset/includeTotal, envelope shape, cursor semantics).
- [doc/alarms.md](doc/alarms.md) — raising alarms and picking a severity (what pages, what only gets recorded), plus the config that routes them.
- [SECURITY.md](SECURITY.md) — how to report vulnerabilities (do not file them publicly).
- [BUG-BOUNTY.md](BUG-BOUNTY.md) — bounty program scope.
- [TRADEMARK.md](TRADEMARK.md) — trademark usage.
---
## Repo-wide conventions
These apply everywhere — backend, puter.js, and GUI.
### Language & files
- Write ES modules, not CommonJS — we transpile and build as needed.
- TypeScript preferred for new files in the backend and extensions; convert existing JS there opportunistically when you're already touching a file. The GUI and puter.js are plain JavaScript — don't introduce TypeScript files in them. In puter.js, type with JSDoc instead (see the puter.js section for what must be typed).
- **Reuse before adding.** Search for an existing type, helper, or implementation and extend it; only add a new one when nothing suitable exists.
- Make new types findable: descriptive `PascalCase` names, exported from the obvious entry point. A type used from many places belongs in the owning module's `types.ts` (e.g. [src/backend/controllers/types.ts](src/backend/controllers/types.ts)) rather than bloating a logic file; a type with a single consumer can stay next to it.
- Naming: `camelCase` for variables/functions, `PascalCase` for classes and files containing a class (`AuthService.ts`). **Prefer `camelCase` for all new code** — new local variables, parameters, functions, and internal object properties. `snake_case` is reserved for identifiers that are part of an external contract: wire/JSON keys sent to or received from the backend, stable API error codes, public option names that already ship as `snake_case`, and established `puter_*` namespaced fields. Don't rename those (it's a breaking change), but don't introduce new `snake_case` alongside them either — a camelCase local can carry a value into a `snake_case` wire key (`{ operation_id: operationId }`).
### Comments
Keep comments light; prefer self-documenting code. Comment only when the _why_ is non-obvious or a usage detail would trip the next reader. Use `//` for single lines and `/** ... */` JSDoc when it genuinely needs more — if a comment runs long, it's probably too long. Don't restate the code and don't reference the current task, PR, or version — those rot. **No ticket references** (`PUT-1234`, `// fix for FOO-99`) in code, comments, or test names; describe the why in domain terms, not project-management terms. Use plain ASCII `-` in comment section dividers (`// -- Section --`), never box-drawing characters.
### Security & privacy
Before opening a PR, scan the diff for:
- Logs, error messages, or responses leaking internal paths, secrets, tokens, env vars, or other users' data.
- Debug routes, test credentials, commented-out auth checks.
- Endpoints returning more than the caller actually needs.
When in doubt, return less. Auth-, permission-, or data-export-related changes deserve an explicit callout in the PR description.
### Working rules of thumb
- **Run it, don't just compile it.** "It type-checks" is not "it works." Exercise the code path end-to-end at least once.
- **Read the neighbors before writing.** Match the shape of similar things already in the tree. If you think the existing pattern is wrong, raise it — don't quietly diverge.
- **Test new behavior.** Every new function, endpoint, driver method, or logic branch gets a test; every bug fix gets a regression test that fails before the fix. If something is genuinely hard to test, skip it but say so in the PR.
- **Boy Scout Rule, proportional to the change.** Fix the obvious typo or dead import in files you're already touching; don't ride a refactor along with a bug fix.
- **Understand what you commit.** AI assistance is fine; shipping code you couldn't defend in review is not.
- **A limit change is not done until the docs change with it.** Every rate limit, concurrency cap, quota, and allowance in the code is published in [src/docs/src/rate-limits-and-quotas.md](src/docs/src/rate-limits-and-quotas.md) — an undisclosed limit is one developers discover as a service failure. If a PR moves any of these numbers, the same PR updates that page. The numbers live in `src/backend/controllers/fs/limits.ts` (filesystem), `src/backend/drivers/util/aiLimits.ts` (all AI drivers), per-driver `rateLimit`/`concurrent` configs, and `src/backend/data/subPolicies/` (free-tier allowances).
---
## Backend
A layered stack with explicit dependency injection: each layer depends only on the layers beneath it, receives them through its constructor, and `PuterServer` ([src/backend/server.ts](src/backend/server.ts)) wires the whole thing together. [doc/architecture.md](doc/architecture.md) is the full reference.
| **Controllers** | [src/backend/controllers/](src/backend/controllers/) | Route handlers: parse/validate input, per-route gates via `RouteOptions` (auth, subdomain, rate limit, body parsers), call services, format responses. |
| **Drivers** | [src/backend/drivers/](src/backend/drivers/) | Optional RPC-style handlers on `/drivers/*`; thin shells that validate inputs and call services/stores. |
| **Services** | [src/backend/services/](src/backend/services/) | Business logic. Assume the caller is already authenticated/authorized. |
| **Stores** | [src/backend/stores/](src/backend/stores/) | Persistence; wraps clients in the domain shapes services consume. |
| **Clients** | [src/backend/clients/](src/backend/clients/) | Adapters for external/internal services (sql, redis, s3, email, event bus). Protocols, not domain concepts. |
| **Config** | `config.*.json` → `IConfig` | Flat, typed config object every layer receives at construction. |
A public API can be exposed through either a controller or a driver — both are supported; prefer a controller when you need fine-grained control over routes and gates. [doc/contributing-apis.md](doc/contributing-apis.md) has the decision guide with links to the decorators and middleware for each.
Cross-layer rules:
- **Don't reach across layers.** Controllers don't poke clients; services don't register routes. If you want to, the abstraction is wrong — fix the abstraction.
- **Don't call sideways within a layer for code reuse.** Two services needing the same logic means a util/helper, not a service-to-service dependency.
- **Prefer explicit arguments over `Context` (ALS).** Reach for [Context](src/backend/core/context.ts) only for genuinely request-scoped values that would otherwise thread through many layers — today mostly `actor` and `req`.
### Extensions
[extensions/](extensions/) parallels the layered stack and is for **non-crucial parts of the system** — Puter still works with the extension removed. If core needs to call it, it belongs in core, not an extension (see [whoami](extensions/whoami.ts) as the cautionary example). The `extension` global ([src/backend/extensions.ts](src/backend/extensions.ts)) exposes:
-`extension.registerClient/Store/Service/Driver/Controller(name, Class)` for first-class additions.
-`extension.on(event, handler)` and `extension.get/post/put/delete/patch/use(path, opts?, handler)` for the lightweight common case. `opts` is the same `RouteOptions` controllers use.
-`extension.import('client' | 'store' | 'service' | 'controller' | 'driver')` — lazy proxy to instantiated core objects. `extension.config` exposes live config.
Follow the same layered structure inside an extension — unless it only needs a few route handlers, in which case the lightweight helpers are enough on their own.
### Backend tests
- Vitest; test files sit next to the code they test (`*.test.ts` / `*.test.js`). Run with `npm run test:backend`.
- **Mock data, not methods.** Stub inputs (fixtures, fake rows, payloads), not the function under test or the layer beneath it — over-mocking produces tests that pass while production breaks. If you must mock, mock at a real boundary (a client/external service).
- **Prefer the test server over mocking deps.** `setupPuterTestEnv()` in [src/backend/testUtil.ts](src/backend/testUtil.ts) boots a fully in-memory backend; hit a real database/client shape where reasonable — integration shapes catch what mocked unit tests miss.
---
## puter.js (the SDK)
[src/puter-js/](src/puter-js/) is the public SDK. It ships live from `https://js.puter.com/v2/` with no version pinning — every existing app picks up changes immediately. Treat every observable behavior (signatures, response fields, error codes) as something a production app depends on.
Layout: SDK modules in [src/puter-js/src/modules/](src/puter-js/src/modules/) (one file or directory per area — `FileSystem/`, `kv/`, `ai/`, …), shared plumbing in [src/puter-js/src/lib/](src/puter-js/src/lib/), generated (gitignored) type declarations in `src/puter-js/types/`, API tests in [src/puter-js/tests/api/](src/puter-js/tests/api/), UI e2e tests in [src/puter-js/tests/e2e/](src/puter-js/tests/e2e/), developer docs in [src/docs/](src/docs/).
Language & typing: puter.js source is plain JavaScript — never TypeScript files — typed via JSDoc. **The JSDoc is the source of truth for the SDK's types.**`src/puter-js/types/` is `tsc --emitDeclarationOnly` output: the SDK build (`npm run build` in `src/puter-js`) generates it, the npm tarball ships it so TypeScript consumers get declarations, and git ignores it — it is never committed and never edited by hand. `npm run check:puterjs:types` generates it in CI and type-checks the result without `skipLibCheck`. The one hand-written declaration file is [src/puter-js/index.d.ts](src/puter-js/index.d.ts), which decides what is public and re-exports the generated names.
Declare a shape where it belongs, and reference it with an `import(...)` type from elsewhere. A shape more than one file needs lives in the module's `types.js`; a shape shared across modules lives in [src/puter-js/src/lib/types.js](src/puter-js/src/lib/types.js); a shape with one consumer can stay next to it:
Use `@typedef {Object}` + `@property` for any shape whose fields need documenting — it is the only JSDoc form that carries a doc comment per field into the generated declaration. Keep the inline object-literal form for small internal shapes with nothing to say about each field, and prefer `unknown` over `*`:
Public (exposed) methods must carry JSDoc types — parameters and return value — with one `@overload` block per accepted call form, since those overloads *are* the published signature. Unexposed/private helpers are typed at the contributor's discretion: annotate where it helps the next reader, and either way keep them clean. Members tagged `@internal` are stripped from the generated declarations, so use that tag rather than `@private` to keep something off the public surface.
Typing in JS files is encouraged: annotate with JSDoc `@type`/`@param`/`@returns` using the TypeScript type system, and define shared shapes with `@typedef`. API types must not be `unknown` or untyped `...args` — spell out the real parameter and return shapes; the only exception is values passed through transparently to an upstream layer that owns their type. For example:
```js
/**
* @typedef {{key:string, value: unknown}} KVEntry
*/
/** @type {KVEntry[]} */
letentries=[];
```
Every SDK change carries all five of the following — a puter.js PR missing one is incomplete:
1.**Backward compatibility.** Mandatory unless a maintainer explicitly signs off on a break. Existing call signatures keep working (including both positional and options-object forms where a method supports them); new parameters are optional with defaults that preserve old behavior; never rename or repurpose existing params, response fields, or error codes. New parameter names are `camelCase` (existing `snake_case` stays for compatibility). Say in the PR how existing callers are unaffected.
2.**Tests.** Add or extend a suite in [tests/api/suites/](src/puter-js/tests/api/suites/) (`<area>.suite.ts`; register new suites in `suites/index.ts` — no globbing). One suite runs unchanged on node, browser, and workerd via `npm run test:puterjs`; never write a per-platform test. The runners execute the **built** bundle — run `npm run build:workerLib` after SDK changes or the suite silently tests stale code. For `puter.ui.*` methods rendered by the desktop, use the Playwright e2e harness instead — see [src/puter-js/TESTING.md](src/puter-js/TESTING.md).
3.**Docs.** New or changed APIs update [src/docs/src/](src/docs/src/): the method page (`<Area>/<method>.md`, with frontmatter and a runnable example) and the area overview when the surface changes. Docs are the contract users code against — signatures, defaults, and return shapes must match the implementation exactly.
4.**Types.** Type the change in JSDoc on the implementation, then run `npm run check:puterjs:types` to confirm it still produces declarations that type-check. Name any new type in [src/puter-js/index.d.ts](src/puter-js/index.d.ts) if consumers should be able to import it. Declarations must match runtime behavior exactly — a wrong type is worse than a missing one.
5.**Error handling.** Reject/throw `{ message, code }` objects with stable `snake_case` codes, matching the existing modules (see `KV.js`). Validate cheap preconditions client-side before making the network call; pass backend errors through unchanged rather than swallowing or re-wrapping them. Error codes are API surface — changing one is a breaking change.
[doc/contributing-apis.md](doc/contributing-apis.md) walks the full lifecycle of adding an API across backend + SDK.
---
## GUI
[src/gui/](src/gui/) is the Puter desktop: deliberately plain JavaScript + jQuery with HTML-string templates. Don't introduce a UI framework or a new rendering pattern.
The guiding rule here is **conformity over novelty** — match the existing design and code structure even where you'd personally choose differently. A visually or structurally divergent addition is a defect even when it works.
- **Reuse existing UI primitives before writing new ones.** Windows and dialogs are `UIWindow*` functions in [src/gui/src/UI/](src/gui/src/UI/); generic pieces already exist (`UIAlert`, `UIPrompt`, `UIContextMenu`, `UINotification`, `UIPopover`, widgets in [UI/Components/](src/gui/src/UI/Components/)). A new window should read like its neighbors: an async function taking an options object, composing an HTML string, wiring behavior with jQuery, delegating to `UIWindow(...)`.
- **Match the visual language.** Use existing CSS classes (`button`, `button-primary`, window chrome, form styles) and copy the layout patterns of neighboring windows; new styles go in [src/gui/src/css/](src/gui/src/css/) following existing conventions. Verify anything positional (menus, overlays, z-index) on both desktop and mobile viewports.
- **i18n every user-facing string.** No hardcoded UI text — use `i18n('key')` and add the key to [src/gui/src/i18n/translations/en.js](src/gui/src/i18n/translations/en.js). Run `npm run check-translations` before opening the PR.
- **Shared logic goes in helpers/services.** Reusable non-UI logic belongs in [src/gui/src/helpers/](src/gui/src/helpers/) or [src/gui/src/services/](src/gui/src/services/), not copy-pasted between windows.
- **Tests.** Vitest is wired for the GUI (`src/**/*.test.js`; see [appOrder.test.js](src/gui/src/UI/Dashboard/appOrder.test.js) for the shape). Extract pure logic into functions and test those. Desktop behavior driven through puter.js (`puter.ui.*`) is covered by the Playwright harness in [src/puter-js/tests/e2e/](src/puter-js/tests/e2e/) — add a spec there when you change how the desktop renders SDK-driven UI.
@@ -6,38 +6,75 @@ We at **Puter** are committed to maintaining a secure experience for our users a
The following are in scope for this program:
* **The Puter open-source project** (available at [github.com/HeyPuter](https://github.com/HeyPuter/puter))
* **`puter.com`**
* **`api.puter.com`**
- **The Puter open-source project** (available at [github.com/HeyPuter/puter](https://github.com/HeyPuter/puter))
- **`puter.com`**
- **`api.puter.com`**
Out-of-scope:
* Third-party services, applications, or libraries not maintained by Puter.
* Social engineering attacks (e.g., phishing against staff).
* Denial of Service (DoS), spam, or volumetric attacks.
* Physical security issues.
- Third-party services, applications, or libraries not maintained by Puter.
- Social engineering attacks (e.g., phishing against staff).
- Denial of Service (DoS), spam, or volumetric attacks.
- Physical security issues.
## Known Non-Issues (Please Check Before Submitting)
The following have already been reviewed and determined **not to be vulnerabilities**. Reports that only re-describe one of these are **not eligible for a reward** and will be closed as non-issues — even if they include new code references. Please review this list before submitting:
- **XSS / CORS / token issues scoped to `api.puter.com`.** The primary user session cookie lives on `puter.com`, not on the API origin, so reflected/stored XSS, CORS, or token handling on `api.puter.com` is generally out of scope. Two exceptions we _do_ evaluate on their merits — please report these: (a) attacker-controlled content served **inline** (e.g. an HTML `Content-Type`) from API file/response endpoints, and (b) anything that abuses the app-scoped `puter_token_v2` companion cookie set on the API host.
- **SSRF via `secureFetch`.** Production routes outbound requests through an isolated proxy that has no access to internal/SSRF-sensitive resources.
- **Attacks that depend on guessing an `appInstanceID` or app UID.** These are random 128-bit secret values and are not considered guessable.
- **Apps invoking drivers, creating workers, or using KV.** Applications are intended to do this; worker permissions are scoped to the owning app. This is by design.
- **App metadata or app user-count "leaks".** This information is currently public by design.
- **General "token in a URL" / token-lifetime designs** — signed directory URLs exposing children, a write signature implying read, or app tokens outliving a web session. These are current intended behaviors.
- **Missing PKCE, unverified `id_token` signatures, or other OIDC hardening.** Provider tokens are obtained through a server-to-server authorization-code exchange with the provider's token endpoint over TLS, so the resulting `id_token` / userinfo claims are trusted from that channel rather than from local JWKS signature verification — the callback never accepts a caller-supplied `id_token`. Adding local signature / `aud` / `iss` / `exp` checks is welcome defense-in-depth (please open a GitHub issue/PR), but their absence is not an account-takeover vector on its own.
- **JWT "algorithm confusion" / unpinned `algorithms` in `jwt.verify`.** Puter's session tokens are HMAC-signed (HS256) with a server-side secret, and there is no asymmetric public key anywhere in the verification path, so `alg`-substitution attacks do not apply. Explicitly pinning `algorithms` is a fine hardening PR, but it is not a vulnerability.
- **Static-source "SQL injection" in internal pagination.** Findings such as `LIMIT ${limit}` in list/notification queries: the limit is numerically coerced and clamped before it reaches the query, so it is not reachable with attacker-controlled string input. Hardening PRs to the internal stores are welcome, but these are not exploitable as reported.
- **Deprecated `saveTo*` GUI app messages.** The legacy `saveToDesktop` / `saveToDocuments` / etc. app-IPC handlers can create — never overwrite — new files in standard user folders. The behavior is non-destructive, path-traversal-safe, and deprecated (slated for removal); it is not treated as a sandbox escape.
- **Best-practice suggestions** such as login/registration username enumeration (kept intentionally for UX) or unauthenticated unsubscribe links (industry norm).
- **Rate-limiting suggestions for TURN credential issuance** (intentional; not billed per tunnel).
If you believe you have a **genuinely new** exploit chain that defeats one of these rationales (for example, demonstrating a sensitive credential that really is reachable on `api.puter.com`), say so explicitly and show why the reasoning above does not apply.
## Rules of Engagement
To participate, you must:
1.**Report responsibly**: Provide detailed steps to reproduce the issue, including proof-of-concept code or screenshots where applicable.
1.**Report responsibly**: Provide detailed steps to reproduce the issue, including proof-of-concept code, screenshots, or a screen recording (see _Proof of Reproduction_ below).
2.**Do no harm**: Do not exfiltrate, modify, or delete data. Only access your own account or test data.
3.**Respect availability**: Do not perform denial-of-service attacks or automated scans that degrade service.
4.**Follow disclosure policy**: Do not publicly disclose vulnerabilities until we have confirmed and patched the issue.
5.**Act in good faith**: Make every effort to avoid privacy violations, destruction of data, and interruption or degradation of services.
6.**Check the Known Non-Issues list**: Reports matching an item in the "Known Non-Issues" section above are not eligible and will be closed.
Reports that do not meet these guidelines may not be eligible for a reward.
## Proof of Reproduction
Reports **MUST** demonstrate a **working, reproducible exploit with real impact** — not a theoretical or static-source-review finding. Please include:
- Exact steps to reproduce, the relevant request/response or code path, and the commit or version you tested.
- The **observed** result versus the **expected** result.
- For client-side, UI, or authentication-flow bugs: a short screen recording (≤ 2 minutes) showing the exploit working end-to-end on a real Puter instance.
- For server-side bugs: a runnable proof-of-concept and screenshot or video showing the attack process.
Reports based solely on reading the source ("source review only, not tested") or unverified AI/LLM-generated reports are the **lowest triage priority and not eligible for bounty**.
If you used an AI tool to help find an issue, you must personally verify that it actually reproduces before submitting.
Please submit **one issue per report**.
Bundled "audit packs" of many speculative findings will not be elligible for bounty; send each confirmed issue separately.
Additionally please **DO NOT** submit too many issues at once; these will be treated as a bundled report, and will not be elligible for bounty.
@@ -16,6 +16,8 @@ Add tests for new behavior, endpoints, or bug fixes. If something's genuinely ha
Match the shape of similar code already in the repo. [doc/architecture.md](doc/architecture.md) is the source of truth for layers, wiring, and naming. If you think a pattern is wrong, raise it — don't quietly diverge.
In plain-JS files, typing is encouraged via JSDoc `@type` annotations using the TypeScript type system, with `@typedef` for shared shapes. Don't type API surfaces as `unknown` or untyped `...args` unless the values are passed through transparently to an upstream layer that owns their type.
## 3. Don't expose system or user information
Scan your diff for stray logs, debug routes, internal paths, secrets, tokens, or user data in errors/responses. When in doubt, return less. Flag any auth, permission, or data-export changes in the PR description.
@@ -26,9 +28,11 @@ For private security reports, see [SECURITY.md](SECURITY.md).
Don't commit code you couldn't have written, debugged, or defended yourself. Read the diff, run it, and be ready to explain it in review.
## 5. Update docs for API changes
## 5. Adding or changing APIs
If you change puter-js APIs (drivers or endpoints used by puter-js), update [developer docs](src/docs/). puter-js itself may also need updating.
If you add or change a public API (an endpoint, driver method, or puter-js method), follow [doc/contributing-apis.md](doc/contributing-apis.md) — backward compatibility, [developer docs](src/docs/), types, and tests all move in the same PR.
The same rule applies to limits: rate limits, concurrency caps, quotas, and allowances are published in [src/docs/src/rate-limits-and-quotas.md](src/docs/src/rate-limits-and-quotas.md), and a PR that changes one of those numbers updates that page in the same PR. A limit nobody published is a limit developers only discover as a service failure.
How to add a new public API to Puter, and how to maintain one that already exists. "Public API" means anything applications can call: HTTP endpoints, `/drivers/*` methods, and the puter.js methods that wrap them. This guide is written for human contributors and AI agents alike — [AGENTS.md](../AGENTS.md) defers to it for API work.
puter.js is served live from `https://js.puter.com/v2/` with no version pinning — every app in existence picks up your change the moment it deploys, and the backend endpoints underneath have the same property. Assume every observable behavior (parameter handling, response fields, error codes, ordering) has someone depending on it.
Every change is backward compatible unless a maintainer has explicitly agreed to a break beforehand.
## Core or extension?
The first decision is where the API lives.
If the API is **not crucial to core functionality — nothing in core will call it — prefer an extension** over wiring it into core. Extensions live in [extensions/](../extensions/), parallel the core layered stack, and reach core through `extension.import(...)`:
extension.post('/my-feature/frobnicate',opts,handler);// or plain routes
```
Follow the same layered structure inside the extension (driver/controller → service → store) unless it genuinely only needs a couple of route handlers — then the lightweight `extension.get/post/...` helpers are enough on their own.
The test is the direction of dependency: Puter must still work with the extension removed. The moment core needs to call your API, it belongs in core — see [whoami](../extensions/whoami.ts) for the cautionary example of a load-bearing "extension".
## Adding a new API
Work through all seven steps; the PR is complete when every one is.
### 1. Design the surface first
- Sketch the signature, options, return shape, and error cases before writing code. Find the two or three most similar existing APIs and match their conventions.
- New parameter and field names are `camelCase`. (Existing `snake_case` names stay where they already exist.)
- Anything returning a list follows the [pagination convention](pagination.md): `limit`/`cursor` in, `{ items, cursor, total }` envelope out.
- Prefer an options object over a growing list of positional parameters, but keep the common case callable with a single argument where siblings do.
### 2. Backend
Follow the layered stack ([architecture.md](architecture.md)): a controller or driver at the edge, business logic in a service, persistence in a store. The edge parses and validates input and applies gates; services assume the caller is already authorized. Return exactly what the caller needs and no more — every response field you ship is permanent — and use stable `snake_case` error codes.
#### Controller or driver?
Both are supported ways to define an API. **Prefer a controller when you need fine-grained control** — URL shape, HTTP verbs, per-route gates, response and streaming formats. A **driver** fits when the API is a set of RPC methods implementing one of the named interfaces on `/drivers/*` (`puter-kvstore`, `puter-chat-completion`, …) and the generic driver plumbing — one call envelope, interchangeable implementations, per-method policies — covers what you need.
- **Controller:** extend `PuterController` ([src/backend/controllers/types.ts](../src/backend/controllers/types.ts)) and declare routes with the `@Controller(prefix)` class decorator plus `@Get`/`@Post`/… method decorators ([src/backend/core/http/decorators.ts](../src/backend/core/http/decorators.ts)), each taking `(path, routeOptions)` — or override `registerRoutes(router)` imperatively. Core controllers register in [src/backend/controllers/index.ts](../src/backend/controllers/index.ts); extensions use `extension.registerController(...)` or the plain route helpers.
- **Driver:** extend `PuterDriver` ([src/backend/drivers/types.ts](../src/backend/drivers/types.ts)) and mark it with the `@Driver(interfaceName, opts)` decorator ([src/backend/drivers/decorators.ts](../src/backend/drivers/decorators.ts)), which also declares its policies. Core drivers register in [src/backend/drivers/index.ts](../src/backend/drivers/index.ts); extensions use `extension.registerDriver(...)`.
#### Middleware and gates
- **Controller routes** (and extension routes — same options) take [`RouteOptions`](../src/backend/core/http/types.ts): auth gates (`requireAuth`, `requireUserActor`, `noUserSession`, `adminOnly`, `allowedAppIds`, and the access-token controls), `subdomain` routing, per-route `rateLimit`, body parsers, and arbitrary extra `middleware`. The auth flavors are subtle and default-deny — read the JSDoc on each field before picking.
- **Driver methods** get their policies from the `@Driver` options: per-method `rateLimit` (limit/window/backend), `concurrent` in-flight caps (optionally `bySubscription`), `requireSubscription`, and `noUserSession`. The `/drivers/call` surface enforces them.
- **A surface only paying accounts should reach** declares `requireSubscription` — the route option on a controller endpoint, the per-method `@Driver({ requireSubscription })` block on a driver (`/drivers/call` is one shared route, so a driver's requirement can't live in route options). `true` accepts any plan that isn't free, so a plan registered by an extension counts without core naming it; an array of policy ids (`['business', 'pro']`) accepts only those; `false` is "no requirement", the same as leaving it out. An empty array is a boot error rather than a silent no-op — it reads as subscribers-only while admitting everyone. Off unless asked for, and answered from the metering service's cached per-actor subscription, so it costs a map lookup. Distinct from `requireCredits`: this asks which plan the account is on, not whether it has budget left. Deployments with no paid plans (self-hosted installs) turn the whole thing off with `meteringEnforcement.subscriptions: false`.
- **An account must have verified a specific factor** — `requireVerified` (email, and only under `strict_email_verification_required`), `requirePhoneVerified`, `requireCardVerified`. These are opt-in and require the factor to have actually been verified, unlike the default-on gate that turns away accounts still carrying a pending verification the abuse harness asked for.
- **An endpoint that spends metered resources** on the caller's behalf — moving file content, making object-store requests, anything else the account is billed for — also declares `requireCredits: true`, which turns an account with nothing left of its budget away with a 402 before the handler runs. Endpoints that only describe or delete things deliberately don't: an account that has run out still has to be able to see what it has, clear it, and reach its billing pages. Drivers have no route options to declare this on, so they call `assertActorHasCredits` themselves ([src/backend/services/metering/enforcement.ts](../src/backend/services/metering/enforcement.ts)) — see `KVStoreDriver`, which does it once for every method.
### 3. puter.js
- Add the method to the matching module in [src/puter-js/src/modules/](../src/puter-js/src/modules/), matching the calling conventions of its siblings (promise-returning; positional shortcut plus options form where that's the local pattern).
- Validate cheap preconditions client-side and throw `{ message, code }` objects; pass backend errors through unchanged rather than swallowing or re-wrapping them.
### 4. Types
- Type the method where you wrote it, in JSDoc: `@param`/`@returns` on the implementation, one `@overload` block per accepted call form, and `@typedef {Object}` + `@property` for any new shape. The JSDoc is the source of truth — declarations are generated from it, so there is nothing to keep in sync by hand.
- Put a shape more than one file needs in the module's `types.js` (e.g. [src/puter-js/src/modules/kv/types.js](../src/puter-js/src/modules/kv/types.js)); anything shared across modules goes in [src/puter-js/src/lib/types.js](../src/puter-js/src/lib/types.js). A shape with one consumer can stay next to it.
- Run `npm run check:puterjs:types` — it generates the declarations and type-checks the published surface without `skipLibCheck`. **Never edit anything under `src/puter-js/types/`**: it is gitignored build output, produced by the SDK build and shipped in the npm tarball, and the next build overwrites it.
- Name the new type in [src/puter-js/index.d.ts](../src/puter-js/index.d.ts) if consumers should be able to import it. That file is the one hand-written declaration in the package: it decides what is public and re-exports nothing else.
### 5. Docs
- Add the method page at [src/docs/src/](../src/docs/src/)`<Area>/<method>.md` — frontmatter (`title`, `description`, `platforms`), syntax, parameters, return value, and at least one runnable example — and update the area overview (`<Area>.md`). Copy the structure of an existing page.
### 6. Tests
- **Backend:** colocated Vitest tests; prefer the in-memory test server (`setupPuterTestEnv` in [src/backend/testUtil.ts](../src/backend/testUtil.ts)) over mocking.
- **SDK:** add cases to [src/puter-js/tests/api/suites/](../src/puter-js/tests/api/suites/)`<area>.suite.ts` (register new suites in `suites/index.ts`). One suite runs on node, browser, and workerd via `npm run test:puterjs` — never write per-platform tests, and rebuild first with `npm run build:workerLib` since the runners execute the built bundle.
- **Desktop-rendered UI** (`puter.ui.*`): add a Playwright spec per [src/puter-js/TESTING.md](../src/puter-js/TESTING.md).
### 7. Security pass
Scan the diff before opening the PR: no internals leaked in errors or logs, no over-broad responses, auth gates present. Flag anything auth-, permission-, or data-export-related in the PR description.
## Maintaining an existing API
Changes are **additive by default**:
- New parameters are optional, with defaults that reproduce the old behavior exactly.
- Never rename, repurpose, or remove existing parameters, response fields, or error codes. Don't change types, ordering guarantees, or which fields are present when.
- New behavior that could surprise existing callers goes behind an opt-in flag.
- Docs, types, and tests move in the same PR as the behavior. A signature change with stale docs is a bug — the docs are the contract users code against.
- Bug fixes come with a regression test that fails before the fix. Be suspicious of fixes that change observable behavior: someone may depend on the bug. When in doubt, ask a maintainer.
### Breaking changes
Rare and deliberate. In order: explicit maintainer sign-off, a documented migration path, and a rollout plan — typically new surface added first, old surface deprecated, old surface removed much later, if ever. Never break as a side effect of a refactor.
### Deprecating
The old surface keeps working. Mark it `@deprecated` in the type declarations, note the replacement on its docs page, and stop using it in examples. Removal is a separate, maintainer-approved decision.
## Definition of done
- [ ] Backward compatible (or the break was explicitly approved)
- [ ] Right home: extension if core never calls it; layered structure either way
- [ ] puter.js method matches sibling conventions; errors are `{ message, code }` with stable codes
- [ ] Types updated and matching runtime behavior
- [ ] Docs page + area overview updated, with a runnable example
- [ ] Tests: backend + three-platform SDK suite (+ e2e for desktop-rendered UI)
`docker-compose.yml` brings up Puter **plus every external service it needs** — MariaDB, Valkey, DynamoDB-local, RustFS S3, nginx — wired together. Closest thing to a production deployment you can self-manage on a single host.
`docker-compose.yml` brings up Puter **plus every external service it needs** — MariaDB, Valkey, DynamoDB-local, RustFS S3, Caddy — wired together. Closest thing to a production deployment you can self-manage on a single host.
## One-line installer (recommended)
@@ -8,7 +8,7 @@
curl -fsSL https://raw.githubusercontent.com/HeyPuter/puter/main/install.sh | sh
```
Generates secrets, writes `.env` + `puter/config/config.json`, downloads `docker-compose.yml` from the OSS repo, and runs `docker compose up -d`. Re-running is safe — it won't overwrite existing config (set `PUTER_FORCE=1` to rotate). Use the manual steps below if you want to inspect or tweak each step yourself.
Generates secrets, writes `.env` + `puter/config/config.json`, downloads `docker-compose.yml` + `caddy/Caddyfile` from the OSS repo, and runs `docker compose up -d`. Re-running is safe — it won't overwrite existing config (set `PUTER_FORCE=1` to rotate). Use the manual steps below if you want to inspect or tweak each step yourself.
-`env: "prod"` — the bundled `config.default.json` ships with `env: "dev"` (matches the source-tree `npm run start=gui` workflow, which expects webpack-dev-server emitting a CSS manifest). Self-host runs against pre-built static bundles, so `env: "prod"` makes the homepage emit the `/dist/bundle.min.css``<link>` tag instead of waiting on a manifest that doesn't exist.
-`database.migrationPaths` — Puter applies the bundled MySQL schema on boot. `mysql_mig_1.sql` (tables) and `mysql_mig_2.sql` (default apps: editor, viewer, pdf, camera, player, recorder, git, dev-center, puter-linux). Idempotent — safe to re-run.
-`jwt_secret_v2` — the HMAC secret Puter signs and verifies auth tokens with (`kid: 'v2'` JWT header). The pre-v2 token format is retired: a token signed with the old `jwt_secret` no longerverifies, and holders are asked to sign in again. If you are upgrading from a release that had `jwt_secret`, drop it from your config — it is ignored.
-`env: "prod"` — the bundled `config.default.json` ships with `env: "dev"` (matches the source-tree `npm run start:gui` workflow, which expects webpack-dev-server emitting a CSS manifest). Self-host runs against pre-built static bundles, so `env: "prod"` makes the homepage emit the `/dist/bundle.min.css``<link>` tag instead of waiting on a manifest that doesn't exist.
-`database.migrationPaths` — Puter applies the bundled MySQL/MariaDB schema on boot. The migration files are idempotent, so it is safe to leave this configured across restarts.
-`dynamo.bootstrapTables: true` — Puter creates its KV table on boot. **Only set against a local emulator**, never real AWS.
-`dynamo.aws` keys are dummies; DynamoDB-local doesn't validate them but the AWS SDK requires _something_. **Note:** DynamoDB uses `access_key` / `secret_key` (snake_case); S3 below uses `accessKeyId` / `secretAccessKey` (camelCase). Not interchangeable.
-`meteringEnforcement.subscriptions: false` — a few surfaces (the OpenAI/Anthropic-compatible AI endpoints) are declared paid-plan-only. A self-hosted install has no paid plans, so every account sits on the free policy and those routes would answer `402 subscription_required` for everyone. Turning the plan gates off opens them; nothing else about metering changes.
-`providers.ollama.enabled: false` — Puter auto-probes a local Ollama at `127.0.0.1:11434` by default; without one running you'd see `ECONNREFUSED` on every boot. To run a bundled Ollama, see [Optional: local LLM (Ollama)](#optional-local-llm-ollama) below.
-`s3.s3Config.forcePathStyle: true` — RustFS / MinIO / fauxqs need path-style URLs (`<endpoint>/<bucket>`). Real AWS S3 wants virtual-hosted (`<bucket>.<endpoint>`) — drop this flag (or set `false`) when you swap to real S3.
-`s3.s3Config.publicEndpoint` — `endpoint` (`http://s3:9000`) only resolves inside the docker network; presigned upload/download URLs handed to the browser need a host-reachable URL. nginx routes the `s3.<domain>` subdomain to RustFS internally and preserves the Host header end-to-end (required for S3 signature validation), so the browser hits the same port/protocol as the rest of the app — no separate published port, no mixed-content surprises when you turn on TLS. Switch to `https://s3.<your-domain>` once you enable TLS in Step 3. Real AWS S3 doesn't need this — its endpoint is already public; drop the field entirely.
-`trust_proxy: 1` — nginx terminates TLS and forwards `X-Forwarded-For`. Without this, `req.ip` is the docker-network address of the nginx container instead of the real client IP, which breaks rate limiting and IP-based audit logs. `1` = one trusted hop (nginx). Bump to `2` if you put Cloudflare in front of nginx; never set `true` (it trusts every hop and makes XFF forgeable).
-`s3.s3Config.publicEndpoint` — `endpoint` (`http://s3:9000`) only resolves inside the docker network; presigned upload/download URLs handed to the browser need a host-reachable URL. Caddy routes the `s3.<domain>` subdomain to RustFS internally and preserves the Host header end-to-end (required for S3 signature validation), so the browser hits the same port/protocol as the rest of the app — no separate published port, no mixed-content surprises when you turn on TLS. Switch to `https://s3.<your-domain>` once you enable TLS in Step 3. Real AWS S3 doesn't need this — its endpoint is already public; drop the field entirely.
-`trust_proxy: 1` — Caddy terminates TLS and forwards `X-Forwarded-For`. Without this, `req.ip` is the docker-network address of the Caddy container instead of the real client IP, which breaks rate limiting and IP-based audit logs. `1` = one trusted hop (Caddy). Bump to `2` if you put Cloudflare in front of Caddy; never set `true` (it trusts every hop and makes XFF forgeable).
> If you ever change `MARIADB_PASSWORD` after first boot, `.env` alone won't update MariaDB — its credentials are baked into `./puter/data/mariadb/` on first init. Either rotate the password inside MariaDB by hand or `docker compose down && rm -rf ./puter/data/mariadb` to start fresh.
## Step 2 — Point DNS at the server \[Optional\]
In your DNS provider, add records for the main domain plus the subdomains Puter and nginx route on (`api.*`, `site.*`, `app.*`, `s3.*`):
In your DNS provider, add records for the main domain plus the subdomains Puter and Caddy route on (`api.*`, `site.*`, `app.*`, `s3.*`):
```
A puter.localhost → <your server's public IP>
@@ -159,7 +165,7 @@ A dev.puter.localhost → <your server's public IP>
A *.dev.puter.localhost → <your server's public IP>
```
The wildcards are required — Puter routes via subdomains (`api.*`, `app.*`, etc.) and nginx routes browser S3 traffic via `s3.*` to RustFS.
The wildcards are required — Puter routes via subdomains (`api.*`, `app.*`, etc.) and Caddy routes browser S3 traffic via `s3.*` to RustFS.
## Step 3 — TLS (recommended for public installs) \[Optional\]
The cert needs to cover `*.puter.localhost` so that `s3.puter.localhost` (browser S3 endpoint), plus Puter's own `api.*` / `app.*` subdomains, all validate.
> **Why not Caddy's automatic HTTPS?** Caddy issues certs over ACME by itself, but only for hostnames it knows up front, and only over HTTP-01 with the stock image. Puter serves every user's site and app on a subdomain it invents at runtime (`<name>.site.<domain>`, `<name>.app.<domain>`), which needs a **wildcard** cert — and wildcards require DNS-01, which requires a DNS-provider plugin that isn't in `caddy:2.11-alpine`. So `caddy/Caddyfile` sets `auto_https off` and reads the cert you supply below. If you'd rather have Caddy manage certs, build an image with the plugin for your DNS provider (`xcaddy build --with github.com/caddy-dns/<provider>`) and swap the `tls` line for a `tls { dns <provider> … }` block.
Drop the resulting `fullchain.pem` and `privkey.pem` into `./puter/tls/`.
**Wire nginx to use them:**
**Wire Caddy to use them:**
1. Open [nginx/nginx.conf](../nginx/nginx.conf), uncomment **both**`# server { listen 443 ssl … }` blocks (one for `s3.*`, one for the catch-all).
2. (Optional) Replace the body of the port-80 blocks with `return 301 https://$host$request_uri;` to force HTTPS everywhere.
3. In [docker-compose.yml](../docker-compose.yml), uncomment the `443:443` port mapping under the `nginx` service.
1. Open [caddy/Caddyfile](../caddy/Caddyfile) and uncomment the `# :443 { … }` block at the bottom.
2. (Optional but recommended) Replace the plain `:80 { import puter_routes }` block with the `redir` version shown alongside it, to force HTTPS everywhere.
3. In [docker-compose.yml](../docker-compose.yml), uncomment the `443:443` port mapping under the `caddy` service.
4. In `.env`, uncomment `HTTPS_PORT=443`.
5. In `config.json`, switch:
```json
@@ -195,8 +203,36 @@ Drop the resulting `fullchain.pem` and `privkey.pem` into `./puter/tls/`.
The bundled `puter-caddy` mirrors production and is the supported default — it terminates TLS (Step 3) and forwards every Host to Puter. But plenty of self-hosters already run their own edge proxy (Traefik, nginx, HAProxy, a cloud load balancer, another Caddy). You can put Puter behind it instead; Puter doesn't terminate TLS itself in any setup, so "your proxy does TLS" is just a matter of pointing it at the Puter container and getting a handful of details right.
You can keep the bundled Caddy as the single hop your proxy talks to, or bypass it and forward straight to the Puter container on port `4100` (uncomment the `4100:4100` mapping under the `puter` service in [docker-compose.yml](../docker-compose.yml), or attach your proxy to the compose network). Either works — what matters is the rules below.
**The rules that actually matter** (getting any of these wrong is what causes the redirect loops and "Invalid Host header" failures people hit):
1. **Don't rewrite the `Host` header.** Puter routes entirely on Host — `api.<domain>`, `site.<domain>`, `app.<domain>` are all distinguished by the incoming host. Forward the original host through unchanged. Do **not** rewrite external hostnames to an internal name like `puter.local`; that forces you to remap every subdomain by hand and still breaks signed-URL and CORS checks. Most proxies preserve Host by default — just don't override it.
2. **The external domain must equal `domain` in your `config.json`.** If users reach the box at `puter.example.com`, then `domain` must be `puter.example.com` and the hosting domains must be its real subdomains (`site.puter.example.com`, `app.puter.example.com`, …) — exactly as the installer/`config.json` lays them out. Puter rejects hosts it doesn't recognize with `Invalid Host header`.
3. **Set `protocol` to match the public scheme.** When your proxy terminates TLS, set `"protocol": "https"` (installer: `PUTER_PROTOCOL=https`). Puter builds its origins, redirects, signed S3 URLs, and OIDC callback URLs from this — leave it `http` behind an HTTPS proxy and you get redirect loops, broken logins, and mixed-content errors.
4. **Forward the standard proxy headers.** Puter needs:
- `Host` — the original request host (rule 1).
- `X-Forwarded-Proto` — the **external** scheme (`https`), so Puter knows TLS was terminated upstream.
- `X-Forwarded-For` — the real client IP (rate limiting + audit logs).
- `Upgrade` / `Connection` — passed through for WebSocket / socket.io upgrades, or the realtime connection silently fails.
5. **Set `trust_proxy` to the number of hops in front of Puter.** One external proxy talking directly to Puter → `"trust_proxy": 1` (installer: `PUTER_TRUST_PROXY=1`). A second proxy in front (e.g. Cloudflare → your proxy → Puter) → `2`. This is the count of proxies between the client and Puter, **including** the bundled Caddy if you keep it. Too low and `req.ip` becomes a proxy address (breaks rate limiting); never set `true` (it trusts every hop and makes `X-Forwarded-For` forgeable).
6. **Route the wildcard to Puter.** Your proxy must forward `*.<domain>` (covering `api.*`, `app.*`, and `s3.<domain>`) and `*.site.<domain>` (and any other hosting domains) to Puter — same wildcard DNS as Step 2. Puter does the per-subdomain routing internally; the proxy just needs to hand it the traffic with the Host intact.
With those in place the rest of the stack is unchanged — `docker compose up -d` as below.
## Step 4 — Bring it up
Both [docker-compose.yml](../docker-compose.yml) and [caddy/Caddyfile](../caddy/Caddyfile) need to sit next to your `.env` — clone the repo, or copy those two paths out of it. The Caddyfile is bind-mounted read-only; if it's missing, Docker creates a *directory* at that path and `puter-caddy` dies with "not a directory".
```bash
docker compose up -d
```
@@ -228,6 +264,26 @@ Change it in Settings after first login.
All optional. Drop any of the blocks below into `puter/config/config.json` and `docker compose restart puter`. See [config.template.jsonc](../config.template.jsonc) for the full list. Per-key documentation lives in [src/backend/types.ts](../src/backend/types.ts).
### PostgreSQL database
> **Community contribution — expect rough edges.** PostgreSQL support was contributed by the community and is not run by Puter.com production. It boots, applies the bundled schema, and exercises the common user/app/fsentry/session/permission/OIDC flows in the integration tests, but less-traveled SQL call sites may still need porting. If you hit a query that doesn't work on Postgres, please open an issue. For production self-hosting today, MariaDB/MySQL and SQLite are the supported defaults.
The bundled Docker Compose stack still defaults to MariaDB. To use PostgreSQL instead, run a PostgreSQL service yourself, point Puter at it, and use the PostgreSQL migration path:
You may use `"connectionString": "postgres://puter:...@postgres:5432/puter"` instead of the host/user/password fields. Puter only needs normal PostgreSQL connection details and the migration path; CloudNativePG is one possible Kubernetes operator, but no operator-specific manifests are required by Puter.
### Email (SMTP)
Used for password resets, email confirmation, and notifications. Without it those flows silently fail.
@@ -244,6 +300,18 @@ Used for password resets, email confirmation, and notifications. Without it thos
To require email confirmation before login, also set `"strict_email_verification_required": true`.
To read the mail yourself instead of sending it, point the transport at a local SMTP sink — [MailHog](https://github.com/mailhog/MailHog) catches everything and serves it at `http://localhost:8025`. Publish both ports; a container that only exposes them is unreachable from the host:
```sh
docker run -d -p 1025:1025 -p 8025:8025 mailhog/mailhog
Share email is on once a transport exists: a recipient who already has an account is emailed as well as notified in-app, and they opt out with the unsubscribe link the mail carries. Set `"share_email_notifications": false` to keep those in the app only — an address with no account is still emailed, since there is nothing else to reach them with.
### Sign in with Google (or another OIDC provider)
```json
@@ -319,6 +387,37 @@ Default is 100 MB per user.
Set `is_storage_limited: false` for unlimited (bounded by host disk).
### Usage metering and budgets
Puter meters what an account costs to serve — bytes sent back, object-store
requests, KV capacity, AI tokens — against a monthly budget, and refuses the
operations that spend it once that budget is gone. The refusal is a `402` with
code `insufficient_funds`; reads that only describe things, and every kind of
deletion, stay available so an account can always see what it has and clear it.
On a self-hosted install this is almost certainly not what you want. There is
nowhere to buy more, so accounts are held to the free monthly allowance
(US$0.25 of measured cost — roughly 2 GiB of downloads) and start being refused
after that. Turn it off:
```json
"unlimitedMetering": true
```
Every account then resolves to an unlimited policy. Usage is still recorded, so
the dashboard still shows what is being consumed; nothing is ever refused for
lack of budget.
To keep the budgets but stop them blocking anything — recording only:
```json
"meteringEnforcement": { "enabled": false }
```
Calls driven by a deployed worker are exempt from enforcement by default,
because a worker has no prompt to show and nobody watching it fail. Set
`"meteringEnforcement": { "workers": true }` to include them.
### Captcha on signup / login
Built-in proof-of-work captcha — no external service needed.
@@ -329,6 +428,15 @@ Built-in proof-of-work captcha — no external service needed.
`difficulty` is one of `easy` / `medium` / `hard`.
### Disable new signups
Force visitors to log in with an existing account instead of creating a
temporary or permanent one.
```json
"disable_user_signup": true
```
### Block disposable email TLDs
Only enforced when `env: "prod"`.
@@ -410,7 +518,7 @@ Migrations re-apply idempotently across pulls. Volumes are preserved.
## Troubleshooting
**Site loads but I get "Bad Gateway" / nginx errors.**
**Site loads but I get a 502 / "Bad Gateway" from Caddy.**
The puter container failed to come up. `docker compose logs puter` will tell you which dependency rejected it (most often DB password mismatch between `.env` and `config.json`).
"test:backend":"npm run setupExtensions && npm run build:workerLib && vitest run --config src/backend/vitest.config.ts ",
"test:backend:postgres":"npm run setupExtensions && npm run build:workerLib && PUTER_TEST_DB_ENGINE=postgres vitest run --config src/backend/vitest.config.ts ",
"test:puterjs":"npm run setupExtensions && vitest run --config src/puter-js/tests/api/vitest.config.ts",
"test:puterjs:node":"npm run test:puterjs -- src/puter-js/tests/api/runners/node.test.ts",
"test:puterjs:browser":"npm run test:puterjs -- src/puter-js/tests/api/runners/browser.test.ts",
"test:puterjs:workerd":"npm run test:puterjs -- src/puter-js/tests/api/runners/workerd.test.ts",
"test:puterjs:coverage":"npm run build:workerLib:coverage && npm run setupExtensions && rm -rf src/puter-js/coverage && PUTER_COVERAGE=1 vitest run --config src/puter-js/tests/api/vitest.config.ts && node ./tools/puterjsCoverageReport.mjs",
"build:workerLib:coverage":"cd src/puter-js && npm run build:coverage && cd ../worker && npm run build",
('app-5584fbf7-ed69-41fc-99cd-85da21b1ef51',1,NULL,'camera','Camera','Camera in the browser.','https://online-camera.com',FALSE,FALSE,FALSE,TRUE,FALSE,FALSE,NULL,'2020-01-01 00:00:00'),
('app-11edfba2-1ed3-4e22-8573-47e88fb87d70',1,NULL,'player','Player','A free video player app in the browser.','https://simple-player.puter.com',FALSE,FALSE,FALSE,TRUE,FALSE,FALSE,NULL,'2020-01-01 00:00:00'),
('app-7bdca1a4-6373-4c98-ad97-03ff2d608ca1',1,NULL,'recorder','Recorder','Online voice recorder in the browser with cloud storage.','https://voice-recorder.com',FALSE,FALSE,FALSE,TRUE,FALSE,FALSE,NULL,'2020-01-01 00:00:00'),
('app-d7e9471f-e441-4d72-a5ab-75e96573b76b',1,NULL,'music-player','Music Player','A free music player app in the browser.','https://player.puter.com',FALSE,FALSE,FALSE,TRUE,FALSE,FALSE,NULL,'2026-05-10 00:00:00'),
('app-0b37f054-07d4-4627-8765-11bd23e889d4',1,NULL,'dev-center','Dev Center','This is the app that makes apps','https://builtins.namespaces.puter.com/dev-center',TRUE,TRUE,FALSE,TRUE,TRUE,FALSE,NULL,'2020-01-01 00:00:00'),
('app-fbbdb72b-ad08-4cb4-86a1-de0f27cf2e1e',1,NULL,'puter-linux','Puter Linux','Linux emulator for Puter','https://builtins.namespaces.puter.com/emulator',TRUE,FALSE,FALSE,TRUE,TRUE,FALSE,NULL,'2020-01-01 00:00:00'),
('app-862fc09e-c7b8-4c30-b5b4-47b3cc5f5232',1,NULL,'memos','Memos','Notes and memos','https://memos.puter.com',FALSE,TRUE,FALSE,TRUE,FALSE,FALSE,'productivity','2020-01-01 00:00:00'),
('app-c10a1999-22f5-4644-9960-9aaac0d4934e',1,NULL,'spreadsheet','Spreadsheet','Work with spreadsheets','https://spreadsheet.puter.com',FALSE,TRUE,FALSE,TRUE,FALSE,FALSE,'productivity','2020-01-01 00:00:00'),
('app-fb7d9e42-8207-4fa0-b680-f0239327097f',1,NULL,'pdf-editor','PDF Editor','Edit PDF documents','https://pdf-editor.puter.com',FALSE,TRUE,FALSE,TRUE,FALSE,FALSE,'productivity','2020-01-01 00:00:00')
Loaded 100 of 1369 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.