The Add-an-app and uninstall cards had no height cap and their overlay
centers without scrolling, so on a landscape phone with the keyboard up
(the viewport resizes for it) the request form's title and buttons sat
off-screen with no way to reach them.
The touch menu took 90% of the viewport, which on an iPad meant a ~920px
strip with its labels far from the row they belong to. It now stops at
400px, which every phone stays under. Its height is also measured after
the width is set, so the clamp to the viewport uses the real height.
On phones the sheet sits flush with the bottom edge, and unlike the share
sheet it mirrors, its body didn't pad for the bottom safe-area inset, so
the last details scrolled under the iPhone home indicator.
The installed app runs edge to edge, so the footer's item count sat under
the iPhone home indicator. The footer now grows by the bottom safe-area
inset, and the list padding and floating action bar move up with it, as
the Apps tab and the dashboard's bottom sheets already do.
The bar's visible state set z-index 99999999999999999 (clamped to the
maximum), so in phone select mode it drew over the open navigation drawer,
covering the account button, and over the share sheet it opens. Its base
z-index already clears the rows and the footer.
A long path wrapped the crumbs over several lines on top of the column
headers and pushed the toolbar actions off-screen; where the crumbs already
scrolled (phones), the row opened at the root, hiding the current folder.
The crumbs now scroll within their row and are scrolled to their end on
render and when the tab is shown.
On tablets the nav buttons, crumbs and actions sat on one row wider than
the listing, so the sort, view, new-folder and upload buttons ran off the
right edge. Narrow listings now use the phone's two-row toolbar (crumbs
above, buttons below); only the menu-button clearance stays phone-only.
The two-row rows (name, then size and date) were keyed to a phone-width
viewport, so between 481px and ~930px, where the sidebar and folder column
take their share, the fixed size and modified columns squeezed names to a
few characters. The layout now switches on the listing's own width with a
container query, which phones still meet and wide listings never do.
Infron (a new-api gateway) answers "bad response status code N" with the
upstream's status when the upstream replied without a JSON error, e.g. a
model the upstream no longer serves. That 4xx was classified as the
caller's bad request, so the request skipped fallback, the dead route
stayed in rotation, and the caller got upstream_bad_request. It now counts
as an upstream failure: fallback runs, the route is marked unhealthy, and
an exhausted chain reports upstream_provider_unavailable. A gateway error
that carries the vendor's own message still stays request-level.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
The desktop built the language list and never placed it on the user menu, so a saved locale could not be chosen. Register Filipino and show that list.
Co-authored-by: Cursor <cursoragent@cursor.com>
* fix(teams): refuse to boot when teams_enabled has nothing registering Teams
Core's seat checks (team 2FA, seat password reset, email and username
changes, self-delete, the signup placeholder claim) read the team store and
skip when it is absent. With `teams_enabled` on and no extension registering
the store and service, the server now fails at boot instead of serving
without those checks.
- whoami no longer string-matches the extension proxy's "missing property"
error: with the flag on, the store is always there.
- Tests that turned the flag on without Teams drop it; the puter.js harness
sets it only under the `teams` capability.
- Drop a stale services/index.ts comment.
* refactor(gui): drop the Teams console styles, which ship with the console now
* feat(metering): let registered plans declare free and no-allowance traits
A policy passed to registerPolicy can now set `free: true`, so
isFreeSubscription and everything that reads it count the plan as free.
It can also set `noAllowance: true`: the plan grants no monthly
allowance, so only purchased credit can be spent, and the cached
credit check refuses an actor that has none. A non-positive allowance
without the trait still means the plan is not metered.
getActorSubscriptionStatus returns the plan along with whether it was
paid for and whether the answer is provisional (a lookup behind it
failed, or the actor lacks a field resolvers key on), so callers can
tell a fallback from a resolved plan. getActorSubscription is unchanged.
* feat(metering): provisional resolver answers, and tighter no-allowance accounting
A subscription resolver can answer { id, provisional: true }. The answer
wins like any id but is cached only for the fallback window.
On a no-allowance plan, the month record now records allowanceUsed = 0,
so spend that purchased credit paid for is not counted again against an
allowance the actor gets later in the month. An actor that never had
budget raises no credit-state event. setMonthAllowance refuses plans
whose allowance an override never applies to.
* fix(metering): keep resolved plans clean when a default fails, and announce moves onto no budget
A failed or provisional default resolver now marks the answer provisional
only when the default is what applied; an explicit plan that won is
cached for the full window. A provisional answer with no id still marks
the result provisional.
A plan or credit change now re-arms an actor's credit alert instead of
forgetting it, so an actor moving onto a plan with no budget hears
'exhausted' once. An actor never seen with budget still hears nothing.
* chore(metering): let a provisional answer carry no id, and fix two resolver docs
* chore: one-line comments
* refactor: let core run without a teams registration
Teams becomes optional: `services.team` and `stores.team` may be absent, and
core stops assuming either is there.
Seventeen call sites across share, signup, account deletion and the homepage
now degrade instead of throwing: a team recipient that cannot be resolved is
"no such recipient" rather than a fault, a group share folds in nothing, and
account deletion stops asking what a seat was charged for. This generalises
the pattern `gates.ts` and `SocketService` already used.
Teams' own files take a private handle to their store and service, so the
feature's code stays free of optionality that only core needs.
No behaviour changes where Teams is registered.
* refactor: put a contract between core and teams
`core/teams.ts` holds the shapes core reads and the calls it makes, and the
registries declare the pair as provided rather than built in. The service,
store and controller are registered by whatever runs the feature.
Typing them properly turned up eight AuthController call sites the previous
commit could not see: the controller reads its stores from the built-in
registry, so an absent `team` fell to an index signature and typed as
`unknown` instead of erroring. They now go through the same optional path as
the rest.
The feature's own tests move with it. The cases in core suites that build a
team to exercise a core rule still need relocating, so those suites are red
here.
* test: stub core's seat rules, rehome the rest with the feature
A core suite can no longer build a team, so the rules that refuse a seat get
`testFixtures/seatStub.ts` instead: it answers the contract, which is all
those rules read, and puts the registry back when the test ends.
Seventeen cases move that way. Four could not: group shares and a seat's 2FA
enrolment over HTTP need the real feature and the `/teams` route, so they go
with it, as does the whole of `GroupGrants.test.ts`.
Two whoami cases asserted a seat's team through `extension.import`, which
resolves the registry rather than the stores object. They now assert the
absent case, and the behaviour they covered is asserted against
`buildUserDetails`, which takes its stores as an argument.
Suite: 12252 passed, 38 skipped.
* refactor: take the teams console out of the built-in tabs
`TabTeams`, `teamsConsole` and `teamActionIcons` are no longer built in. The
dashboard already invites a tab through its `dashboard-will-open` event, so
whatever provides Teams contributes the console the same way.
Its 113 strings go with it and register into the shipped English dictionary
at load, so `i18n('teams_*')` resolves as it did. Only `en.js` ever carried
them, so no translation is lost.
Three modules stay, because things other than the console use them:
`teamBadge` (the dashboard shell), `orgSeat` (Home, Account, Security) and
`shareTeams` (the share picker). They already render nothing when the
account has no team. `teams_account_of` stays with `teamBadge` for the same
reason, and the `share_*` and `plan_team_*` strings stay with their callers.
The GUI builds clean with no console at all, and builds it in when the tab
is contributed.
* docs: seats past the owner's current limit get no allowance
Presigned URLs die with the credentials that signed them, and the SDK only refreshes temporary credentials five minutes before expiry, so a URL signed late in a credential's life could expire long before its requested lifetime (up to an hour). The chain provider now reports expiry an hour early and force-refreshes credentials that are already inside that window.
Node 26's bundled undici negotiates HTTP/2 by default with a 256 KB
per-stream receive window, which capped large downloads at about
window / RTT (8.6 MB/s vs 46 MB/s over HTTP/1.1 at 23 ms). The guard swaps
in an Agent of the same class with 4 MB stream / 16 MB connection windows.
The web upgrade dialog's button opens the dashboard with the refusal
reason, the app the call ran as, and the refused method as query params,
so the dashboard can open its upgrade flow with that context.
* chore: move to Node 26
Docker image and CI run on Node 26 (bookworm-slim, same Debian base as before).
Node 26's bundled undici reads the global dispatcher from the
`undici.globalDispatcher.2` slot, so the guard's 10-minute timeout wrap on
`.1` no longer reached plain fetch() and provider calls fell back to the
5-minute headers timeout. The guard now wraps every present slot and tracks
wrapped dispatchers in a set, since compose() can return a Proxy that writes
a marker property through to the original dispatcher.
* test(puter-js): disable Node's web storage in test workers
Node 26 defines its own localStorage global, undefined without a storage
file, and vitest's jsdom environment doesn't replace globals that already
exist, so jsdom tests saw localStorage as undefined.
index_url may not point at a system host: the origin, the api host, or a
subdomain an extension has claimed. App create and update now apply the
same refusal to `<claimed>.<domain>` (any case, any port) that they apply
to the origin and api hosts.
The host-to-claim match is a new exported `subdomainLabelOf` in
hostRedirects, also used by the claim dispatch middleware, so both
decide the same way.
Co-authored-by: Daniel Salazar <daniel.salazar@puter.com>
* fix(fs): support offset and byte_count in read operations
Allow partial content reads via offset and byte_count parameters in puter.fs.read and backend FS controllers.
- In puter.js read operation, check options.offset and options.byte_count against null/undefined so offset: 0 is preserved in query params.
- In handle_resp, accept any 2xx status code so 206 Partial Content responses resolve successfully.
- In backend FS controllers (LegacyFSController and FSController), derive the HTTP byte range from query offset/byte_count parameters when an explicit Range header is not present.
- Add unit and integration tests across controllers and SDK test suites.
* fix(fs): strict range params, 416 past EOF, drop format churn
- resolveReadRange accepts only decimal digit strings and safe integers
- readContent maps S3 InvalidRange to 416 instead of a 500
- revert unrelated union-type reformatting, restore truncated test comment
---------
Co-authored-by: Daniel Salazar <daniel.salazar@puter.com>
A folder opened with an app (Open With, Open with AI) reached puter.js as
a file: the GUI never forwarded the signature's fsentry_is_dir, so the
launched FSItem always had isDirectory false. The GUI now appends
puter.item.is_dir, and puter.js reads it in onLaunchedWithItems and the
onItemsOpened launch replay. Both now share one URL-to-FSItem builder.
"Open with AI" moves directly under Open, above Open With, in the
desktop and dashboard file menus.
"Open with AI" now launches the `ai` app with the item the same way
Open With launches any app: launch_app with file_path/file_uid, so the
app gets a signed launch item via puter.ui.onLaunchedWithItems. It
replaces the custom `ai:openFsEntries` postMessage, which went to an
existing AI window.
- The helper moved from UIItem into helpers/openWithAI.js, shared by the
desktop item menu and the dashboard Files menu (generate_file_context_menu).
- A shortcut opens its target.
- Dropped from multi-select menus, since a launch opens a single item.
- Renamed open_in_ai -> open_with_ai ("Open with AI") and removed the
unused ai_app_unavailable string.
The desktop no longer launches the `ai` app at boot. This removes the
`is_panel` window mode, which existed only for that launch, and the
toolbar/taskbar/window/popover offsets tied to it. It also removes the
desktop sparkles button that showed the panel, and the
`silent_on_failure` launch option, whose only caller was the boot launch.
"Open in AI" in the item context menu still launches the app as a
normal window.
* fix(dashboard): size the Notifications sidebar row like its neighbors
The bell button's font: inherit overrode the sidebar item's font-size
(16px instead of 14px, 15px in the drawer), and its icon wrapper stayed
18px while the drawer grows nav icons to 20px.
* test(dashboard): scope the directory-note assertion to the directory row
The 2FA row reuses .teams-directory-note, so the unscoped selector read
both notes and the test failed.
* fix(dashboard): only treat a picked file as a new avatar while the avatar picker is open
The Account tab listened for every file_opened on the dashboard window,
so choosing an image for a web link's Change Icon in the Files tab opened
the profile-picture crop modal, and saving it replaced the avatar.
* fix(dashboard): let dialogs over the Files tab keep their keyboard
The Files keydown handler ran under an open confirm, so pressing Enter on
the focused Delete button of the permanent-delete prompt opened the
trashed item instead (stacking a second alert), and arrows or Delete
acted on the rows behind the dialog.
* fix(dashboard): keep Apps grid keys away from an open context menu
Choosing a tile's menu item with Enter (e.g. Open in new tab) also
launched the focused app in place, and arrows moved tile focus while
they walked the menu.
* fix(dashboard): Escape on a menu or modal inside an app folder keeps the folder open
The menu's and the uninstall modal's Escape handlers removed them before
the folder's listener ran, so it saw nothing above it and closed the
folder with the same keystroke.
* fix(dashboard): a tap on the drawer or sheet scrim only dismisses it
Outside-press handlers closed the mobile drawer and the notifications
sheet on touchstart/pointerdown, which took away the scrim's hit area
before the click, so the tap also landed on the app tile or file row
underneath.
* fix(dashboard): let the sidebar nav scroll on short viewports
The nav clipped its overflow, so in a landscape phone's drawer (or a
short desktop window) Usage, Account, and Security fell below the fold
with no way to reach them.
* fix(dashboard): keep the mobile drawer full width when the desktop sidebar is collapsed
The remembered collapse outranked the drawer's width rule, so below the
drawer breakpoint it opened as an unlabeled 50px icon strip, and the
toggle that would undo it is hidden there.
* perf(dashboard): stop refetching account data each time focus leaves an app
Home's window focus listener fired whenever focus came back from an app's
iframe, so every click from an app to the dashboard re-pulled whoami,
subscription, storage, and usage (5 requests), on any tab. It now only
refreshes after the page itself lost focus to another window.
* fix(dashboard): show a seat's team name unescaped in the plan card
i18n escapes its arguments for HTML, but the note is set with .text(), so
a team called "Bob's & Co" read "Managed by Bob's & Co".
* fix(dashboard): stop double-escaping app names in uninstall alerts
UIAlert escapes its message, so an app titled "Tom & Jerry" read
"Tom & Jerry" in the uninstall-failed and sessions-not-ended alerts.
* fix(dashboard): roll back an uninstall whose revoke failed
puter.perms.revokeApp resolves with error set instead of rejecting, so a
failed revoke still removed the tile, marked the app removed for later
sessions, and ended its sign-in, while the app kept its permissions and
the rollback and error alert never ran.
* fix(dashboard): read a team account's suspension when its name matches an Object member
The per-account state map was a plain object, so a username such as
constructor or toString was already "in" it, its audit entries were
skipped, and a suspended account showed as active with no Delete.
* fix(dashboard): update the profile card and sidebar name after a username change
Neither carried the .username class that the rename refreshes, so both
kept showing the old name until reload.
* fix(dashboard): ask how to restore a trashed item whose name is taken
Restore moved without overwrite or dedupe, so when the original location
already had an item of that name the move 409'd and nothing happened:
an uncaught error from the item menu, a console line from the others.
All three restore entry points now share one path that offers the
Replace / Keep Both prompt the paste flow uses, alerts on other errors,
and updates the footer and empty notice once the rows are gone.
* fix(dashboard): refresh a renamed row's name, icon, and position in the Files tab
The Files tab passed rename_file a callback it never accepted, so a
renamed row kept the desktop's 20-character truncation even in a wide
column, kept its old type icon, and stayed out of sort order. rename_file
now takes an optional onDone (carried through undo), which the Files tab
uses to fit the name to its column, refresh the icon and type, and move
the row into place. A cancelled rename no longer truncates the name
either.
* fix(dashboard): drop Publish and Properties from the Shared view's menu
The Shared view is a query, not a folder, so both acted on the virtual
shared path: Properties showed an unknown error and Publish opened a
dialog for a path that doesn't exist.
* fix(dashboard): close the Files image preview on Escape and when leaving the folder
Escape cleared the selection but left the Space preview open with
nothing selected, the preview stayed over a different folder after
navigating, and a preview whose URL arrived after it was dismissed or
replaced still opened.
* fix(dashboard): don't move Files history for a navigation that's dropped
renderDirectory ignores a navigation while another folder is loading,
but every caller had already pushed or stepped history, so after a click
during a load the listing and history disagreed and the next Back or
Forward seemed to do nothing. History now only moves when the render
will run; back/forward steps share one helper.
* fix(dashboard): reopening the current folder adds no Files history entry
Clicking the folder already on screen (e.g. Home in the sidebar) pushed
a duplicate entry, so the next Back reloaded the same folder.
* fix(dashboard): show installed apps when the recommended list fails to load
get-launch-apps sat in the same Promise.all as the installed apps and
its body was parsed without a status check, so a network error or a
proxy's HTML error page replaced the whole grid with "Failed to load
apps". A failed list now falls back to the last one that loaded.
* fix(dashboard): fit the app uninstall dialog on narrow phones
The card is mounted outside the dashboard's border-box rule, so its
320px min-width plus padding came to 370px and was clipped on 360px
screens; a long unbroken app title also overflowed it. Desktop sizing is
unchanged.
* fix(dashboard): keep a new team account's one-time password on screen until dismissed
The credential box lived only in the DOM inside the panel that every
refresh repaints, so revisiting the Teams tab or any later action wiped
a password that can't be retrieved again. It is now kept in state, for
the team it belongs to, and drawn with the panel.
* fix(dashboard): reload the Teams tab after a change made while it was loading
refresh() handed back any load already in flight, so an action or team
switch made during a revisit's background load awaited data read before
the change: a toggled setting snapped back, and a team switch could
paint one team's roster next to another's record. A change during a load
now queues one more load, the stale one skips its paint, and revisiting
the tab doesn't start a second load while init's is still running.
* fix(dashboard): send one re-enable request for a double click in Teams
Re-enable has no confirm dialog to absorb a second click, and nothing
held the action while it ran, so a double click enabled the account
twice (two audit entries). A suspend or re-enable already in flight for
an account now ignores further clicks.
* fix(dashboard): revert the avatar and say so when saving a new photo fails
The crop's Save showed the new picture everywhere and revealed Remove
without waiting for the upload, and update_profile swallows failures,
so a failed save looked saved until reload. The save is now awaited: a
failure puts the previous picture back with an error hint, and Remove
waits for the upload so it can't race it.
* fix(dashboard): hide the Usage tab's plan button for team seats
A seat's plan belongs to its team, so Home already hides its upgrade
link; the Usage tab still offered Upgrade, which only reaches a refusal.
* fix(dashboard): don't drop Home's forced usage refresh during a running load
loadUsageData handed any caller the load already in flight before looking
at force, so a subscription change (or a return from the billing portal)
during a routine load was answered with data read before the change, and
the plan card stayed stale until the next refresh. A forced call now runs
once more after the running load.
* fix(dashboard): activate Apps when the URL names an unknown tab
An unknown hash rendered Apps as the active tab but skipped its
activation, so the grid kept the generic content padding and Apps'
onActivate never ran.
* fix(dashboard): make the sidebar user menu reachable from the keyboard
The user button was a div with no tabindex, so Log Out, account
switching, and Contact Us were out of reach without a pointer. It is
now a button (rendering unchanged), with aria-haspopup.
* fix(dashboard): name the sidebar's collapse and drawer buttons for assistive tech
The collapse toggle held only two unlabeled images, so screen readers
announced an unnamed button; it now carries a translated label and
aria-expanded that follow its state. The drawer's open/close labels were
hardcoded English and now go through i18n.
* fix(dashboard): make Home's recent apps reachable from the keyboard
Each recent app was a click-only div, so keyboard users couldn't open
one. They are now buttons with a focus ring; the icon is decorative
since the title names the button.
* fix(dashboard): name the Security tab's 2FA switch for assistive tech
The checkbox sat in a text-less label, so screen readers announced an
unnamed checkbox. It now carries the translated Two Factor
Authentication label, as the Teams switches do.
* fix(dashboard): sort the Usage table from the keyboard
The sortable headers were click-only th cells. Each now holds a button,
carries aria-sort, and keeps focus across the re-render a sort causes.
The Resource/Units/Cost headers go through i18n.
* fix(dashboard): don't rebuild the Files listing under an open menu
Returning to the browser tab re-rendered the folder even with a context
menu or action sheet open, so its actions then targeted detached rows:
Rename did nothing, Restore faded a row that was already gone. The
refresh now waits while a menu is up, as it does for a rename.
* fix(dashboard): point the Files sidebar's Documents at the current home
The entry read window.documents_path, which globals.js sets once from
the cached user and nothing updates; login and username changes keep
window.docs_path current instead. After a username change, Documents
pointed at the old home and showed "This folder couldn't be opened",
even after reloading.
* fix(dashboard): let only the newest share listing render in the share dialog
Each action ends in a refresh, and refreshes from overlapping actions (or
the opening load) could finish out of order, so an older listing landed
last and undid what the user just did, e.g. the link control snapped
back to restricted after turning the link on. Older refreshes now drop
their result.
* perf(dashboard): load a team's record, roster, and plan in parallel
loadSelected awaited the audit log (every page of it), then the members,
then the plan, on every visit to the Teams tab. They don't depend on
each other, so they are now fetched together.
* fix(dashboard): keep keyboard focus on the Teams control that was used
Every Teams action repaints the whole panel, which dropped focus to
<body>: a keyboard user toggling a switch or suspending an account was
sent back to the top of the page. The repaint now refocuses the same
control, or the same account's new action when the old one is gone.
* fix(dashboard): give the app uninstall prompt dialog semantics and focus
The prompt had no dialog role or label and never took focus, so keyboard
and screen-reader users stayed on the inert grid behind it. It is now a
labelled modal dialog that focuses Cancel on open and returns focus to
the app's tile when dismissed.
* fix(dashboard): keep the add-an-app tile when there are no apps
With no apps at all, the empty state replaced the whole grid, add tile
included, so a new user on an instance with no recommended apps had
nothing to act on. The empty line now sits above a grid that keeps the
tile, as a search with no matches already does.
* fix(dashboard): don't rebuild the Apps grid under an active drag
An uninstall finishing (or a failed one rolling back) re-rendered the
grid even while another tile was being dragged, replacing the dragged
tile with a fresh copy: the copy showed under the ghost and the drop
was lost. Renders asked for mid-drag now wait for the one the drop does;
the folder eject, which rebuilds the grid as part of the drag, still can.
* fix(dashboard): return focus to the drawer toggle when the mobile drawer closes
Closing the drawer (Escape, the close button, a tab pick) left focus on
controls that had just become hidden, so it fell to <body>; closing the
notifications sheet opened from the drawer tried to refocus the bell
inside the closed drawer. Both now land on the drawer toggle.
* fix(dashboard): truncate Files names between graphemes, with a binary search
The middle truncation sliced UTF-16 code units, so at many widths a long
name with emoji showed a half surrogate (rendered as a replacement
character) or a broken joined emoji. It also measured one more character
per step, a measureText call per character of every long name on each
render and column resize. The cut now falls between graphemes and is
found by binary search; output for other names is unchanged.
* fix(dashboard): translate the Home tab's hardcoded English
The tab label, the recent-apps empty state, card subtitles, the plan
badge and trial wording, the upgrade/manage links, and the usage cards'
"Used" / "of" lines were English literals. They now go through
i18n, with placeholders where values are interpolated.
* fix(dashboard): translate the sidebar's remaining English
The Apps, Files, and Usage tab labels, the user button's fallback name,
and the user menu's Developers item were English literals; they now use
i18n keys like the other tabs.
* fix(dashboard): translate the Usage tab's hardcoded English
The subtitle, the "used of" meter lines (now the existing used_of
key), Usage Details, the plan button, Show more/less, and the details
error were English literals.
* fix(dashboard): translate the Account and Security tabs' hardcoded English
Both subtitles, the delete-account description, the sessions hint, the
username fallback, and the avatar picker's window title were English
literals.
* fix(dashboard): translate the Apps tab's hardcoded English
The uninstall prompt and its failure alert, empty and no-match notices,
pager and folder-dot labels, the search placeholder, the edit toggle,
and the tile menu's Open in New Tab / Quit / Uninstall were English
literals.
* fix(dashboard): translate the Files tab's hardcoded English
The folder sidebar, footer counts (now the existing item-count keys),
sort and view menus, upload status and errors, the empty and
can't-open notices, the reserved-name alerts, the move error, and the
loading text were English literals.
* fix(dashboard): translate the action sheet's Back row and match delete in any language
The touch action sheet styled an item red when its label contained the
English word "delete", so Delete lost its danger styling in every other
language; it now compares against the translated Delete labels. Its
submenu Back row and the properties panel's Directory/disassociate
labels were English literals.
* fix(dashboard): make the Files sidebar, crumbs, and nav arrows keyboard-operable
The folder sidebar, the breadcrumbs, and the back/forward/up arrows were
click-only li/span/img elements, out of the tab order. They now take
focus, carry button/link roles (the arrows with labels and
aria-disabled), answer Enter and Space, and show a focus ring. The rows'
Enter/Space shortcuts also no longer swallow those keys from a focused
control: with rows selected, Enter on Upload opened the selection.
* perf(dashboard): read grid name widths before writing any in the Files tab
fitItemNames read each tile's name width right after writing the
previous name, forcing a layout per row. Reading them all first, then
writing, cuts switching 400 files into grid view from about 300ms to
170ms; the rendered names are identical.
* feat(ai): add Kling for video, image and speech generation
One `kling` API key now registers a provider on three drivers:
- txt2vid: Kling 3.0 Turbo, 3.0, 3.0 Omni, O1, 2.6 and 2.5 Turbo,
with first/last frames, reference images on the omni models, native
audio, and editing a source video on 3.0 Omni and O1. Billed per
second at the resolution, audio and video-input rate; an edit is held
at the longest source and billed for the clip Kling returns.
- txt2img: Kling Image 3.0, 3.0 Omni, O1 and 2.1, billed per image.
- txt2speech: Kling TTS with its English and Chinese voices, billed per
request.
Kling's business error codes map onto the driver error contract, so an
exhausted resource pack reports as credit exhaustion and a submit-time
content block as `moderation_flagged`. "Risk control" failures now
count as content-filter refusals.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(ai): treat Kling's unknown-model codes as a Puter-side failure
Kling answers 1202/1203 when the method or model isn't one it serves.
Puter picks both, so these meant catalog drift but surfaced as a 400 that
blamed the caller and raised no alarm. They now return 502 upstream_failed
with a warning alarm.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(ai): tighten Kling input checks and error mapping
- 3.0 Turbo takes at most 2,500 prompt characters with a first frame,
the image-to-video endpoint's limit.
- O1 refuses reference images together with a first and last frame.
- 3.0 Omni accepts a source of up to 15.5 seconds, and an edit is held
at 16 seconds to cover it.
- A source video Kling can't decode fails as 400 upstream_bad_request
instead of a Puter-side 502.
- A 1201 about the model counts as catalog drift, like 1202/1203.
- Auth failures no longer pass Kling's console wording to the caller.
Adds integration tests for the image, video and TTS providers, gated
on PUTER_TEST_AI_KLING_API_KEY.
* feat(ai): wait up to 14 minutes for a video job
Video jobs, Kling edits in particular, can take over 10 minutes. The
poll window and the Gemini Omni deadline move to 14 minutes, under the
SDK's 15-minute idle timeout, with holds and signed source links
lengthened to match. The keep-alive default moves to 920 seconds so it
stays above a 15-minute proxy idle timeout.
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Daniel Salazar <daniel.salazar@puter.com>
Sites and workers tied to an app go with it by FK cascade, on app delete and
on deleting the app owner's account. Only caches were dropped for them, so
the subdomain.delete listeners (CDN purge, worker script teardown, worker
sessions, site cleanup) never ran.
* feat(fs): finish a home rename that stopped partway
FSEntryStore.rebaseUserSubtree runs renameUserHome's owner-scoped
cascade on its own (same subtree matching, same rebased-cache
invalidation) and also drops the home row's cached copies at either
path. countUserSubtree counts the owner's rows under a prefix with that
same matching, from the primary. renameUserHome now uses the shared
cascade.
* fix(telemetry): resolve the tracer per span so a test's provider is the one used
withSpan held a ProxyTracer built at module load, which caches the first
provider it delegates to. Test files sharing a worker then sent spans to a
provider an earlier file had already shut down.
* fix(fs): drop cached copies of only the rows a home rebase moves
The owner-scoped rewrite read its invalidation set from everything under the new prefix, which for a finishing rebase is the whole live home; it now reads the rows it is about to move.
---------
Co-authored-by: Daniel Salazar <daniel.salazar@puter.com>
`extension.claimSubdomain(name, { mainPipeline: true })` serves
`<name>.<domain>` the way core's own subdomains are served: through CORS,
body parsing, auth and every route, `subdomain: '*'` ones included. Routes
declared for the name are ordinary routes, with no option restrictions.
Gated claims (`{ authorize, checkRoute? }`) behave as before.
onlyoffice's subdomain now comes from its extension's claim, so core no
longer lists it among the subdomains it serves itself. Without that claim
`onlyoffice.<domain>` gets the unclaimed-subdomain 404 like any other name.
* feat(ai): edit a source video with txt2vid on Gemini Omni
txt2vid takes an `inputVideo` option (sent as `input_video`): a URL, data
URI or Puter file path to an MP4, QuickTime or WebM video of up to 32 MiB.
Gemini Omni receives it inline next to the prompt, which describes the
edit or extension. Google enforces the 10-second limit.
Models declare `videoInput` in the catalog, and the driver rejects a
source video for any other model instead of letting the provider drop it.
The credit hold adds 16,000 input tokens for a source video, since
countTokens reports far fewer tokens than the reply bills.
loadFileInput takes an optional `timeoutMs` for web URLs, and cancels
the body of a failed response.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* feat(ai): edit a source video with FLUX 3 on Together AI
FLUX 3 takes `input_video` as Together's `media.source_video`. A web URL
goes through for Together to fetch; a data URI or Puter file is read with
the caller's access, capped at 32 MiB, sniffed as a video and sent inline.
Editing is estimated and billed at the 1080p rate of $0.54/s, since FLUX 3
jobs report no cost. Image inputs can't be combined with a source video.
Seedance 2.0 ignores `source_video`, treats `reference_videos` as a loose
reference rather than an edit, and Seedance 2.5 fails on reference videos,
so neither declares `videoInput`.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* docs(ai): name FLUX 3 as a video editor in the AI overview
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix(ai): match Hailuo 02 to Together's price and document estimate billing
Together quotes $0.56 for a Hailuo 02 clip; the catalog had $0.49.
Together's video jobs no longer report what they cost, so every Together
clip is billed at the catalog's highest published rate. The docs said the
charge was the amount Together reports; they now describe the estimate.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
* fix: enforce video edit durations and propagate source fetch cancellation
* fix(ai): pass media inputs as Puter files and stream source videos
Source videos were read whole into the API process, base64-encoded and
sent twice to Gemini (countTokens and the interaction), which held about
128 MiB per in-flight edit for up to ten minutes.
- Video inputs are a URL, a Puter file path or a data URI of at most
1 MiB. Together gets a Puter file as a 15-minute presigned S3 URL and
fetches it itself; Gemini streams it into the request body with an
exact Content-Length, so it's never held as one string.
- Gemini counts only the prompt and image for a source video, and both
providers read the source only after the credit hold.
- txt2img and txt2vid image inputs accept Puter file paths, read within
the caller's access right before the provider call. Existing URL,
data URI and base64 inputs are unchanged.
- puter.js writes a Blob, File or data URI over 1 MiB to a temporary
file under .tmp, sends its path, and deletes it once the call settles.
- FLUX 3 edits bill at the 720p editing rate, matching their 1280x704
output.
* fix(ai): stage inputs after website sign-in, keep images working without a write
- puter.js chose the temporary file path before signing a website visitor
in, so it fell outside the app's data directory and the upload was
refused. It now signs in first, then picks the path.
- An image that can't be written (no access, storage full) is sent inline
as before instead of failing the call. Videos still fail, since a
large inline video isn't accepted.
- Telling raw JPEG base64 from a path decodes only the first 24
characters instead of the whole input.
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: 404oops <oops404@util.404oops.com>
Co-authored-by: Daniel Salazar <daniel.salazar@puter.com>
* fix(gui): refresh Start menu apps without blocking cached rendering
* docs: remove desktop Start menu limits from SDK reference
* fix(gui): drop the 1 MiB cap on the launch-apps refresh
Recent entries carry each app's raw icon column, which can still hold a data URL of up to 5 MB, so a large icon tripped the cap on every request, including the one at startup, and left the Start menu with no apps. Replace the cap's test with one for the unlaunchable-app filter.
---------
Co-authored-by: Daniel Salazar <daniel.salazar@puter.com>
The start menu read a session-cached launch-apps list and only refetched when it was empty, so an app deleted after page load (for example in Dev Center) stayed in Recent and Recommended until a reload. Refetch on every open, and filter out entries the backend marks unlaunchable, so a reclaimed or inaccessible app leaves the list too. Apply the same filter to the dashboard's Recent apps. Adds helpers/launchAppsList.js with filterLaunchableApps and its unit test.
* fix(ai): validate large base64 without anchored regexp loops
Gemini TTS replies over ~4 MB of base64 (~65 s of audio) failed with
"Maximum call stack size exceeded". Once a process holds enough compiled
code, V8 compiles new regexps unoptimized, and `^[...]+={0,2}$` then
pushes a backtrack entry per character until its stack overflows.
TTS now searches for a character outside the alphabet instead. The raw
base64 icon/reference-image check drops its own anchored regexp;
decodeStrictBase64 already rejects the same inputs.
* perf(ai): validate base64 by decoded length instead of regexp passes
Unoptimized regexp scans dominated large payloads: 186 ms for max-length
TTS audio, 880 ms in decodeStrictBase64 for 30 MB of video. Buffer.from
skips characters outside the alphabet, so comparing the decoded length
with the one the input length predicts catches them for free.
decodeStrictBase64 now does that plus rejecting the URL-safe alphabet
(~10 ms for 30 MB), and TTS reuses it instead of its own check.
Non-canonical trailing bits now decode instead of being rejected.
* fix(ai): reject non-ASCII base64 that Node decodes by its low byte
Node reads code units above 0xFF by their low byte, so U+012B decodes
as '+' and keeps the decoded length the input predicts. Require ASCII
(~1-2 ms for 43M chars) before the length check.
PROPPATCH now requires write access on the target, like PUT and DELETE,
and its response href goes through the request's share-path masker the
way PROPFIND's do.
Pipeline
- CORS preflights send Access-Control-Max-Age: browsers stop re-preflighting authed calls every 5 s.
- `req.ua` is parsed on first read instead of on every request.
- JSON `rawBody` reuses the parsed buffer instead of copying it.
- `emitAndWait` returns early when nothing listens: no span or closure per unlistened driver call.
- SQLBatcher sends a lone query on the next tick while fewer than 8 flushes are in flight, and
waits `maxTimeInQueue` as before otherwise. Lone reads skip the 10/30 ms wait; batching under
load is unchanged.
Auth and permissions
- Token-to-actor auth starts the user, session and app lookups together and checks them in the
same order: fewer sequential Redis round trips per authed request.
- get-user-app-token and check-app reuse the resolved app row. getOrCreateApp skips the re-select
after its own insert and doesn't wait on the cache write for replica rows.
- getOrCreateApp reads the race winner from the primary when its insert loses, so it no longer
hands out a session that was never written.
- whoami runs the email check and the OIDC/taskbar/team lookups in parallel; key order unchanged.
- OIDC providers are memoized once every discovery succeeds, with one discovery fetch in flight
per URL, and the Apple key is parsed once.
- gui-token and sync-cookie use `req.actor.user` instead of re-reading the user.
- listSessions batches app lookups. App icon probes run together, and the original-icon probe
skips the lineage fallback.
- Default-granted permissions are answered from config before the scan cache, and exploded
options are deduped: one less Redis read per /drivers/call.
- ACL resolves ancestors lazily (owners and full-access tokens skip them) and scans the node
first, then the ancestors concurrently on a miss.
Filesystem
- Missing-path lineage lookups run 10 at a time: batch-upload path resolution drops from about
1.3 s to about 40 ms per 100 new files.
- MySQL single-file inserts return the row built from the written values, falling back to the
primary read: one less primary round trip per new file.
- Batch thumbnails are stored with one UPDATE per chunk (200 rows or 4M chars), with a per-row
fallback.
- storage.quota.bonus listeners run concurrently next to the usage SUM, and the payload carries
`userUuid`: quota checks and /df no longer serialize the listener reads.
- readdir cache backfill is one pipeline instead of 3xN SETEX.
- Profile reads skip the lineage fallback; the own profile is read alongside the user lookup.
- Legacy /stat runs is_empty (new `hasChildrenByParentUid`), owner and thumbnail concurrently.
- Downloads and the video proxy pipe through `pipeToResponse`: aborts release the storage stream
at once with no extra 'close' listeners, and the video proxy no longer crashes on a mid-stream
upstream error.
- mkdir looks up target and parent together.
- Upload completion reuses the session from the access check (status is still re-read before
completing), and multipart signing reads it once.
- Moving a shared file to Trash skips two lookups that always returned the entry itself.
Drivers and events
- Subscription resolvers run concurrently and are folded in registration order.
- SubdomainDriver and WorkerDriver overlap independent lookups.
- The events "anyone subscribed?" check is one Redis pipeline when the region is warm.
- Handler publish overlaps the count with the size check.
- Broadcast flushes peers concurrently over a keep-alive agent, so a slow peer no longer delays
cross-region invalidations to the others.
Sites, share and AI
- Hosted sites resolve the owner first, then the app and root together. Site config loads only
on 404s. The hosted-app query selects only the columns it uses and moves into AppStore.
- shareFlags asks about the plan only when an item is shared by link only.
- Share listings run the count, teams and plan reads alongside their page query.
- Claude attachment cleanup no longer blocks the response.
- New util `awaitLater` for promises started early and awaited later.
* fix(metering): pin metering writes to one replica, fence settles, idempotent monthly claim (PUT-2001)
store-kv-v1 is a last-writer-wins global table, and every region settled
its metering buffer into its local replica, so concurrent increments to the
same per-actor item from two regions could drop one side. Writes and
consistent reads of metering:* and policy:actor:* items now go to the
dynamo.home replica.
Buffered settles record each write on the claim with a fence token before
sending it, and the KV store refuses a token the item already holds, so a
re-driven claim applies its write once. Claims are serialized per counter
within a cache so one writer's tokens land in order.
The monthly charge claim is a set-if-absent of a random token, and a failed
claim write keeps its token for the retry, so a write that landed anyway
still charges exactly once.
* fix(metering): drop home-replica pinning; keep addon consumption off the purchase record (PUT-2001)
Pinning every metering write and exact read to one replica put a
cross-region round trip on credit checks near the allowance and on every
settle, and made one region's outage stall metering everywhere. The
per-item clash it guarded against is rare and undercharges at most a
flush window.
The one clash that loses money is a credit purchase and an overage settle
landing on the addons item in different regions at once. Consumption now
goes to its own item and getActorAddons sums it with what the purchase
record already holds.
* perf(metering): read addon consumption only for accounts with purchased credit (PUT-2001)
* perf(metering): settle counters through a sliding pool instead of waves (PUT-2001)
* feat(metering): consistent-read option on getActorAddons (PUT-2001)
Admin credit checks read the purchase record directly to get a consistent
read, which misses consumption now that it lives on its own item. They can
call getActorAddons with consistentRead instead. Also formats the lines this
PR added.