* Remove the desktop taskbar
The desktop no longer has a taskbar. Windows on /desktop can't be
minimized, since nothing could bring them back; the dashboard still
minimizes into app tiles. The desktop and maximized windows use the
full page height.
Backend: POST /update-taskbar-items is removed, and /whoami and the
login response no longer include taskbar_items. The user table's
taskbar_items column stays until a migration drops it.
puter.ui.createWindow() still accepts show_in_taskbar, which has no
effect and is marked deprecated.
* Keep desktop backdrops above ordinary windows
A backdrop's z-index used to take the taskbar's as a floor, which kept
modal dialogs on /desktop above every ordinary window, including ones
focused after the dialog opened. Keep that floor on the desktop; the
dashboard and fullpage mode stack as before.
Also drops commented-out geometry that referenced window.taskbar_height,
and a dashboard comment about the removed fullpage minimize rule.
* Keep dropped desktop icons fully on screen, and fill the phone popover
A desktop icon drop is accepted only where the whole item fits above the
bottom edge. Phone popovers use the full viewport height.
The desktop no longer shows a wallpaper. It shows the page's plain
background color: white, or #1e1e1e in dark mode. The sign-in dialogs
and account picker that opened over the wallpaper now open over that
color too.
This removes the default wallpaper image and its credit, its preload in
both page shells, the "Set as Desktop Background" item on images'
right-click menu, the GUI's set_desktop_background and
refresh_desktop_background, and the set_as_background i18n key.
Desktop icon labels were white with a shadow to read on the wallpaper;
they now use the same gray as labels inside windows, and a light gray
in dark mode.
The backend drops POST /set-desktop-bg, and /whoami no longer returns
desktop_bg_url, desktop_bg_color or desktop_bg_fit. The user table's
desktop_bg_* columns stay; dropping them needs a migration.
* Remove the desktop's QR login button
The desktop toolbar no longer shows a QR code that signs another device
into the current session. This removes the toolbar button, its click
handler, the UIWindowQR window (its only caller), the qr.svg icon, the
window's CSS, and the four i18n keys only that window used.
UIQRCode and the qrcode library stay, since 2FA setup still uses them.
The ?auth_token= handler in initgui also stays, because the Playwright
e2e harness signs in through it.
* Remove the desktop's My Websites window
The user menu no longer has a My Websites item. This removes the
UIWindowMyWebsites window, its menu item, its CSS, the plug.svg icon only
it used, and the i18n keys only it used.
When publishing a website or worker hits the subdomain limit, the error
now shows only the server's message, without the "Manage your subdomains"
link that opened the window. The trash handlers keep fading the website
badge on a trashed folder and no longer touch the window's rows.
* Remove the desktop's Task Manager window
The user menu no longer has a Task Manager item. This removes the
UIWindowTaskManager window, its menu item, its CSS, the cog.svg icon only
it used, and the i18n keys only it used, including the process_type_*
and process_status_* keys it built at runtime.
The process model in definitions.js, including END_SOFT and END_HARD,
stays: windows still register processes, and a process still forwards
an end signal to its children.
* Remove the desktop's Change Desktop Background window
The desktop's context menu no longer has a "Change Desktop Background…"
item, or the separator above it. This removes the
UIWindowDesktopBGSettings window, its CSS, the palette.svg icon only it
used, and the i18n keys only it used.
Desktop backgrounds still work: the saved background loads at boot, and
an image's "Set as Desktop Background" item still applies and saves it
through /set-desktop-bg.
* Remove the desktop's Start menu
The taskbar no longer has a Start button, so it begins at Explorer. This
removes the Start item and its app popover (recent and recommended apps,
search, tile context menu, and drag-to-taskbar), along with:
- The taskbar's startup /get-launch-apps request and the desktop's
app.opened socket handler, which only kept the menu's lists current
- The Start popover's search, click and keyboard handlers in UIDesktop.js,
keyboard.js, initgui.js and UITaskbarItem.js
- The taskbar's handling of items dropped from outside it, and the
UITaskbarItem append_to_taskbar option only that path used
- The Start CSS, the start.svg icon, and the i18n keys only the menu used
The dashboard's recently used apps still load, since the dashboard fetches
its own list and launchApp.js still records launches. UITaskbar.test.js now
covers the taskbar's item order and that it doesn't fetch launch apps.
* Remove the desktop's Search window
The desktop toolbar no longer has a Search button, and Ctrl/Cmd+F on the
desktop no longer opens the Search window, so it falls through to the
browser's find as it already did on the dashboard. This removes
UIWindowSearch, the toolbar button and its handler, the window's
keyboard handling (arrow keys, Enter, Escape) and click-outside close,
its CSS, the search.svg and magnifier-outline.svg icons only it used, and
the i18n keys only it used.
The backend /search endpoint is unchanged.
* Remove the desktop toolbar
The desktop no longer has the toolbar at the top of the screen, so its
Puter logo, guest Save Account button, and account menu are gone from the
desktop. Log out, account switching, language, settings, and contact all
remain on the dashboard.
Removed:
- The toolbar markup, its auto-hide logic and mouse tracking, its
right-click menu, and the account menu with its click handlers
- The modify-user-options-menu GUI extension, which only added a
Developer item to that menu
- UIWindowFeedback, the desktop's Contact Us window, which only the menu
opened; the dashboard keeps its own contact dialog
- Toolbar hooks elsewhere: the hide_toolbar() call on window maximize,
pointer-events toggles during window and item drags, the click guard in
initgui, the account-button toggle in helpers.js, and the avatar sync
for the toolbar's profile image in TabAccount
- The toolbar and feedback CSS, the toolbar's two KV keys from the
puter.js GUI read cache, and the i18n keys only these used
window.toolbar_height stays: it was already 0, so window and desktop
layout are unchanged. The welcome window keeps its backdrop stacking.
* Remove the desktop's Share window
The right-click menu on desktop and Explorer items, and on items in open
and save file dialogs, no longer has a Share item. This removes
UIWindowShare, that menu item, the share-outline.svg icon only it used, its
CSS, and the share_with and share_row_team i18n keys only it used.
The Dashboard keeps sharing through UIShareModal. generateFileContextMenu
no longer falls back to the removed window, so it offers Share only when
the caller passes onShare, which the Dashboard's Files tab does. The share
dialog layout classes the Blocked Senders window uses, and the recipient
suggestion styles the share modal uses, stay.
* Remove the desktop's Welcome window
New desktop users no longer see the Welcome window on their first visit.
This removes UIWindowWelcome, the first-visit check in UIDesktop that
opened it, its CSS, the welcome_* i18n keys only it used, and the
UIWindow backdrop_covers_toolbar option only it passed.
has_seen_welcome_window is also dropped from the puter.js GUI boot read
cache. Values already saved under that key stay in KV, unread.
* Replace the desktop's color picker with puter.js's picker
puter.ui.showColorPicker() inside Puter now opens puter.js's own
<puter-color-picker>, the one it already shows on websites outside Puter,
instead of the desktop's iro-based window. Apps get the same answer
everywhere: a lowercase 6-digit hex string, or null when the user cancels.
Inside Puter it used to resolve to an 8-digit hex with alpha, or undefined.
This removes UIWindowColorPicker, UIColorPickerWidget, iro.min.js and its
lib-list entries, and the select_color i18n key only that window used.
The GUI loads puter.js from js.puter.com, so the IPC handler doesn't rely
on this branch's component: it drops the alpha from an 8-digit default,
leaves out a default that isn't a 3- or 6-digit hex, and removes the
picker once it answers.
<puter-color-picker> drops the alpha from an 8-digit default instead of
failing to open, opens on its own color when the default isn't valid,
and removes itself when Escape closes it. showColorPicker()'s type and
docs now give the 6-digit return value and null on cancel.
* Remove the desktop's Publish Worker window
The right-click menu on .js files on the desktop and in Explorer, and in
open and save file dialogs, no longer has a Publish as Worker item. This
removes UIWindowPublishWorker, that menu item, its CSS, and the
pick_name_for_worker i18n key only it used.
The Dashboard keeps publishing workers through its own dialog.
generateFileContextMenu no longer falls back to the removed window, so it
offers Publish as Worker only when the caller passes onPublishWorker,
which the Dashboard's Files tab does.
* Add a Language setting to the dashboard's Account tab
The desktop toolbar's account menu was the only place to change Puter's
language, so removing the toolbar left no way to change it. The Account
tab now has a Language card that shows the current language and opens a
dialog listing every supported language by its own name. Saving a new
one stores it on the account and reloads the page in it.
changeLanguage() now returns false when the language can't be saved, so
the dialog can show an error instead of doing nothing. The
change_language key comes back in every locale, and
language_save_failed is new.
* Remove the unused theme dialog and its translations
UIWindowThemeDialog has had no importers since the settings window was
removed, and the reset_colors, ui_colors and change_ui_colors strings
are referenced only by it or by nothing at all.
* Remove the Open Desktop card from the dashboard Home tab
Drops the card's markup, styles, color tokens, and the open_desktop and
open_desktop_hint strings, which nothing else uses.
* feat(gui): let a first-party page frame the custom domain dialog
`/embed/attach-custom-domain` joins the framed popups. On a signed-in page
one label under the Puter origin, `puter.hosting.attach()` now shows the
custom domain dialog in a frame over the page instead of a popup window.
Apps, the desktop and every other website keep their current behavior.
The frame covers the page with a transparent GUI page, so the dialog
floats over it with its own scrim as it does on the desktop. The GUI
serves only the framing page's own user, with no account check waiting,
and otherwise reports `unavailable`, which leaves the page to open the
popup as before. It runs the popup's action unchanged: the framing page
is the requester the dialog names, and gets the same
`customDomainAttached` answer.
Escape is left to the dialog, which doesn't close while a request is in
flight, and the frame allows `clipboard-write` for the DNS record copy
buttons. A call made while the frame shows resolves null without
replacing it, as a second call does on the desktop.
* Update RecommendedAppsService.ts
* fix(gui): let the framed custom domain dialog finish closing before it answers
The answer removes the frame from the page, so the dialog's exit transition
never showed. The framed action waits for the overlay to leave first, with the
wait the feedback embed already had, now one shared helper.
JSON request bodies over 32 KiB are parsed in slices, with the event loop
running between them, instead of in one JSON.parse call. Reading, limits,
decompression, req.rawBody and the errors stay as express.json has them.
Large bodies parse one at a time.
Gemini TTS and video replies use the same parser, and their base64 is
checked and decoded 1 MiB at a time.
A profile picture is sized before its pattern runs, so one of several
megabytes gets a 413.
Body parsers skip the dav subdomain: a PUT labeled as JSON or form data
was read by them and stored empty, or refused.
puter.js sends auth_token as the first field of a driver call. A chat body
over 50 MiB that opens with a valid token is accepted up to 128 MiB, as
one with a header credential is.
JSON in a charset other than UTF-8 is now limited to 1 MiB, and a body
over 32 KiB nested deeper than 10,000 levels is refused.
Two writers that both start before the row exists each upload under their
own object key. The second one found the first writer's row and updated its
bucket and size while keeping the first writer's uuid, so the row could name
an object that was never written under that key.
The store now leaves a row untouched when an overwrite arrives with a
different object key, and the service moves the uploaded bytes onto the
row's key before updating it. The last writer wins and the uuid is stable.
- drivers: a null `args` in a driver call is treated as no arguments
- ai-chat: a `model` that is not a string is answered as model not found (400)
- apps: app stats split a long uid list across ClickHouse queries, which cap the size of one query parameter
The Usage tab told an account how much add-on credit it had left in a
line of text under the monthly meter. It now gets a meter of its own
there: a heading, how much of the credit bought has been spent, and a
bar, with the line saying what is left and when it is used kept under
it. An account that never had add-on credit sees the tab as before.
* feat(gui): let a first-party page frame the deployment's top-up dialog
`/embed/top-up` joins the framed popups. Like the upgrade, it covers the
framing page with a transparent GUI page, serves only the page's own
user, and reports `unavailable` unless the deployment defines
`window.UITopUp` with `embeddable === true` and no account check is
waiting. It runs `new UITopUp({ onClose }).open()` and posts
`closed { toppedUp }` once the dialog closes. The frame's lifecycle is the
upgrade's, now shared by both.
`<puter-user-menu>` gets an Add credits item under Usage, shown only when
whoami reports `subscribed: true`. It frames the top-up on a page that can
frame the GUI, and otherwise opens the dashboard with `?topup=` in a new
tab, within the click. Hidden items are skipped by the menu's arrow keys.
`puter.ui.showTopUp_()` is internal; no public SDK method is added.
* fix(puter-js): show no usage-limit dialog over an open top-up
A call refused for funds while the framed top-up was open put the
usage-limit dialog in the top layer over it, covering the payment the
user was making to clear that refusal. The prompt now stays out of the
way while a top-up shows, as it does for the framed upgrade.
* fix(puter-js): leave Add credits out of the user menu for a team seat
whoami reports a seat as subscribed through its team's plan, so the menu
offered Add credits to an account that can't buy any: the dialog only
answers that the team pays. The item now stays hidden when whoami names a
team paying for the account, as the desktop's own menu already does.
* fix(puter-js): open the top-up tab on the page's own deployment
The tab the Add credits item falls back to went to puter.com whatever
GUI origin the page belongs to, so on another deployment it opened a
different one, signed in as whoever is signed in there. It now opens the
dashboard of the GUI origin, like the menu's Usage link beside it.
* fix(puter-js): return keyboard focus to the account button after Add credits
Choosing Add credits closed the menu with focus on the item it had just
hidden, and the frame restored focus to the menu's shadow host, which
can't take it, so a keyboard user was left on the page body once the
dialog closed. The menu now moves focus to its button first, and a
framed popup restores focus to the element inside a shadow root.
* docs(embed): say when the top-up dialog's open() resolves
It resolves after the account has loaded, not as soon as the dialog
shows.
* fix(puter-js): offer Add credits after an upgrade made on the page
The user menu showed Add credits by the whoami it loaded with the page.
A user who subscribed in the framed upgrade therefore found no Add
credits in the menu until a reload. A framed upgrade that reports a plan
now marks the page's cached whoami as subscribed, as a confirmed email
already is, and the menu reads it again each time it opens.
* fix(puter-js): let a top-up that failed to start be asked for again
showTopUp_ cleared its in-flight marker in a finally inside the async
function it stored. A failure before that function's first await, such
as building the dashboard address from an origin with no scheme, ran the
finally before the marker was set, so the marker kept the rejected
promise for good: every later call got the same rejection, and
topUpShowing_ went on suppressing the usage-limit dialog. The marker is
now cleared once the stored promise settles.
* fix(puter-js): keep requestUpgrade from framing the upgrade over an open top-up
The usage-limit dialog already stays away while the top-up shows, but a
page's own requestUpgrade() call, such as one in the handler of a call
refused mid-payment, still mounted the upgrade frame. Frames are single
per path, so it covered the top-up and the payment being typed in it,
and the two dialogs' own guard can't see across documents. The call now
waits for the top-up and resolves { upgraded: false } once it closes.
* fix(puter-js): offer Add credits after an upgrade when whoami was read again
The user menu keeps the whoami object it loaded and ignores a token set
again unchanged, but the SDK reads whoami into a new object each time a
token is set. A framed upgrade then marked the new object as subscribed
and the menu went on reading the old one, so Add credits stayed hidden
until a reload. The menu now reads the SDK's cached whoami when it is
the same account's.
* docs(embed): list the top-up tests among the checks to run
* fix(explorer): record typed address-bar paths in navigation history and canonicalize them
Pressing Enter in the path input now pushes the destination onto the
window's Back/Forward history like every other navigation, and resolves
the entry first (trim, ~ and home-relative expansion, trailing or doubled
slashes) so the window's path matches the form the rest of the GUI compares
against.
* fix(explorer): keep the address-bar input in step with the Shared view and the root
The path input was only updated after a successful stat, so in views that
are never statted it kept the previous folder's path and Enter navigated
back there.
* fix(explorer): drop the previous folder's identity when a folder fails to load
A rejected stat was never handled, so the window kept the previous folder's
uid, title and item count while showing the load error, and layout or sort
changes were saved to the wrong folder. The window now clears them, and the
failed request no longer surfaces as an unhandled rejection.
* fix(explorer): release a window's selection listeners and history when it closes
Each folder window left its rubber-band selection instance, a document
mouseup handler and its navigation history behind after closing, so every
later mouse press ran once per window ever opened. Windows that are not
folders no longer bind the handler at all.
* fix(explorer): anchor a Ctrl/Cmd rubber-band selection where the drag started
The rectangle's origin was only recorded for unmodified drags, so adding to
a selection drew it from the previous drag's start point or the top-left
corner.
* fix(explorer): keep arrow-key navigation on visible items and a valid anchor
Arrow keys could land on a hidden dotfile and then stop moving, threw when
the remembered anchor belonged to another window, and left the footer's
selected count stale.
* fix(explorer): ignore non-character keys in type-to-select
Shift, arrows and Enter were appended to the search term by name, so typing
a capital letter or typing right after navigating matched nothing. Hidden
files are no longer matched, and the footer count follows the container the
selection changed in.
* fix(explorer): make the keyboard permanent-delete confirmation work in every language
The confirmation compared the dialog's answer with the English button label,
so in any other language confirming did nothing.
* fix(explorer): paste once per Ctrl+V press
The shortcut ran on both keydown and keyup, so releasing V before Ctrl
pasted the clipboard twice.
* fix(explorer): keep selection shortcuts working after navigating, and out of text fields
Select All, Copy, Cut and Delete looked up the folder from the element last
pressed, which is a removed item or a sidebar entry after navigating, so the
shortcuts did nothing. Select All also skips hidden files, and Delete keys
typed in a text field are left to the field.
* fix(explorer): count only visible items in the footer and drop hidden files from the selection
The item count included dotfiles that were not shown, and hiding hidden
files left them selected, so a later Delete or Cut acted on files the user
could not see.
* fix(explorer): paste cut items after their folder is no longer displayed
Paste resolved the cut paths to rows on screen, so cutting a file and then
navigating the same window to the destination moved nothing and silently
emptied the clipboard. Items without a row are now looked up and moved the
same way.
* fix(explorer): show a moved item once in its destination
The tab that moves an item renders the row and also receives the move
event, so the destination showed the item twice whenever the event arrived
first. Both paths now replace any row the other already rendered.
* fix(explorer): sort names the way the dashboard does, with a stable tie-break
Folder windows and the desktop compared raw lowercase strings, so file10
came before file2 and accented names landed after z, and equal sizes, dates
or types kept whatever order they arrived in. They now use the dashboard's
comparator, so the same folder reads the same in both.
* fix(explorer): keep the Trash window's states when Trash is emptied elsewhere
Emptying Trash from another tab cleared the whole container, including its
empty, error and loading elements, and left the item count stale.
* fix(explorer): remove rows deleted elsewhere instead of hiding them
A deletion from another tab or an app only faded the row out, so it still
counted in the footer, in Select All and in keyboard actions. The row is now
removed, and windows showing the deleted folder close even when its own row
is not displayed anywhere.
* fix(explorer): refresh size and date when a file is overwritten elsewhere
An overwrite arrives as an update event, which only applied the name, icon
and path, so the size and modified columns and sorting kept old values.
* fix(explorer): download files whose names contain &, #, % or +
The path went into the download URL unencoded, so the server received a
truncated path and the download failed.
* fix(explorer): allow moving into folders whose name starts with Trash
The deleted-folder check matched any sibling of Trash sharing its prefix,
so moving into a folder such as Trash-old was refused.
* fix(explorer): end the extract operation when an archive is corrupt or has no files
A corrupt zip threw before its error could be shown, and an archive with
nothing to write or a failed upload never closed the progress window. A
progress update arriving before the window existed also threw.
* fix(explorer): follow renamed and moved folders in Back/Forward and on undo
A window's navigation history kept the old path after a folder was renamed
or moved, so Back led to a load error. Undoing a folder rename also left
windows and items inside it on the renamed path, because the undo retargeted
from the path it was restoring.
* fix(explorer): keep one progress window per copy or move
The progress timer is re-armed after every conflict dialog, and it opened a
second window when one was already showing, leaving the first on screen for
good.
* fix(explorer): show file names once-escaped in move alerts
The name was escaped before being passed to an alert that escapes its whole
message, so names with &, quotes or angle brackets showed entity text.
* fix(explorer): don't flash the empty-folder message while a folder refreshes
Clearing the rows for a refresh showed the message until the listing came
back, even for a folder full of items.
* fix(explorer): accept drops into a window that covers a desktop folder
A desktop folder hidden under a window still reacted to the drag and
disabled the window as a drop target, so the drop was ignored. A drop that
bailed out early also left every folder view disabled as a target until the
next hover; targets are now restored when the drag ends.
* fix(explorer): release each row's document click handler when the row is removed
Every file row bound a click handler on document that was never unbound, so
browsing folders grew the number of handlers run on every click and kept the
removed rows alive.
* fix(explorer): let Delete work on a file after its rename was cancelled
Cancelling a rename with Escape, or leaving the editor without a change,
left the item marked as being renamed, and the Delete shortcut skips such
items.
* fix(explorer): offer a download when no app can open a file
The prompt called a translation import that is not a function, so opening
a file with no suitable app threw and did nothing.
* fix(explorer): use an item's current path and name in its context menu
Cut, Copy, Download, Create Shortcut and the rename editor read the path and
name captured when the row was created. After a rename made from another
window or tab they acted on the old path, and closing the editor repainted
the old name.
* fix(explorer): offer Cut only where the item can be moved
The item menu on the desktop and in folder windows offered Cut on trashed
items and on shared items the user may not move, unlike the same menu built
for the dashboard.
* fix(explorer): give a folder named *.app a folder icon
The app-shortcut branch ran before the directory branch, so such a folder
was read as a file on every listing, failed, and showed the app icon.
* fix(explorer): make New > JPEG Image create an actual JPEG
The blank image was encoded with the canvas default, so the .jpg file held
transparent PNG data.
* fix(explorer): handle a drop onto a folder that is part of the dragged selection
Dragging a selection onto one of its own selected folders also tried to move
that folder into itself. On the desktop the drop also cleared the saved
position of the target folder instead of the items that left the desktop.
* fix(explorer): don't highlight a window as a drop target for its own items
The check meant to skip the folder an item is already in compared the wrong
path, so dragging inside a window outlined it although dropping there does
nothing.
* fix(explorer): keep desktop rearranging to the desktop itself
Dropping an item from a window showing the Desktop folder onto the desktop
background positioned the window's row absolutely and saved that position.
* fix(explorer): empty Trash from its window in one request
The Trash window's own Empty Trash sent one delete request per item and
reported Trash as empty before any of them finished. It now runs the same
single operation as the taskbar and desktop.
* fix(explorer): label the root and Shared in the Back/Forward menus
The long-press history menus showed an empty row for the root folder and a
lowercase path segment for Shared, and the Forward menu's Home entry was not
translated.
* fix(explorer): keep sidebar labels translated and titled after reordering
A reordered sidebar rendered the labels saved at the time of the drag, so
they stayed in the old language, and every item's tooltip was empty.
* fix(explorer): enable the Open dialog's button only for a selected file
The button stayed enabled after navigating to another folder, and a
rubber-band selection of folders alone enabled it, although clicking it then
did nothing.
* fix(explorer): show the slow-load message only for a load that is slow
Once any folder took over a second to load, the spinner text switched to
the slow-load message three seconds later even after the load finished, and
every later load in that window opened with it. The text is also translated
now.
* fix(explorer): keep the current folder visible in a long breadcrumb
A path too long for the bar was cut at its end, hiding the folder being
viewed and its nearest parents. It is now cut at the root end.
* fix(explorer): grey out the whole selected row in an unfocused details view
Only the name lost its highlight when the window lost focus, leaving a grey
pill inside a blue row.
* fix(explorer): align the details header with its rows and mark it clickable
The header ended 10px before the rows and the selection highlight, and its
sortable column titles kept the default cursor.
* fix(explorer): give the layout button a tooltip and a pointer cursor
It was the only navbar control without either, so it did not read as
clickable.
* fix(explorer): remove the stray tooltip from the loading spinner
The spinner's SVG carried an internal title that showed as a tooltip and
was read out by screen readers.
* fix(explorer): refuse uploads, copies and shortcuts into the Shared view
Dropping files on Shared in the sidebar, or Ctrl/Alt-dropping items onto a
window showing it, sent requests against a path that is not a directory and
ended in an error or a stuck progress window.
* fix(explorer): open an item's context menu where it was requested
The menu is built after permission and app lookups, and it opened wherever
the pointer had moved to by the time they finished.
* fix(explorer): close a folder's window when the folder is deleted, however it was opened
Windows opened by path, such as with Ctrl-click on a breadcrumb or sidebar
entry, never received the marker used to find a deleted folder's windows,
so they stayed open on a folder that no longer existed.
* Send one request, not four, after the account pick in the sign-in popup
A sign-in popup exchanges for the opener's app token as soon as the user
picks an account. postAuthActions now hands over that result when the
opener origin and session token still match, and exchanges again otherwise.
The relationship check is skipped when an account was picked, since the
pick already decides both the second picker and consent. Signup in the
popup is not a pick and still runs it.
The /whoami read before the picker is reused only when the same user and
session token are still in place, the exchange just accepted that session,
no verification gate is pending, and the read is under a minute old.
* Test picking a different account in the sign-in popup
The popup has to read the picked account once and hand over a token for
it, not for the account it opened with. The second-account helper moves
to the shared e2e helpers so both specs that need one use the same copy.
The `puter-apps` `select` method returned every app that was not
protected unless the caller sent `predicate: ['user-can-edit']`. It now
always scopes to the caller's own apps. The predicate is still accepted
and no longer changes the result.
Apps owned by someone else stay reachable by name or uid through `read`
and `/query/app`.
`AppStore.count` drops `visibleToUserId`, which only the unscoped
listing used.
* Reload a tab when another tab switches to a different account
Every tab shares one stored session token. A tab that sees a new token
appear in storage adopted it whoever it was issued to, so after an account
switch in one tab the others kept showing the previous account while
sending requests as the new one.
A tab now reads the user out of the stored token. A token for the account
it already shows is adopted as before. A token for a different account
reloads the tab, which comes back up as that account. A tab with no
signed-in user, or a token whose user can't be read, behaves as before.
* Store a new account's token only after its session cookie is set
A tab that sees another account's token in storage reloads and signs in
from the session cookie. update_auth_data() stored the token before it
synced the cookie, so a tab that reloaded faster than the sync came back
up as the previous account and stored that account's token again, which
the switching tab then adopted under the new account.
The token is now stored after the cookie sync, in the same task as the
stored user.
* Keep the new account's token when another tab stores one during the cookie sync
update_auth_data() sets window.auth_token, then waits for the session cookie sync. A token another tab stored during that wait was adopted over it, so the tab finished the switch holding the previous account's token and saved it under the new account in logged_in_users. The token is set again once the sync has settled.
* Create one visitor account for the account switch e2e tests
Each test signed up its own visitor, and sign-ups are limited to 10 per 15 minutes, so a few runs in a row failed in setup.
* Show a shared item once, with its strongest access, when it has more than one share
The Shared listing returns one row per grant, so an item shared by two
people, or directly and through a team, appeared twice and took its
actions from whichever grant was listed last. The GUI now merges the
rows for an item and keeps the strongest mode.
* Keep a shared item under each person who shared it in the people filter
A merged row carried only the strongest grant's issuer, so filtering the
Shared view to someone whose grant was the weaker one hid an item they
shared, and their chip left it out of its count. A merged row now lists
every issuer. The filter and its counts match any of them, Everyone
counts the item once, and the Shared by tooltip names them all.
* Keep an item listed after Remove from Shared when a team still shares it
Leaving an item drops the grants made to the user directly. One a team
also holds stays shared, but the desktop took its icon away until the
next refresh and the dashboard removed the row before re-listing it. A
merged row now records that a team holds the item too. The desktop
re-lists the Shared folder so the item shows as the team share it has
become, and the dashboard leaves the row in place for its refresh.
* Show an item as shared by the person the Shared view is filtered to
An item several people shared showed the strongest grant's issuer in
the Shared by column even when the listing was filtered to one of the
others. A filtered row now shows the person filtered to, which also
keeps the Shared by sort in step with what the column displays.
* Match desktop items and windows by their path value, not by a selector
Path lookups on the desktop compare data-path values through one helper, so
a name holding &, ' or " is found like any other. Explorer windows keep the
raw path in data-path after navigating, the same as when they open.
* Read data-path straight from the element in path lookups
atPath() and underPath() built two jQuery objects for every element they
looked at. On a desktop with 5,000 items a lookup took about 1.4 ms,
against 0.08 ms for the selector it replaced. Reading the attribute from
the element brings it to about 0.3 ms.
* Match no element when a path lookup has no path
atPath() with a missing path matched every element whose attribute is
empty, which is every item that is not a shortcut and every app window.
An item.moved event without old_path would point all of those items at
the moved file. underPath() with an empty path matched every path.
* Use atPath() to find the items a share badge belongs to
mark_item_shared() had its own copy of the same lookup.
* Keep the raw folder name in an explorer window's data-name
update_window_path() and refresh_item_container() wrote the HTML-encoded
name there, so after navigating into "R&D" the tab title and the publish
dialog showed "R&D". A newly opened window already held the raw name.
* Answer requestEmailConfirmation() once, and show it in a frame on first-party pages
puter.ui.requestEmailConfirmation() in an app opened a second code dialog
after the first: a confirmed user got a dialog they couldn't complete and
two answers, and an unconfirmed one entered the code and then got a second
dialog, whose result was the only answer. The desktop handler now runs one
walk-through and answers once with its result.
That walk-through is ensureConfirmedEmail: save a temporary account, then
ask an unconfirmed email for its code, each window at most once. Publishing
a website or worker uses it too, replacing two copies of the same steps.
The code window now resolves false when it closes without a confirmation,
so dismissing it answers the app instead of leaving it waiting, and it
marks window.user confirmed before resolving, so a caller that checks next
doesn't ask again before the refresh lands. A logout from its footer still
leaves it unsettled, so the boot gates don't reopen it. In the dashboard
the windows open above the app's window, which stays on top and covered
them.
On a signed-in page one label under the Puter origin, the call now frames
/embed/request-email-confirmation over the page, served like the upgrade
frame, and resolves { response }, the field an app reads from the
desktop's answer. The GUI shows it only for the framing page's own user
with no phone, card or password check waiting. It skips the boot's
non-closable email gate for this action, so the user gets one code window
they can close or Escape. An email already confirmed resolves true without
showing the frame. A frame that can't show it resolves { response: false }.
* Skip the email confirmation frame when the page knows the email is confirmed
A confirmed email never goes back to unconfirmed, so requestEmailConfirmation()
on a first-party page resolves { response: true } from the page's whoami
instead of booting the GUI in a frame behind a loading scrim.
* Close the framed email confirmation on Escape in the save-account window
A temporary account sees the save-account window first, where Escape did
nothing, while it closed the code window that follows.
* Let the email code window recover from a failed code check with no response body
A network failure has no JSON body, so reading its error threw, leaving the
window spinning with its inputs disabled. It now shows an unknown-error message
and lets the user try again.
* Answer an email confirmation asked for while one is showing with the windows already open
A second walk-through opened a second code window over the first, and for a
temporary account it never answered, since the save-account window opens once.
* Read the email code from the code window it was typed in
A second code window read the inputs of the first one on the page, so typing
in it never filled in the code or enabled its button.
* Keep the email code window's answer in step with a code check it was closed during
Closing the window while a code was being checked answered false, and the
check went on to confirm the email. A code not sent yet is no longer sent, and
the window waits for one already sent before it closes.
* Show why a new email code couldn't be sent
A failed re-send, such as one past the hourly limit or a network failure,
showed nothing, so the link seemed to do nothing.
* Skip the email confirmation frame on calls after the user confirmed in one
The page's cached whoami kept reading the email as unconfirmed, so each later
call framed the GUI again and flashed its loading scrim before resolving true.
* Leave the email code window unsettled when any logout closes it
Only a logout from its footer did. One from elsewhere, such as after a 401,
answered false, so the boot's email gate opened a new code window while the
page was navigating away.
* Hide the save-account window while its email code window shows
A save-account window with a backdrop stayed under the code window's own, so
the page behind was dimmed twice, and on a phone the window showed around the
code window's edge.
* Answer a save-account request made while the save-account window is open
The window opens once, so the second request got no window and never
answered, which left an email confirmation that asked for it waiting too.
* Answer a framed email confirmation closed with Escape with the code check it was closed during
Escape in the framed code or save-account window closes that window, and the window's answer closes the frame. A code already sent is waited for, as it is when the window's close button is used. Before, Escape closed the frame and answered false while the check went on to confirm the email.
A file row without a stored region is read with the server's configured
region. When its bucket lives elsewhere, S3 answers 301 PermanentRedirect
and the read failed with a 500. The SDK now retries once in the region the
redirect names.
File, directory and save pickers opened in a popup now post their results,
cancellations and the save-data request to window.openerOrigin instead of
'*', matching the token hand-off. A test checks that opener messages in the
GUI only target '*' when the opener's origin is not yet known.
* Show the upgrade in a frame on first-party pages
puter.ui.requestUpgrade() on a signed-in page one label under the Puter
origin now frames /embed/upgrade over the page and resolves { upgraded }
once it closes. The usage limit dialog's Upgrade button does the same on
those pages instead of opening the dashboard in a new tab. Apps, the
desktop and every other page keep their current behavior.
/embed/upgrade is served like the sign-in and picker frames: the GUI
shell with the same framing rules, query and exact-path redirects and
no-store. The GUI shows the deployment's upgrade dialog
(window.UIUpgradeAccount) only for the framing page's own user, with
nothing left to verify, and only when the dialog says it reports its
close (UIUpgradeAccount.embeddable). Otherwise it reports unavailable and
the page opens the dashboard upgrade in a tab, through a consent dialog
when the click has expired. The app it credits is resolved from the
framing page's origin, never read from the fragment.
The frame covers the page with a transparent GUI page so the dialog's own
scrim shows, allows payment, and leaves Escape to the dialog. A call made
while it shows settles with it. If every dialog and window in the frame
goes without a close being reported, the frame closes itself rather than
staying over the page.
* Open the framed upgrade only once the frame has a viewport
The page keeps a loading frame hidden, which Chromium and WebKit lay out at
0x0. The team-seat alert, a GUI window that centers itself when it opens,
landed at (-175, 0) with its message and OK button cut off. A hidden frame
now tells the page it's ready first, and opens the dialog on the resize that
follows, or after a second if none comes.
* Document when the framed upgrade posts ready
* Retry a framed popup's focus() that the frame ignored
Firefox and WebKit ignore focus() inside a frame for a moment after the
page shows it, and Firefox sends no focus event afterwards, so the framed
upgrade dialog and its team-seat alert opened with nothing focused there:
Enter didn't press OK and screen readers didn't land in the dialog. A
focus() that didn't take is now retried briefly, until the user presses a
key or the pointer.
* Keep the framed upgrade's GUI in the light color scheme
The transparent upgrade frame put `color-scheme: light dark` on body as
well as the root, so in dark mode form fields the GUI leaves at browser
defaults turned dark. The email confirmation code digits showed slate
text on a dark grey field inside a white window. Only the root needs to
match the frame's scheme to stay transparent; body now keeps the light
scheme the GUI uses everywhere else.
* Fall back to the upgrade tab when the framed dialog fails to open
If the deployment's upgrade dialog threw while opening, the frame reported
a close, so requestUpgrade() resolved { upgraded: false } and the user's
click did nothing. It now reports itself unavailable, and the page opens
the dashboard's upgrade page as it does for any frame that can't show it.
* Say which plan changes count as upgraded in UpgradeResult
The upgrade dialog sets `upgraded` only for a subscribe or an upgrade;
a downgrade, cancellation or resume closes with false. The type said any
plan change counted.
* Stop the page scrolling behind a framed GUI embed
Firefox and WebKit pass a wheel or touch scroll over a frame on to the
page behind it, whatever the dialog's overscroll-behavior, so scrolling
over the framed upgrade, sign-in or a picker scrolled the page under the
scrim. The page's root now stops scrolling while an embed shows, taking
a classic scrollbar's width as padding so its content doesn't shift, and
gets its own styles back once the last embed closes.
* Skip the usage limit dialog while the framed upgrade shows
A refused call while the upgrade shows in a frame over a first-party
page opened the usage limit dialog on top of it, which made the upgrade
dialog inert until the user closed it. The refusal still rejects.
* Restore the page's root styles after an embed the page removed
An embed dialog the page removed itself never unlocked the page scroll,
and the next embed read the locked styles as the page's own, so the
page stayed unscrollable once it closed. The page's styles now go back
before the next lock reads them.
* Open the upgrade tab when the page can no longer frame it
The usage limit dialog decided to frame the upgrade when it opened, so
an Upgrade click after the page signed out did nothing. onUpgrade now
returns false when the page can't frame it, and the dialog opens the
dashboard tab as it does without onUpgrade.
* Keep the upgrade showing until its tab consent is answered
When the framed upgrade fell back to a consent dialog, requestUpgrade
settled as soon as the dialog showed. A refusal meanwhile opened a usage
limit dialog over it, and a second call asked again. The upgrade now
counts as showing until the user answers, so both join it.
* Say where requestUpgrade never settles
Its JSDoc named only apps, though on any website that can't frame the
upgrade the promise doesn't settle either.
* Treat a GUI embed the page removes as dismissed
A page that re-renders its body took the embed's dialog with it, and the
embed's promise never settled. For the upgrade that left it counted as
showing for good: later requestUpgrade() calls joined the dead one, no
refused call opened the usage limit dialog again, and the page stayed
unscrollable.
* Let the framed upgrade credit the remembered prompt when it knows none
When the framing page's origin resolved to no app and the call named no
reason or method, the frame handed the dialog an empty attribution. The
dialog then remembered that as the latest prompt, erasing another app's
credit, and sent the purchase with no attribution at all. It now gets
null, as from every other entry point.
* Pad the page by the width its scrollbar gives back behind an embed
A page with scrollbar-gutter: stable keeps its gutter once the root stops
scrolling, so padding it by the scrollbar's width as well shifted its
content left while any GUI embed showed. The lock now measures how much
width the root actually gets back.
* Keep the page's scroll position behind an embed when its body scrolls it
A page whose body carries the viewport's overflow (html and body at 100%
height, body with overflow-x hidden) jumped to the top while an embed
showed, and stayed there after: hiding the root's overflow made the body
a scroller of its own. The lock now hides the overflow of whichever
element the viewport uses, and restores a single-axis inline overflow.
* fix(dashboard): middle-truncate long names in the default Files list view
In the list view's default auto-width name column, a long name was cut at
the end by CSS with no tooltip, so names that differ only near the end
("report … (1).txt", "report ….txt") looked identical and the extension
was hidden. The name is now cut in the middle to the room its cell has,
like a resized column and grid tiles already are, and the full name shows
on hover. The phone layout's two-row rows get the same treatment.
* fix(dashboard): toast the newest notifications of a catch-up burst
A burst of more than four notifications toasts three and folds the rest
into an "N new notifications" summary. The split kept the first three it
was given, assuming newest first, but the reconnect catch-up and the
events-feed replay hand notifications over oldest first, so after a tab
woke up the toasts showed the oldest three and hid the newest. The split
now sorts the burst itself.
* fix(dashboard): act on a regrouped notification's current content when its toast is clicked
When more shares from the same person fold into a notification that's
already toasted, the toast's text updates ("alice shared 3 items") but
its click still acted on the notification as first shown, so it selected
only the first file instead of opening Shared. The click now looks up the
notification's current content.
* fix(dashboard): show browsers on Puter's own Web Push as browsers in Your devices
A device registered through Puter's own Web Push client reports platform
"webpush", which the Notifications tab didn't know: it got the phone icon
and no "Web" label. It's now treated like the other browser platform.
* fix(dashboard): clear the push preference's load error once it loads
If the Puter notifications preference couldn't be read, its card's hint
was replaced with a load error. Coming back to the tab once the read
worked re-enabled the switch but left the error text in place. A
successful read now puts the hint back.
* fix(dashboard): keep push off for this browser when removing it half-fails
Removing "This browser" from Your devices turns its push off and then
deletes the server record. If the delete failed (a dropped connection),
the handler returned before saving the opt-out, so the card still read
"on" and the next dashboard load quietly turned push back on. The opt-out
is now saved as soon as push is off, and the card is redrawn on failure.
* fix(dashboard): don't let an older device or app listing redraw a removed row
The Notifications tab lists devices and apps each time it opens. If one
of those listings was still in flight when the user removed a device or
turned an app off, it landed afterwards and drew the removed row again.
Only the newest listing draws now, and a removal outdates any listing
already in flight, as the browser card already did.
* fix(dashboard): report the items that were shared when a large share fails partway
Sharing more than 50 items sends one request per 50. If a later request
failed (the daily share limit, say), the items earlier requests had
already shared were forgotten: the dialog showed a flat failure and none
of their rows got the shared badge until a reload. The dialog now says
"Shared with bob on 50 of 60 items" along with the error, badges those
rows, and refreshes the shared listing.
* fix(dashboard): drop the site address from files inside a disassociated folder
Disassociating a folder's website from its Properties cleared the
folder's own badge, but the files inside it kept their website-address
badge and URL, which now led to a site that no longer exists. Their
addresses are now worked out again without that site, so a file still
served by a site on a folder within keeps its address.
* fix(dashboard): keep an app's own "(N) " title prefix beside the unread badge
The dashboard puts the unread count in front of the page title and keeps
it there as app windows rewrite the title. It removed any leading
"(N) " before adding its own, so an app titled "(3) Inbox" lost its
count ("(1) Inbox"). Only the badge the dashboard itself put there is
replaced now ("(1) (3) Inbox").
* perf(dashboard): redraw the notification list once per burst of acks from other tabs
"Mark all read" in one tab sends the other open dashboards one ack per
notification, and each ack rebuilt the whole list. With 150 unread that
was about half a second of main-thread work in every other tab (more on
slower devices). Acks and removals from other tabs now redraw once per
50ms beat: 150 acks take about 5ms.
* fix(dashboard): let the keyboard reach "Also delete data" in the Uninstall dialog
The Apps tab's dialogs keep Tab inside the card, but the trap only
cycled buttons and text areas, so Tab and Shift+Tab bounced between
Cancel and Uninstall and the delete-data checkbox couldn't be reached
without a mouse. Inputs, selects and links are part of the cycle now.
* fix(dashboard): don't leave the app request form on an IME composition's Escape
In the "Request an app" form, Escape steps back to the options. While
typing with an input method (Chinese, Japanese, Korean), Escape is how
the composition is cancelled, and that Escape also left the form. It's
now ignored while composing, as the Apps grid and folder name already do.
* fix(dashboard): keep focus out of an Apps folder while it closes
A folder's card stays on screen for its 240ms close animation. During
that time its tiles could still take focus: an arrow key pressed right
after Escape moved into the closing card, and focus then fell to the
page when the card was removed, losing the user's place. The closing
card is now inert and the arrow keys skip it.
* fix(dashboard): save the Apps layout one write at a time
The app order and folders were saved with a write per change, without
waiting for the one before. Changes close together (ejecting an app from
a folder saves, and the drop right after saves again) put two writes of
the same key in flight, and if they landed out of order the older layout
was what the next load showed. Each key now has one write in flight at a
time, and the next carries only the newest layout.
* fix(dashboard): close an open dialog when browser Back switches tabs
Back and Forward between dashboard tabs closed the drawer, menus, Share
and Properties, but not the dashboard's dialogs (Edit Profile, Change
password, confirmations) or the photo crop. The tab changed underneath a
dialog that stayed up with focus inside it. They now close through their
own close buttons, like the rest.
* fix(dashboard): count the trial countdown in calendar days
The Home tab's trial badge rounded the time left up to whole 24-hour
blocks, so it disagreed with the date shown under it: a trial ending at
3pm today read "1 day left" beside today's date and never "Ends today",
and one ending tomorrow morning read "2 days left". It now counts
calendar days in the user's time zone.
* perf(gui): rewrite paths after a username change only on elements that carry one
Changing the username from the dashboard's Account tab rewrote paths on
every element in the document (the selector matched them all) and set
every username label once per element, which grows with the size of the
Files listing on screen. It now visits only elements with a path
attribute and sets the labels once. With no earlier username (signing
in) there's nothing to rewrite, which also stops it throwing then.
* fix(dashboard): make long resource names in the Usage table readable
Resource names in Usage details are cut off with an ellipsis, and on a
phone the resource column is the one that shrinks, so a key like
"openai:gpt-4o-mini-2024-07-18:prompt_tokens" read "openai:gpt-4o-mi…"
with no way to see the rest. The full name now shows on hover, and on
phones, which have no hover, the name wraps instead of being cut.
* fix(dashboard): show Usage units in the dashboard's language
In Usage details the Cost column followed the dashboard's language but
the Units column beside it always used English separators, so a German
row read "1,234,567 | 1,23 $", where the units look like 1.2. Units now
use the language's separators too ("1.234.567").
* fix(gui): translate the 2FA setup wizard's step labels and messages
The two-factor setup opened from the dashboard's Security tab had
English hardcoded in its progress steps (Scan, Backup, Verify, Done),
the "Enabling…" spinner, the success message and its Done button, the
copy-secret button's label, the "Copied!" confirmation and the printed
recovery codes' heading. They go through i18n now.
* fix(dashboard): leave Alt and Cmd arrow shortcuts to the browser in Files
Files took every arrow, Home/End and Page key to move the selection,
whatever modifier was held, so Alt+Left (Back) and Cmd+Up/Down moved the
selection instead of doing what the browser does with them. Arrow keys
with Alt or Cmd are left alone now.
* fix(dashboard): explain in the user's language why a Trash item can't be restored
An item moved into the Trash by an app or the API has no record of where
it came from. Choosing Restore on it showed "Cannot restore: original
path not found in metadata", in English and in internal terms. The
message now says which item it is and why, through i18n.
* perf(dashboard): delete Trash items permanently a few at a time
"Delete permanently" on a Trash selection deleted one item after another,
a round trip each: 60 items took about 8s at 100ms of latency, and a few
hundred took most of a minute. Six run at once now (60 items: about
1.3s). The bounded runner the notification center used for "Mark all
read" moves to a shared helper for both.
* fix(dashboard): don't leave a "folder is empty" notice over the next folder's rows
Opening a folder clears the pane while its listing loads. If something
finished in that time and found the pane empty (a Trash restore, a
failed new folder), it added the "This folder is empty" notice, and the
listing's rows were then drawn under it. The listing now clears a stale
notice before drawing its rows.
* fix(dashboard): restore create_file when two "New" creates overlap in Files
Each "New > Text Document" (or HTML, Web Link…) in Files swaps in its own
window.create_file while it runs and put back whatever it found when it
finished. When two overlapped and the first finished first, the second
put back the first's stand-in for good. Any later create that reached it
counted a create that never ended, and from then on files added by apps
or other tabs stopped showing in the listing until a reload. The stand-ins
are now tracked together, and the real function comes back once none is
left.
* fix(dashboard): don't let a slow "New" file pull the user back or land in another listing
After "New > Text Document" (or HTML, Web Link…) in Files, the new file's
row is drawn once its upload finishes. If the user opened another folder
meanwhile, then:
- if that folder was still loading, the row (with its rename box) was
drawn into the loading pane and the spinner removed, and the folder
then lost its "empty" notice;
- if it had loaded, the user was taken back to the folder the file was
made in.
The row is now drawn only if the folder is still on screen, and the user
is only taken to the folder whose menu made it if they haven't moved.
* fix(dashboard): keep an uninstalled app off the Apps grid until its uninstall lands
Uninstalling an app takes its tile away at once, but the server keeps
listing the app as installed until the revoke request finishes. A grid
refresh in that time (switching tabs, coming back to the browser tab)
put the tile back, and it stayed until the next refresh; clicking it
reinstalled the app. Refreshes now leave the app out until the revoke
settles, and ones started before it landed are discarded.
* fix(dashboard): keep keyboard focus when uninstalling an app from inside a folder
After uninstalling an app from the Apps grid, focus goes to the tile that
took its place. An app uninstalled from inside an open folder had no
place on the grid, so focus fell to the page, and a keyboard user had to
start over from the top. Focus now goes to the folder's next app, to the
folder itself, or, when only one app was left and the folder dissolved,
to that app.
* fix(gui): show sizes under 1 KB as whole bytes
The size formatter wrote a size under 1 KB with two decimals and a
mismatched unit: a new account's storage on the dashboard read "0 Byte
Used", and a small file "149.00 Bytes". Bytes are now whole, with the
unit in the right number: "0 Bytes", "1 Byte", "149 Bytes".
* fix(dashboard): show the bottom edge of the Files drop outline in list view
Dragging files over the Files listing outlines it with a dashed border,
but in list view the bottom edge was missing: the listing cleared the
footer with padding, so its box ran under the footer and the footer hid
the outline's bottom edge. It now clears the footer with a margin, as
the grid view already did, so the outline shows all the way round.
* Fill the send-feedback popup with the feedback dialog
The popup opened by puter.ui.showFeedbackDialog() drew the floating modal
over a scrim. It now fills the window like the custom domain popup, with
the actions pinned to the bottom and the message box taking the spare
height. The full-page styles move from .custom-domain-fullpage to a shared
.app-feedback-fullpage on the modal shell.
* Land the first ArrowDown on the first <puter-user-menu> item after a click
Opening the menu with the pointer focused the first item without a visible
focus ring, so ArrowDown appeared to skip it. A pointer open now focuses the
menu itself; opening from the keyboard still focuses the first item.
* Show sign-in and file pickers in a frame on first-party pages
puter.auth.signIn(), showOpenFilePicker(), showSaveFilePicker() and
showDirectoryPicker() on a page one label under the Puter origin now
show the GUI popup in a frame over the page instead of a window. Every
other page, and any frame that can't serve the request, keeps the popup
exactly as before, so apps need no changes.
/embed/sign-in and /embed/show-{open-file,save-file,directory}-picker
render the normal GUI shell with the framing rules, query redirect and
no-store of /embed/apps, and name the framing page (read from the
referrer, pinned with frame-ancestors) as embed_host_origin. The GUI
runs the popup boot unchanged with that page as window.opener; close()
asks it to remove the frame. Popup parameters come from the fragment
(msg_id, options, request_auth, and user for a picker).
Sign-in has a close button that rejects with auth_window_closed, as
closing the popup does. A federated provider can't be framed, so
choosing one continues in the popup. attempt_temp_user_creation and
cross-origin isolated pages keep the popup.
Pickers frame only for a signed-in page whose user owns the GUI
session; otherwise the GUI reports unavailable and the page opens the
popup, through a consent dialog if the click has expired. The page
keeps its own token rather than adopting the one sent with the answer.
The feedback embed and the new frames share one loading/ready state
machine in guiEmbed.js.
* Close a framed picker when Escape cancels it
Escape closes only the picker window inside the frame, so the frame now
closes with it. The mismatched-user e2e test waits for sign-in's whoami
before naming another user, which it could otherwise overwrite.
* Close a framed sign-in on Escape
The sign-in shows as a page in the frame, which ignored Escape. Escape
now asks the framing page to close it, as its close button does, unless
a request is in flight.
* Focus a framed sign-in once the page focuses the frame
A cross-origin frame's focus() calls do nothing until the framing page
focuses it, so the sign-in opened with focus on the body: keys missed
the form and Escape never reached the dialog. The screen now takes
focus when the frame gains it, unless something already has it.
* Skip the frame on subdomains the server refuses to let frame
The SDK framed embeds from any page one label under the GUI origin, but
the server refuses its own subdomains (api, docs, apps, ...). On those,
sign-in and the pickers waited on a refused frame before opening the
popup, which Safari could then block for want of a recent click. They
open the popup straight away now, and the user menu hides Apps there,
as its docs say.
* Keep a framed picker on the framing page's user
With several saved accounts, a framed picker could still show the
account picker (no grant yet, or request_auth in its fragment). Picking
another account switched the session inside the frame, and the picker
then ran with the first user's details and the second user's token,
saving that token under the first account. A framed picker already
holds the framing page's user, so it no longer offers to switch, and
its user check no longer writes the boot's shared whoami.
* Take a save picker's file only from the page that asked
The save picker opened a dialog for every showSaveFilePickerPopup
message from any window. In a frame, a sibling frame of the framing
page can reach it, so it could supply the bytes the user then saved,
or stack extra dialogs. The picker now takes the file once, and only
from its opener.
* Reopen a framed embed that reloads itself into a refused frame
A reload or navigation the embed starts itself names the embed as its
referrer, so the server refuses the framing: logging out at an account
gate, a sign-out in another tab, or a reset session left a blank box.
The page now shows its spinner and opens the embed again itself, once
per ready, and falls back as for a refused frame if that fails too.
* Correct why a temporary-account sign-in keeps the popup
notes.puter.com and puter.com are the same site, so browsers don't
partition the frame's storage. The popup is kept because only it
creates a temporary account.
* Remove the picker's consent dialog once answered
The consent dialog a picker shows when its click has expired was
closed but left in the page after Continue or Cancel, one element per
fallback. It is removed now, as the other consent dialogs are.
* Name the embed dialog for assistive tech
The modal dialog around a framed embed had no accessible name, so
screen readers announced an unnamed dialog. It is labelled with the
frame's title.
* Expose an embed's loading status only while it shows
The spinner is faded out rather than removed, so assistive tech still
read a Loading status over a frame that was ready. The status is
hidden from it whenever the spinner isn't showing.
* Let a popup frame fill in texted codes
Signup and the account checks fill a texted code in through WebOTP,
which a cross-origin frame may only use when allowed. Popup frames now
allow otp-credentials, so the code fills in as it does in the popup.
* Close a popup frame on Escape from the page too
Once a popup frame was ready, Escape with focus on the page (after a
click on the scrim) only moved focus back into the frame, so it took a
second Escape to close, and none if the frame stopped responding. It
now closes the frame, as Escape inside it does. The feedback embed
still leaves dismissal to the embed.
* Keep scrolling over an embed's scrim from scrolling the page
A wheel or swipe over the scrim around a framed embed chained to the
page behind the modal. The dialog now contains its overscroll.
* Frame a GUI popup only at its exact path
Routes match case-insensitively and with a trailing slash, so another
spelling of /embed/<action> was served frameable while the shell booted
the desktop for it. Any other spelling now redirects to the exact path.
* Prefill a web save picker with the suggested name
The popup and framed save pickers ignored suggestedName and opened with
an empty filename, unlike the save picker an app gets in the desktop.
* Open a provider picked in a framed sign-in straight in the popup
Picking a provider in the framed sign-in fell back to the sign-in popup,
where the user had to pick the provider again. The frame now hands the
page the provider's start URL for its sign-in, and the page opens the
popup there after checking it is the GUI's OIDC start for that sign-in.
* Open the popup straight away for a page framed by another site
frame-ancestors on the embeds allows only the GUI and the framing page,
so a first-party page inside another site's frame waited about a second
on a refused frame before sign-in or a picker opened the popup, by which
time Safari asks for another click. Such a page opens the popup straight
away now, as it did before the frame.
* Close a framed sign-in on Escape at its account checks
Escape closed the framed sign-in on its auth screens but did nothing once
signup moved on to confirming the email, phone or card, which show as
windows. Escape there now closes the frame as well, as its close button
does.
* Leave Escape that cancels an IME conversion to the IME in sign-in
In a framed sign-in, Escape pressed to cancel an IME conversion in a
field also closed the sign-in, losing what was typed. The auth screens
and the account checks now leave an Escape mid-composition to the IME,
as the dashboard's text fields do.
* Fade in a popup frame's scrim and box
The scrim behind a framed sign-in or picker, and the box's white
background, appeared at full opacity in one frame while the GUI's content
faded in over them. Both now fade in briefly, unless the user prefers
reduced motion.
* Keep a page's own styles off a popup frame's dialog, frame and close button
The dialog, its frame and the close button live in the page's document, so
a page's global dialog, iframe and button rules reached them: a transform
that centers the page's own dialogs moved the overlay half off screen, an
iframe max-width shrank the frame inside its box, and button margins and
min sizes turned the close button into a pill. The dialog and frame now
reset those properties inline, and the close button starts from all:unset.
* Fall back to the popup when a reopened embed frame never loads
After a refused self-reload the page opens the embed again, but nothing
timed that second start: a stalled request left the loading spinner up
until the user dismissed it, which settled the request as closed instead
of opening the popup. The reopened frame now gets the same ready timeout
as the first start.
* Hand a save picker frame its file again after it reloads
The page answered a picker's sendMeFileData once and then stopped
listening. A framed save picker that reloads asks again, and without an
answer it never opened its dialog, so the page waited out the ready
timeout and fell back to the popup. A frame is now answered until it
closes; a popup is still answered once.
* Keep a popup frame's white backing off its rounded edge in dark mode
The box's white background shared the frame's anti-aliased corner, so a
light arc showed around the dark sign-in. The backing now stops 1px inside
the edge, which leaves the curve to the frame and the scrim.
* Center a popup frame's box with margins so it fills a phone edge to edge
On a phone with a fractional pixel ratio the translate that centered the
full-screen box left it a fraction of a pixel short of the top, so a line
of scrim showed above the sign-in. Margins snap to device pixels.
* Focus a framed save picker's file name as the popup does
A cross-origin frame's focus() does nothing until the page focuses it, and
the page focusing it again resets it, so the framed save picker opened with
nothing focused and typing went nowhere. A framed popup now gets its latest
focus() back whenever the frame gains focus with nothing focused, until the
user presses a key or the pointer in it.
* Replace a showing popup frame instead of stacking a second one
A double click on a page's picker button framed two GUIs, one over the
other under two scrims, and the user had to cancel twice. A popup reuses
its named window instead. Now a newer popup frame dismisses the one
showing, which settles it as closed.
* Share one framed sign-in between calls made while it's open
A double click on a page's sign-in button started a second framed
sign-in, which replaced the first and rejected it as closed while the
user was still signing in. A call made while a framed sign-in with the
same request_auth is open now settles with it.
* Close only a context menu on Escape over a file dialog
Escape to dismiss a right-click menu in a file dialog also closed the
dialog, which cancels a picker and, in a framed picker, removes the whole
frame. An open context menu now takes the Escape on its own.
* Document that one popup frame shows at a time
* Keep a save dialog's file name and buttons on a phone's screen
The save dialog's bottom bar couldn't shrink below the file name's own
width, so on a phone it spilled off both edges: the name's start and
the Save button were cut off. A framed save picker fills the phone, so it
always hit this. The name now shrinks to fit.
* Keep a file dialog's buttons on one line on a small phone
On a 320px-wide screen the open picker's Open button wrapped under
Cancel and ran into the screen's edge, and Upload wrapped under its
icon. The buttons now stay on one line, with less side padding up to
480px wide so the save dialog's file name keeps room to show.
* Say what a picker is for when its popup needs a click to open
When a framed picker falls back to the popup after the click expired,
the consent dialog said the page used Puter for AI and Cloud features,
not that it wanted a file or folder. It now names the picker, as the
push notification dialog does.
* Share a framed sign-in with a call that differs in request_auth
Implicit auth asks with request_auth, so an explicit signIn() made while
it was open replaced its frame and rejected every puter.* call waiting
on it. Any framed sign-in in progress is now joined.
* Keep a popup frame open when a frame for another popup shows
A sign-in replaced a showing picker frame, cancelling the picker, and
the other way round, though their popups have different window names and
stay open together. Only a frame for the same popup is replaced now.
* Reopen a refused embed frame in WebKit
WebKit took the refused URL as a fragment navigation of its error page
and loaded nothing, so a frame that reloaded itself fell back to the
popup. The frame is reopened through the server's query redirect, which
keeps the fragment.
* Go back to the save picker on Escape at its replace prompt
Escape closed the alert without an answer, so the save waited on it
forever behind the picker's busy mask. In a frame nothing could close
the picker after that. The alert closing now counts as Cancel.
* Leave Safari's IME-ending Escape alone at a framed sign-in's account checks
Safari sends the Escape that ends an IME conversion with keyCode 229 and
isComposing false, so it closed the framed sign-in. The auth screens
already check for it.
* Keep an open dialog's buttons whole on a small phone in German
At 320px, German's longer labels pushed the Open button past the screen
edge, since the upload label couldn't shrink and the buttons could. The
upload label now gives way, cut off with an ellipsis.
Signing up with a provider whose account already exists signs the user
in to it, then showed a blocking "You have been logged in to an existing
account" alert. In a sign-in popup it held back the token until the user
clicked OK, right before the popup closed. The sign-in now completes
without it, and the callback no longer adds oidc_switched to the return
URL.
The signed-in menu now shows the user's picture, username and email
(hidden when whoami leaves the email out), links to Manage account,
Usage and Open Puter on the GUI origin in a new tab, and Send feedback,
which calls puter.ui.showFeedbackDialog(). Each new item takes a label
attribute like the existing ones. The menu opens on its first item, and
the arrow, Home and End keys move between items.
The framed feedback dialog stayed hidden until the embed had checked the
GUI session, so on a slow network a click on Send feedback showed
nothing for seconds, and a second click silently resolved false. A
check that takes longer than 200ms now shows a scrim with a spinner over
the page, matching the embed's own scrim, which takes its place once
the dialog is ready. Escape or a click on it cancels, and a refused
session still falls back to the popup.
WebKit gives a frame the color-scheme of its iframe element as its
preferred scheme, and the feedback embed's frame was set to normal, so
Safari in dark mode showed the light dialog. The frame and the embed's
root now both declare light dark: the embed follows the OS in every
engine, and the frame stays transparent because the two match.
* Show the app feedback dialog in a frame on first-party pages
puter.ui.showFeedbackDialog() on a page one label under the Puter origin
now frames /embed/feedback in a modal over the page, instead of opening a
puter.com popup. The embed uses the same framing rules, query redirect,
and host-user binding as /embed/apps, and sends the feedback for the
framing page's origin, which the server reads from the referrer and pins
with frame-ancestors. Without the named user's Puter session, or with an
email still to confirm, the embed reports itself unavailable and the SDK
falls back to the popup, which can sign in. A frame that never answers
(such as one the host's CSP blocks) falls back too.
The embed session check moves out of appsEmbed.js into a shared helper,
and the first-party page check out of <puter-user-menu> into puter.js's
lib, so both embeds use one copy of each.
* Keep the framing page from scrolling behind the feedback embed
A wheel or touch scroll over the feedback embed's scrim chained out of
the frame and scrolled the first-party page underneath the open dialog.
The embed's root now stops overscroll from leaving the frame.
* Name the feedback embed's user from the cached whoami
showFeedbackDialog() on a first-party page fetched /whoami before it
started loading the embed, adding a round trip to every call. It now
uses the user puter.js already cached for the session and asks the API
only when there is none.
editor, markus and pdf moved to standard host routing, but the host
redirect and embed framing tests still used editor as their native app,
so they failed. They now use about, and a test checks that editor,
markus and pdf are passed through even with a folder under the native
apps root.
The dashboard's Contact Us now uses the dashboard form dialog instead of a
UIWindow: a centered card on desktop and a bottom sheet on phones. Files can
be picked, dropped anywhere over the dialog, or pasted into the message, and
images show a preview. Cmd/Ctrl+Enter sends. The desktop keeps
UIWindowFeedback.
The signup screen showed its email form, then looked up the identity
providers and folded the form away behind "Sign up with email" once they
arrived, a round trip later. Every screen made its own lookup, so moving
from login to signup redrew the card each time.
The provider lookup now happens once per auth flow, so moving between
login and signup draws the providers before the next paint. When signup
is the first screen, its email form stays hidden until the lookup
answers, or for 1.5 seconds if it is slow, and shows when no providers
are offered or the form was opened prefilled.
Updates the English translation for `popup_opener_uses_puter` to a shorter single-line message. This removes the extra explanatory text about AI/cloud features and using a Puter account, likely for a cleaner popup prompt.
Reduce the signup terms text in the auth dialog to match the tighter dashboard layout. This is a small styling adjustment for the dialog copy to keep it balanced and readable.
Someone logging in already has a Puter account, so like the account
picker the login screen no longer explains that the site uses Puter.
The signup screen keeps it.
The account picker reads pictures from the logged_in_users list in
localStorage, but update_auth_data saved that list with an empty profile
and only added the picture in memory once the profile loaded. A picture
reached storage only when a later account switch or sign-out rewrote the
list, so the active account usually showed just its initial.
update_auth_data now keeps the account's saved profile until the fresh
one loads, then saves the user and the account list again. The profile
loads after puter.js holds the new account's token, so a switch can no
longer fetch the previous account's profile, and a profile that arrives
after another account signed in is dropped.
The first-visit migration in globals.js now stores the account list as
JSON instead of "[object Object]".
* Show the app browser embed's session only to the host's signed-in user
/embed/apps now reads the host page's signed-in user from its fragment
(#user=<uuid>) and asks /whoami which user the GUI session belongs to.
It shows the user's apps only when the two match. A host that names no
user, a malformed one, or another user, and a session the API refuses,
get the signed-out view of recommended apps with no SDK and no app
tokens. A failed /whoami check shows the unavailable state. Changing the
fragment reloads the embed.
Hosts pass the uuid from puter.auth.getUser() and remove the iframe when
their own sign-in changes; the doc's example does both.
* Add <puter-user-menu> for first-party apps on Puter subdomains
puter.js registers a <puter-user-menu> element that follows the page's
own puter.js sign-in. Signed out it shows Sign in; signed in it shows the
user's profile picture, or their initial when there is none, with a menu
holding the username and Sign out. On a page one label under the Puter
origin, an Apps button opens /embed/apps in a modal with the signed-in
user named in the fragment, so the app browser shows the Puter session's
apps only to that user. Any change to the page's sign-in closes the app
browser. The element renders nothing outside a top-level page.
Its labels can be set with apps-label, sign-in-label, sign-out-label,
close-label and account-label, and it fires signin and signout events.
It is documented internally in components.md and the embed doc, and is
left out of the public docs because the app browser only works on Puter
subdomains.
* Show <puter-user-menu>'s app browser in a panel under its button
The Apps view and the account menu are now panels that hang under their
button with a caret, closing on Escape, an outside press or the button.
They use the top layer where the browser has one, so a host's transform,
overflow or z-index can't clip or cover them. On screens up to 600px wide
the Apps panel spans the width below the button.
Closing the Apps panel keeps the app browser loaded; a change to the
page's sign-in still discards it. A spinner shows until it loads, and its
colour scheme follows the component's theme, which follows the OS unless
the theme attribute is set.
A temporary account counts as signed out: it gets Sign in, no profile
picture request, and an app browser with no user named. The signin event
carries the signed-in user, and the component exposes the parts
apps-button, avatar, sign-in, menu and apps-panel for host styling.
* Read the user <puter-user-menu> signs in as after the SDK starts its whoami
setAuthToken notifies auth listeners before it starts the new token's
/whoami, so a sign-in on a loaded page showed '?' and opened the app
browser without naming the user.
* Close <puter-user-menu>'s account menu when Tab leaves it
Focus moved on to the page with the menu still open, where Escape no
longer reached it. Tab now closes the menu and moves on from its button.
* Move focus to Sign in after signing out from <puter-user-menu>
The focused menu and avatar disappear on sign-out, which dropped focus
to the page body.
* Show the app browser in the theme <puter-user-menu> forces
Chromium doesn't pass an iframe's color-scheme on to the page inside it,
so a forced theme that differed from the OS showed the app browser in
the OS's theme inside a panel in the forced one. The component now
passes a forced theme as theme= in the fragment, and the embed applies
it to its color-scheme rules.
* Show <puter-user-menu>'s account before its profile picture loads
The avatar and Apps button waited for the /profile request as well as
the user, so a slow profile kept them hidden. The picture is now added
when it arrives.
* Open one sign-in popup at a time from <puter-user-menu>
Clicking Sign in again while its popup is still open opened another popup each time. Further clicks are ignored until the sign-in finishes or its popup is closed. A cross-origin isolated page can't see its popup close, so it keeps the old behavior rather than locking the button.
* Keep <puter-user-menu>'s Apps button unshaded after a tap closes it
Touch screens keep :hover on the last tapped element, so tapping the Apps button to close the panel left it shaded as if the panel were still open. Hover shading now applies only where the device can hover; the open state still shades the button everywhere.
* Show <puter-user-menu>'s Sign in pressed and its account hovered
Sign in had no pressed state and the account button no hover state, so neither responded to the pointer. Sign in now takes the pressed look of Puter's other primary buttons, and the account button shows its open-menu ring on hover where the device can hover.
* Show a spinner in the app browser embed until its apps arrive
The embed stayed blank while it checked the session with /whoami and loaded the SDK. <puter-user-menu> hides its own spinner once the embed's page loads, so its panel went blank for that whole time. The empty embed now shows the same spinner in the same place until it renders its apps or its unavailable message.
* Offer a custom domain when publishing a folder from the dashboard
The Files tab's Publish Website dialog has a "Use a custom domain" switch.
It appears only when the server has custom domains. Turned on, it asks for
the domain, with the monthly price and suggestions from the domains already
on the account. Picking one of those moves it to the new site instead of
adding it again.
Publishing creates the site, then adds the domain. The done screen shows the
live address and the domain's status with the DNS records to set, each with
a Copy button, and a Check Status button. A failure after the site is live
keeps the site: the address locks, and the button retries only the domain.
A domain held by another account shows its ownership record to verify.
On a plan without custom domains, the switch shows an upgrade prompt in
place of the field and opens the upgrade flow where the deployment has one.
Once the plan changes, the field unlocks in place.
The /custom-domains request, error wording, price format and status-check
merge move to helpers/customDomains.js, shared with the desktop's attach
dialog.
* Name the served and forwarding domains the right way round in the redirect note
The note reads "served on {first}, forwarded from {second}", but both the
publish dialog and the attach dialog passed the root domain first.
* Fit DNS record labels of any length in the publish dialog
The label column was a fixed 44px, so a longer translation ("Pangalan")
ran under the field beside it. Each record card is now one grid whose label
column grows to its longest label, keeping the name and value aligned.
* Run one domain action at a time on the publish done screen
Check Status and the switch to www each disabled only themselves, so both
could be in flight and a slow status check could redraw the root domain
over the www one it had just become.
* Keep focus on the custom domain switch when it shows the upgrade prompt
Turning the switch on moved focus to Upgrade, so a second Space meant to
turn it back off opened the upgrade flow instead. The prompt follows the
switch in tab order.
* Finish with the live site when the plan can't add its domain
Once the site was live, a 402 from adding the domain left the button
reading Add Domain, and pressing it said to turn the switch off "to publish
without one". The button now reads Done and finishes with the site; an
upgrade brings the add back.
* Keep the ownership record and the Verify step in step while typing
Any edit hid the claim's TXT record for good, but the button went back to
Verify and Add as soon as the claimed hostname was typed again, asking to
verify a record no longer on screen. Both now follow the typed hostname.
* Keep the publish done screen up through a stray backdrop click when it lists DNS records
The records to set at the DNS provider appear only on this screen; a click
outside the card while switching to the provider's tab threw them away.
Done, Escape and the close button still close it.
* Listen for plan changes only once the publish dialog has offered the upgrade
Every website publish dialog added a window listener that held its closed
dialog until the next plan change, which may never come. The dialog is
modal, so the plan can only change after its Upgrade button opened the flow;
the listener is added then.
* Keep the upgrade prompt's text beside its icon on phones
Wrapping put the text on its own line under the icon, leaving the icon
alone on the first row; now only the button wraps.
* Drop "After publishing" from the domain hint once the site is live
When adding the domain needed another try, the hint still promised DNS
records "after publishing" for a site already published; it now keeps
only the price.
* Announce publish domain checks in a status region that stays on screen
Each check redrew the domain section, status line included, so its result
landed in a brand-new live region that screen readers don't announce, while
the redrawn records sat in the done screen's status region. The summary is
now redrawn on its own, outside the announcements.
Each refresh of an item container now takes an id, and only the newest
refresh may write to the container or its window. Before, a slow stat for
the folder a window had left could point the window back at it, so New
folder, Paste and Upload went to the old folder while the window showed
the new one. A listing that arrived just before navigating away added its
rows to the new folder, and returning to a folder while its first listing
was in flight rendered every row twice. The stat in update_window_path
also skips its writes once the window has moved to another path.
The desktop context menu (UIContextMenu) stayed white with dark text when
the OS was in dark mode, even though the dashboard around it turns dark.
It now follows prefers-color-scheme like the dashboard: a dark translucent
surface, light text, and dark variants for dividers, the open-submenu and
blurred highlights, the submenu chevron, monochrome icons, and the mobile
action sheet.
A sign-in page already fills the screen, so it no longer shrinks itself
to the area above an on-screen keyboard. On iOS that resize fought the
browser's own pan to the focused field and the form moved up and down
as the keyboard opened. Dialogs still fit above the keyboard.
Signed out, /action/login, /action/signup, /action/password-recovery and
the sign-in page for returning visitors, app landings and share links no
longer load the wallpaper behind them, and the page covers the screen at
once instead of fading in over whatever was there.
The signup screen no longer explains that only providers are offered when
email signup is closed; it just shows the provider buttons.
* Move login, signup, account picker, password recovery and 2FA to dashboard-style dialogs
The sign-in surfaces were separate UIWindows that opened over each other
(login, then a recovery window over it, then a 2FA window over that). They
are now screens of one dashboard-style card built on mountDashboardOverlay,
the same shell, fields, buttons and status line as the dashboard's account
dialogs. Screens replace each other in place, with Back, and keep what was
typed.
- Page presentation for cover pages and sign-in popups: an opaque page with
a centered card, filling the screen on phones and in popups. Dialog
presentation elsewhere: dimmed backdrop, centered card, bottom sheet on
phones.
- Each call site keeps whether it could be dismissed, its promise contract,
OIDC gating in popups, Turnstile, the honeypot, bonus codes, the
post-signup verification gates, reauth auth_id, and redirect handling.
- Keys typed in the card no longer reach the desktop's document shortcuts.
- A second sign-in prompt joins the open flow instead of stacking a card.
- Server messages render as text; prefilled values are encoded.
* Show the signup screen's errors above the provider buttons
The status line lived inside the email form, which hides behind the
provider buttons (or for good, when only provider signups are open), so
an error the screen opened with, like a refused provider signup, was
never shown.
* Cover the page with a new account's checks when signup was a page
Signup as a page (a sign-in popup, /action/signup) closes its card for
the email, phone and card checks; they opened as floating windows over
an empty backdrop instead of covering the page as they did before.
* Give the auth card's Back and close bar a background once it scrolls
On a short screen the card's content scrolled under the bare Back and
close buttons, which then sat on top of it: the Back chevron covered the
edge of a provider button and took its taps. Once the showing screen is
scrolled, the bar turns opaque with a divider, as the footer has; at
rest nothing changes.
* Raise a failed session-cookie sync alert above the sign-in card
Signing in waits on this alert with the auth card still up. The card
sits above every ordinary window, so the alert opened underneath it and
the login spun forever. It now opens in the stay-on-top band.
* Keep focus where it is when a second sign-in prompt joins the card
A prompt that joins the open auth card focused the screen's first
field, so one arriving while the user typed their password sent the
rest of it into the username field. It now only brings focus back when
it has left the card.
* Keep focus in the auth card while its form is busy
Submitting disabled the focused field, which dropped focus to the page,
so keys pressed while the request was in flight (Delete, Enter, letters)
reached the desktop's document shortcuts.
* Keep focus in the auth card when its backdrop is pressed
A press on the dimmed backdrop (or a page's background) that didn't
dismiss the card moved focus to the page, so the next keys went to the
desktop's document shortcuts and Tab could leave the card.
* Point the Turnstile migration note at the new signup screen
UIWindowSignup no longer exists; the widget renders in UI/Auth/UIAuthSignup.js.
* Reload after sign-in when only the URL's hash would change
On a dashboard tab (/#files) the redirect after a login or signup was
the same page with a hash, so location.replace only scrolled: the new
account was signed in but the page never reloaded, and the card stayed
up, busy, until closed.
* Span the screen with the auth bottom sheet on wider phones
The card's 400px cap outranked the dashboard sheet's full width, so on
phones between 401 and 600px wide the sheet left gaps on both sides.
* Return focus to the auth form when a request fails
A busy card parks focus on itself while its fields are locked. When the
server refused a signup, focus stayed there, so Enter did nothing and the
user had to tab back in. Ending busy now hands focus back to the control
that had it, once that control is enabled again.
* Keep the current account when a login is dismissed mid-request
Closing the auth card while its login request was out still signed the
browser in to the other account and reloaded once the server answered,
though the caller had already been told the user dismissed it. A login
that answers after the card closed is now dropped.
* Make the auth card's logo a link where it leads somewhere
On a cover page the Puter logo was an image with a click handler: no
keyboard access, no name for screen readers, no middle-click. It is now a
link named Puter. Everywhere else the logo is marked decorative, since the
title beside it names the screen.
* Return to the login screen when signing in fails after a 2FA code
If storing the new session failed after a verified code, the error went
to the hidden login screen while the code screen stayed up, busy, with
its boxes locked. The card now goes back to the login screen first.
* Hand the session cookie back when a login is dismissed mid-request
The server sets the session cookie as soon as it accepts a login or a
second-factor code. Dropping an answer that arrived after the card closed
kept the page on its account while the cookie had moved to the other one,
so cookie-gated actions (password, 2FA) applied to that account and the
next load picked up its token. The cookie is now synced back to the
account the page is signed in as.
* Keep padded notices inside the auth page's column
On a phone-width page or in a sign-in popup, every direct child of the
auth body is stretched to the column's width. The overlay sits outside
.dashboard, so it isn't border-box, and the padded error line and bonus
notice came out wider than the fields and scrolled the page sideways.
* Keep the recovery-code field one size when it takes focus
On phones the generic dialog-input and phone input rules outranked the
recovery-code field's style until it was focused, so tapping it grew the
text from 16-17px to 20px and shifted the screen. The field's rule now
carries the same weight as theirs, like the code boxes beside it.
* Show the provider buttons' hover in dark mode
The hover shade was the sidebar background, which in dark mode is one
step off the card's, so Google, Apple and the other sign-in buttons gave
no feedback under the pointer. Dark mode now uses the hover shade.
* Theme the signup bonus notice with the auth card
The notice kept the old signup window's fixed pastel colors, which stood
out as a light box on the card in dark mode. It now uses the dashboard's
success and danger colors, which follow the color scheme.
* Keep a new account's email check from closing without an answer
A signup that needs its email confirmed takes the auth card down and
waits on the confirmation window, whose ✕ closed it without ever
answering. In a sign-in popup reached from the account picker that left
an empty popup with nothing to pick. The check now stays until the code
is confirmed, like the phone and card checks and the boot-time email
check.
- lowerDecoratorsPlugin dropped esbuild's source map, so coverage and
stack traces reported transformed lines. Pass the external map through.
- puter.js node runner kept a ~6 MB `__coverage__` per vm context and ran
out of heap. Contexts now share one object; workerd merges as it goes.
report-coverage skips its comparison when the PR leg failed.
- CI-only test/hook timeouts of 20 s / 60 s.
- setupTestServer disables the database-liveness health check, whose
`SELECT 1` landed in query-counting tests.
- LocalWorkerService.shutdown asserts the timer shutdown arms instead of
the global fake-timer count.
- userHandlers worker test waits for posts by label.
- AppStore stats test pins the clock so rows don't cross midnight UTC.
- Realtime backpressure test waits up to 5 s for holds to settle.
Updates `RECOMMENDED_APP_NAMES` to prioritize core productivity apps (`word-processor`, `spreadsheet`, `presentation`, `whiteboard`, `pdf-editor`) immediately after `builder`, and removes `terminal` from the recommended list.
* Show recommended apps in the app browser embed without a session
/embed/apps only worked for a signed-in user; without a session it showed
an unavailable state. The route now puts the recommended apps
(RecommendedAppsService) in the page's config as guestApps, and when the
embed finds no session it shows those, without loading puter.js or reading
installed apps, folders, or saved order. Selecting one opens it at the
same URL a signed-in launch uses, with no opener and no token on offer, so
the app signs in on its own; apps that need the desktop open /app/<name>.
Signed-in behavior is unchanged.
createAppBrowser takes a guestApps option for this, and appTabLaunch's
URL building is shared between launchAppInTab and the new
guestAppLaunchUrl.
* Show the app browser embed's error state when the session lookup fails
Only a 401 from /get-gui-token now means there is no session. A rate
limit or server error shows the unavailable state, as before, instead of
the signed-out list of recommended apps.
* Let the app browser embed find no session when storage is refused
Reading local storage can throw when the browser blocks site data. The
embed now falls back to the session cookie, as it does for empty
storage, so a visitor without a session sees the recommended apps
instead of the unavailable state.
* Fix the app browser embed doc's broken sentence about puterOrigin
Safari keys a framed origin's local storage by the top-level origin, so the
app browser embedded on a sibling subdomain finds no auth_token_v2 and shows
"Apps are unavailable". The session cookie is still sent there, so the embed
now falls back to /get-gui-token and keeps the token in memory.
/embed/apps now names the referring origin in frame-ancestors only when it
is exactly one label under the domain and that label is not one the server
answers itself (api, dav, js, www, native-app hosts) or the host of
api_base_url. Nested subdomains are refused too.
The query-string redirect now checks the raw URL instead of the parsed
query, so every request carrying a query string is redirected.
On a website, puter.notifications.enable() threw user_gesture_required
without prompting once the calling click's activation was gone: after
puter.auth.signIn() or requestPermission() opened a popup in the same
click, after a few seconds of awaited work, or when called on load. Apps
that ignored the error showed nothing.
It now shows Puter's consent dialog in that case and asks the browser
from its Continue click; Cancel resolves false. Calls made while an ask
is open share it. PuterDialog gets an onContinue option and a show()
that launch() also uses. Inside Puter's own desktop page the call still
throws, since it always runs from a click.
The push guide also notes that Chrome stops showing the prompt after
three dismissals and that its quieter messaging shows a bell icon.
Recently used apps on the Home tab opened /app/<name> in a new tab, which
loads the desktop and launches the app from there. They now open the way
Apps tab tiles do: a new tab at the app's index_url through launch_app's
newTab, or in this page when the browser refuses the tab. The Apps tab's
open-window lookup moves to helpers/focusAppWindow.js so both share it,
and the shared launch lives in helpers/dashboardAppLaunch.js, outside the
app browser so the embed bundle doesn't pull in launch_app.
The embed page always loaded /dist/apps-embed.min.js from the server,
while the GUI bundle and the embed build are published to gui_bundle's
location (a CDN in production). The server's /dist copy can be stale, so
the embed ran an old build. It now loads apps-embed.min.js from
gui_bundle's directory, keeping /dist when gui_bundle isn't set.
* Open apps from the embedded app browser at their own URL
Selecting an app in the /embed/apps browser now opens it in a new tab at
its index_url, with the same launch params the dashboard's tab launch
uses, instead of the GUI's /app/<name> landing. The tab is opened inside
the click and without noopener; the app's SDK asks the embed for its
token through the opener, so the token stays out of the URL.
Apps that need the desktop (built-in and godmode apps, private apps the
user can't open, or a failed token request) still open /app/<name>, with
the tab's opener cleared. Website shortcuts are unchanged.
The app URL, the app token request and the /rao report move out of
launch_app into helpers without desktop globals (appLaunchUrl.js,
appLaunchApi.js), so launch_app and the embed build the same URL.
uuidv4 moves to a helper the embed can import.
* Tag app launches with utm_medium and a utm_campaign naming the launch surface
Apps now get utm_medium=referral and utm_campaign=desktop, dashboard, or
app_browser_embed alongside utm_source=puter.com, so their analytics can
tell where in Puter they were opened from.
* Open dashboard app tiles in a new tab at the app's index_url
Clicking an app tile in the dashboard now opens the app in a new browser
tab at its index_url, with the same query params (puter.app_instance_id,
puter.app.*, puter.auth.token, puter.domain, puter.api_origin,
puter.locale, puter.args, ...) its in-page iframe gets.
launch_app takes a newTab option: it runs the normal launch (private
access, file signing, token) and sends the caller's tab to the built URL
instead of opening a window. The tile click opens the blank tab inside
the click so popup blockers allow it, and closes it if the launch fails.
* Hand an app tab its token through the opener, not the URL
An app opened in its own tab no longer gets puter.auth.token in its URL,
which kept the token in browser history, and which puter.js ignored anyway
outside an iframe.
The GUI now holds the token for the tab it opened. Once the app loads,
puter.js (standalone, with puter.app_instance_id in its URL and an opener)
asks its opener for it at the configured GUI origin, takes only a reply
from that window at that origin, and then clears window.opener. The GUI
answers once, only to that tab at the origin it sent the tab to, and
drops the offer after two minutes. Sign-in and implicit auth wait for the
handoff, then fall back to the normal sign-in flow when no token came
(opener closed, slow load, redirect to another origin).
Built-in apps join explorer in opening in-page, since they belong to the
desktop.
* Open files from the dashboard Files tab in a new tab
Opening a file in the Files tab (double-click, Enter, tap, or the Open
menu item) now opens its app in a new browser tab, the same way app
tiles do. The tab is opened inside the gesture and handed to open_item,
which passes it to launch_app or closes it when the file opens no app.
Web links reuse the tab, cut off from the dashboard. A second file of a
multi-select Enter, whose tab the popup blocker refuses, opens in-page.
The opened file's signed read/write URLs don't expire, so a tab gets
them through the opener handoff with its token instead of in its URL;
the URL keeps only puter.item.uid as a marker. puter.js delivers the
handed-over item to onLaunchedWithItems / onItemsOpened, and
wasLaunchedWithItems answers true while it's on its way.
launch_app now closes a tab it was given whenever the launch fails, so
callers don't each have to.
* Open "Open With" apps from the Files tab in a new tab
Picking an app under Open With in the dashboard Files tab now opens it
in a new browser tab, like opening the row. The tab is opened in the
menu click, or, when the "always open with" prompt comes first, right
after it's answered, since that click counts as a fresh user gesture.
If the popup blocker refuses the tab, the app opens in-page. The desktop
keeps opening in-page.
* Wait for a GUI-opened tab's token in getUser, whoami and getProfile
A tab the GUI opens for an app gets its token over postMessage moments
after puter.js loads. These calls short-circuited on a missing token, so
an app reading the user at startup failed with a 401 (or a null profile)
even though the token was on its way. They now wait for the handoff
first, the same way sign-in already does.
* Close an app's blank tab before alerting that its launch failed
A launch denied for a private app, or one whose token couldn't be
fetched, awaited its alert while the blank tab it was given stayed in
front. The alert renders on the dashboard behind it, so the user was
left on an empty tab until they went looking. The tab is now closed
before the alert shows.
* Launch an app tile in-page when the browser refuses its tab
When window.open returned null (a popup-blocking setting, or a context
that can't open tabs), clicking an app tile did nothing at all. It now
falls back to the in-page launch it had before, as opening a file from
the Files tab and Open With already do: an open window is surfaced,
otherwise the app opens maximized with the tile's open morph.
* Open no tab for a file opened from the Trash
Opening a file in the Trash only explains that it can't be opened, but
the Files tab still opened a blank tab for it first, which then closed
again: a flash of a new tab before the alert. Trashed files (by the
path a shortcut resolves to) now get no tab.
* Don't count a tab launch as one that can report interactivity
launch.ipc_tracked on app-is-ready is the denominator for the
app-interactive series: launches that could produce one. An app opened
in a new tab never signals over IPC, yet was recorded as tracked, so
every tab launch counted as an interactive span that never came. Tab
launches are now recorded as untracked.
* Leave openerLaunch_ unset when there is no GUI opener to ask
requestLaunchFromOpener resolved null asynchronously even when there was
no opener at all, so openerLaunch_ was briefly set on every boot with an
app instance id in the URL. A reloaded app tab (opener already cut off,
URL still marking the opened item with puter.item.uid) reported
wasLaunchedWithItems() as true to code running in the same task as the
boot, though no item would ever arrive. With no opener to ask it now
returns null synchronously and nothing is left pending.
* Call an app tab launch off when the user closed its tab
Closing the blank tab while the launch's fetches were in flight didn't
stop the launch: it still navigated the closed tab, offered it a token,
and recorded the app as opened (recent apps and /rao). The launch now
checks the tab before sending the app there and ends as not launched,
without an alert, when the user has closed it.
* Keep a same-origin opener instead of asking it for a GUI launch
Any top-level page with an app instance id and an opener asked that
opener for its launch, waited out the timeout when no answer came, and
then nulled window.opener. A page an app opened from its own origin (a
pop-out of itself, say) can't have the GUI as its opener, yet it waited
five seconds before its launch item and sign-in, and lost its link to
the app window that opened it. An opener readable from this page, on an
origin other than the GUI's, is now left alone and not asked.
Each response that starts runs `ai.prompt.validate` on the text the client
sent since the previous check (instructions, message text and transcripts,
function call output), and its usage follows as `ai.prompt.cost-calculated`
under the same completion id. A refusal closes the session with
`permission_denied`. Nothing runs when no host listens for the check.
* Add embeddable Puter toolbar and shared app browser
Provide deployment-scoped iframe routes, a responsive host helper, read-only app browsing, saved-account switching, and new-tab account and upgrade links. Validate account sessions before switching and keep credentials inside the GUI origin.
Verified with 182 targeted tests, production GUI build, backend type checking, lint, and a real-backend browser smoke test. Translation checker could not run because tools/check-translations.js is missing.
* Prevent embedded toolbar from scrolling when menus open
* Stop double-encoding translated toolbar labels
i18n() already HTML-encodes its result, so wrapping it in escape() again
turned accented labels into literal entities (e.g. "Schließen") for
screen readers, and .text() did the same for the error message.
* Load only the preferred language in the toolbar and app browser embeds
Importing i18n.js pulled every translation into each embed, making the
toolbar bundle 1.1 MB. The embeds now bundle English and fetch the user's
language on demand (toolbar 1.12 MB -> 179 KB, app browser 1.18 MB -> 237 KB).
The desktop bundle keeps all translations.
* Keep toolbar buttons in place when a menu opens near the viewport edge
On phones the expanded frame was clamped to an 8px viewport margin, which
moved the whole toolbar row (by the header padding minus 8px) under the
user's finger. The frame now shrinks to fit beside and below the collapsed
toolbar, and only falls back to shifting when there isn't enough room.
* Don't pull focus back into the toolbar when the host page closes its menu
Clicking a host text field while a toolbar menu was open closed the menu and
then refocused the toolbar button, so the host field lost focus and typing
went nowhere. Focus is now restored only while the toolbar document has it.
* Close toolbar menus when the empty part of the expanded frame is pressed
While a menu is open the toolbar frame covers host content beside the
buttons; presses there reached neither the host nor the toolbar's outside
click handling, so the menu stayed open. They now close it.
* Omit the empty email line in the toolbar account menu
Temporary accounts and saved accounts without an email rendered an empty
element, adding a stray gap under the username.
* Stop user-hosted sites from framing the toolbar and app browser embeds
frame-ancestors allowed every *.<domain> subdomain, which on the default
self-hosted layout includes user sites (*.site.<domain>) and private apps
(*.app.<domain>), letting any hosted page clickjack sign-out and account
switching. The policy now names the embedding page exactly, from the
request's referrer, and refuses the user-content hosting domains.
* Use the dashboard danger color for the toolbar sign-out confirmation
The hardcoded red had 3:1 contrast on the dark panel, below WCAG AA for
14px text. The dashboard's danger token is 4.8:1 in light and 6:1 in dark.
* Drop the dashboard's hamburger clearance from the embedded app browser
At narrow widths the dashboard pads the search 60px down to clear its fixed
menu button. The embeds have no such button, so the toolbar's app panel
lost 90px of a ~500px frame to empty space above the search.
* Keep the toolbar's Upgrade button legible in dark mode
Upgrade was filled with the link color, which dark mode lightens to
#6b8cff: 3.1:1 under white text. Use the dashboard's primary-button fill
(4.6:1 in both themes) and its hover color, and drop the button classes
nothing styles on this page.
* Keep the toolbar's apps icon legible on hosts in the other theme
The toolbar follows the OS theme but draws on the host page, so in dark
mode on a light host its icon was #d4d4d4 on white (and the reverse on a
dark host). Give the apps toggle a surface of its own, like the avatar
beside it, and the session-unavailable notice one too.
* Let the toolbar sign out of a session that already ended
When the session was ended elsewhere (revoked, or signed out in another
tab), the anti-CSRF and logout requests answer 401 and sign-out failed
on every retry, stranding the stale token. Treat 401 as already signed
out and finish clearing it locally, as the desktop does; other failures
still keep the session and show the error.
* Keep the toolbar's slot following the host layout while a menu is open
The helper reserved the toolbar's space with a span frozen at the
frame's resolved size and margins, so a host that right-aligns it with
margin-left: auto (or sizes it in percentages or media queries) left the
open menu anchored to a stale spot, overflowing the header, after a
resize or rotation. Reserve it with an empty clone of the frame instead,
which the page's own rules keep laying out; if they target
data-puter-toolbar, which the clone drops so hosts still find one frame,
hold the frame's measured size.
* Reuse isOrgSeat for the toolbar's Upgrade check
The toolbar re-implemented the dashboard's organization-seat test
inline. Use the shared helper so both surfaces agree on who sees an
upgrade offer.
* Point comments at AppBrowser.js, where the Apps tab code now lives
The constants and methods these comments name moved out of TabApps.js
when the app browser became shared.
* Restore the license header on the new embed and toolbar files
TabApps.js lost its AGPL header when its code moved to AppBrowser.js,
and the new embed, toolbar, and host-helper modules were added without
one; every other GUI source file carries it.
* Remove the embeddable toolbar, keeping the standalone app browser
Drop /embed/toolbar, its page and host helper, their styles, strings and
tests, and the dashboard's ?upgrade=1 deep link that only the toolbar's
Upgrade button used. /embed/apps no longer allows the GUI origin to frame
it with a wildcard over deployment subdomains, which existed only for the
toolbar's nested app browser, and its bootstrap config no longer carries
the domain the toolbar read.
A push is skipped only when a page that got the notification live was
visible and focused. Hidden, unfocused or unanswering pages no longer hold
it back, and pages no longer show browser notifications themselves, so the
push reaches every subscribed browser.
- Receipts carry the page's visibility and focus as of arrival; the settle
gains `handled`. `showsOwn` is gone.
- Pages send `notif.handled { uid }` on the socket a notification arrived on
when they got it while visible and focused. Core relays it as the
node-local `notif.handled` event (60 a minute per user) for extensions.
- puter.js drops the page-shown notification path; push-sw.js drops its
page-show message. Handlers still run live in the page.
- The desktop registers /sw.js only when push is turned on, updates it when
subscribed, and removes one left without a subscription. Its hidden-tab
banners are gone.
- Docs describe the focus rule.
Main's docs cleanup (#4504) moved the prompt caching docs under
per-provider sections; the OpenAI paragraphs now live under
OpenAI models > Prompt Caching.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A reconnect that left older items out of the 40-item, 48 KiB restore window
fired an error event with context_restore_failed, the code a provider
refusal uses, with the count only in the message. Every reconnect of a long
conversation raised an error that apps treating error as fatal would surface.
Trimming now fires a nonfatal context_trimmed event, before ready, with the
count in omittedItems. context_restore_failed is kept for a provider refusal
of a restored item or a failed send of the replay.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The per-commit cap of 1,280,000 bytes assumed G.711, so a PCM16 session
(48,000 bytes/s, the SDK's format) with transcription on was closed with
1013 after about 27 seconds without a commit: silence under server VAD,
one long utterance, or a long assistant reply.
Unacknowledged audio is now a duration at the provider-confirmed input
format (the lower rate while a requested format is unconfirmed). Without
turn detection it counts all audio since the last commit or clear; with
server VAD only speech from speech_started, plus the buffer after an
explicit commit, since the provider drops silence. Each started 160 s
claims one spare item as its hold instead of closing the session; a
commit, clear or input_audio_buffer_commit_empty returns the holds to the
pool. If the upstream closes first, every claimed item is billed. The
settle no longer commits silence.
The relay test file now expires the in-memory per-IP pre-auth window
after each test; every test connects from loopback and the added tests
pushed the file past 120 upgrades a minute.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* docs: complex teams recipe (PUT-1986)
Showing which teammates have a file open, built from a folder shared with the
team plus `puter.events` watching it. There is no presence API, so the recipe
is about composing the two.
One use case per the recipe guidance. Uses the masked path from
`listShared()` so members address the owner's folder, keys presence files on
`uuid` rather than username, and re-reads the folder on each event so a missed
delivery cannot leave the list wrong.
* refine
---------
Co-authored-by: Reynaldi Chernando <[email protected]>
Input transcription reserved 64 items up front at the costliest model's
price and largest cost factor, about $7 before factors per session. Ordinary
subscribers couldn't enable it, and concurrent sessions were refused while
balance was left.
Each item now holds its own credit at the selected transcription model's
budget and cost factor. Two spare items are reserved before configuration or
audio is forwarded and refilled as the provider commits items, within the
64-item pending cap. A commit with no spare reserves its own item. A model
change releases the spares and reserves new ones at the new price; committed
items keep their hold. When the account can't cover another item, client
reads pause while the session's own holds may still free credit, and the
session is refused with insufficient_funds once it holds none.
Audio awaiting a commit acknowledgement is now tracked separately from pool
capacity and reset by each acknowledged commit or clear. If the upstream
closes before that acknowledgement, the audio bills as one item. Previously
the fallback required an untouched window, so a lost acknowledgement after an
earlier completed item went unbilled; the cumulative byte count could not
simply be reused without charging earlier work twice. The uncommitted-audio
cap is now 160 seconds of audio instead of a per-window total.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The buffered write now passes the claim key too, which moves the counter
key's position. The image provider's write-ordering test matched it by
index and stopped seeing the write.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Three reads still got a flush wrong:
- A write re-sent after its reply was lost, found already applied, left
the claim without touching a warm cached base, so balance reads stayed
short of what the store held. The claim and the base are now dropped
together, and the next read seeds a fresh base from the store.
- A read with no cached base took the buffer, then the store; a flush
that wrote a batch and left its claim in between had it counted twice.
- An exact read added another deployment's claim but not the usage that
arrived behind it, which that read can't flush, so a charge crossing
the allowance went entirely to the allowance.
Reads that go to the store now take everything buffered (delta and
claim), read the store consistently, and read the buffer again, retrying
when it shrank in between. Single and array reads and exact reads all
use it.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
`readExact` read the store and the outstanding claim side by side. When
a flush wrote a batch and took it off its claim between the two, the
store read had the counter from before the write and the claim read had
nothing, so the batch was missed: 10 units recorded, 0 read.
The claim is now read first. An amount gone from it by then was written
before the store read, which is consistent, so the read has it. When the
claim changes during the store read, the batch may be in both, so the
read is retried, up to three times.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The running total `incr` returns and `readExact` also missed usage a
flush had claimed but not yet written. Both feed how a charge is split
between the allowance and purchased credit, so an account could be
charged to its allowance for usage that had already used it up. Both
now add the counter's outstanding claim.
A read or seed with no cached base asked the store with an ordinary
read, which could miss a write a settle had just made; it now reads
consistently. A write-through after a cache failure drops the cached
base, which doesn't have the write, so the next read reseeds it.
The dirty-set reconcile test no longer depends on how many flush cycles
earlier tests ran.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A flush takes a counter's buffered amounts aside before writing them
onward, and reads only looked at the live delta and the cached base.
From the claim until the settle replaced the base, those amounts were
missing from every read: one store write normally, up to the orphan
sweep if the write failed. Balance checks, credit reservations among
them, could see an account as having more than it does.
Reads now add the counter's outstanding claim. A batch written onward
leaves the claim and joins the cached base in one step, so it stays
counted until the settle replaces the base with the store's view.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The browser test now counts the chunks the transport refused while the
socket read as closing, and requires at least one, so it can't pass
without exercising the gap. The socket stays closing until the
connection drops instead of going back to open.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A conversation still reads as connected for a moment after its socket
starts closing. A microphone chunk sent in that moment got
`session_closed`, and the capture handler treated that like a real
media failure: it stopped the tracks and the AudioContext, so the
replacement session after the reconnect had no microphone.
The built-in microphone now drops a chunk the transport refuses with
`session_closed` or `session_not_ready`, as it already did for
`queue_full`, and keeps capturing. A reconnect resumes sending; a final
close still stops capture through the conversation, and processor or
other errors still release it. Dropped chunks aren't replayed, and
public sends keep reporting the transport error.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A server event over the 4 MiB limit closes the socket with 4009, which
ends a connected conversation. During a reconnect attempt the same close
was reported as `session_closed`, which is retried, so the conversation
tried ten more times and finally closed with the original drop code.
A replacement session opened after a restart notice was reopened again
and again for the same reason.
A 4009 close now ends the conversation during a reconnect too, reporting
4009, and a replacement session that hits it isn't reopened. Tests cover
both, a multibyte event of exactly 4 MiB, and a whole retrieved item
through the relay.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The conversation transport refused any server event over 64 KiB with a
terminal 4009 close, the limit meant for what the SDK sends. The relay
forwards provider events up to 4 MiB, and a session.updated echo or a
whole audio item easily passes 64 KiB, so valid conversations ended.
Incoming events now have their own 4 MiB limit, matching the relay,
counted in UTF-8 bytes (a string short enough that it can't exceed the
limit isn't encoded to check). An event over it still closes with 4009
and doesn't reconnect. Uploads keep their 64 KiB cap, and malformed
events are still ignored.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
Every release bumped the balance version, so an account's own short-lived
holds (each realtime session probes one and releases it at once) could
keep its other reservations reading a stale version until they were
refused. The usage a hold stands for already bumps the version when it's
recorded, before the hold is released, and a release without usage
leaves the balance as it was, so releases no longer bump it.
The admission read also uses the cached add-ons record when it shows no
purchased credit left, which can only understate the balance; it reads
consistently only while credit remains.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A reservation that found the balance changed three times in a row used
to skip the version check on its fourth attempt, which brought back the
race the check closes; an account could provoke that itself. It now
waits a short randomized time between attempts and refuses after five.
A missing version key now starts from a random value, so a version read
before the key expired can't match its successor. Credit grants,
allowance changes, manual usage corrections and a usage write that
fails partway also bump the version, and the store refuses an amount
it can't hold instead of storing a negative hold.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
`reserveAiCredits` checked the balance and then took an unconditional
hold, so concurrent requests could all pass the check before any hold
was visible; two 16.2M µ¢ holds were admitted against a 20M µ¢ balance.
The hold store now takes a hold only if it fits beside the live holds,
in one Lua script on the actor's hold key. A per-user version, bumped
when usage is recorded and when a hold is released, lets the script
refuse a balance read that a settlement has since overtaken; the
service reads the balance again and retries, and skips the version
check on its last attempt rather than refuse an account whose balance
keeps moving. Both keys share the actor's hash tag, so the script runs
on Redis Cluster. The balance read for this check reads purchased
credit consistently.
`reserveCreditsIfAffordable` carries this through the service, and the
AI facade calls it once with the cost factor applied. `reserveCredits`
keeps its unconditional semantics. When the cache is unreachable, the
balance alone decides and nothing is held, as before.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A plan or funds refusal was classified by a plan lookup made after the
decision. A fallback plan is cached for only a few seconds, so when it
ran out in between, the refusal was judged against the recovered plan
and still ended the session with a terminal 1008. The plan is now also
checked just before each decision, and a refusal is retryable if either
check found a fallback.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
The reconnect test made the next credential lookup fail once, but the
SDK's own socket.io connection could take that failure before the
relay's reconnect attempt did, so the relay never saw a failed lookup
and the test failed in CI. Lookups now fail until the relay has refused
an attempt on one, then recover.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
When the billing lookup failed, metering answered with a provisional
fallback plan, so the relay refused the session with
`subscription_required` or, against the fallback's allowance,
`insufficient_funds`, both terminal 1008 closes, and the SDK showed the
upgrade prompt to a paying account. A plan or funds refusal decided on a
provisional plan now sends `internal_error` and closes with 1011, at
connection and mid-session, so the client retries once the lookup
recovers.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A credential lookup that threw (a database or cache outage) was treated
as no actor, so the session got `invalid_api_key` and a 1008 close. That
is terminal for the SDK, which then ended an established conversation
that only needed to reconnect. A failed lookup now gets `internal_error`
and closes with 1011, which the SDK retries; missing, invalid and
revoked credentials keep `invalid_api_key` and 1008. Nothing is cached
for the failed lookup, and the cause is logged server-side only.
A session whose usage records pile up behind metering now closes with
1013 instead of 1008, still with `rate_limit_exceeded`. Queued usage is
still recorded and its holds released. An unexpected setup failure now
sends `internal_error` before its 1011 close.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A session refused while the server drains returned before its client
error and close listeners were registered, so a frame that errored the
socket during the close handshake (an oversized one, for example)
emitted an unhandled `error` and ended the process. The listeners now
go on as soon as the session exists, ahead of every refusal.
The 404 answer to an upgrade for another host also ends a raw socket
that has no error listener once Node hands it over; it now has one.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
- dismiss.md is the one place that explains closing a notification on other
devices; on/onNotification/push link to it instead of repeating it.
- Pages follow the house format: section order, error tables, playground
examples for dismiss() and markShown(), sidebar and index listings.
- updateSchedule.md describes what a developer sees instead of internals.
- New recipes: mark notifications as read, incoming calls (high priority,
short ttl, buttons), unread counts on the app icon.
- Existing recipes move reminders, snoozes and recurring summaries with
updateSchedule() instead of cancel-and-recreate; the buttons recipe follows
the recipe format.
- Docs: status(), browser limits, events worker fetch/ack, update() across
pages, stored-notification retries, device removal, push delivery, queue
coalescing, existing service workers and missing error codes match the code.
- puter.notifications.markShown(uid) / dismiss(uid).
- Dismissals reach the app's live feed (on('dismiss')); pages close the
notification on screen, and a later page load closes ones already dismissed.
The desktop closes its own copies on notif.ack.
- Live notification postings are forwarded to sessions in other regions.
- notify({ priority: 'high' }).
- onPush() results accept requireInteraction and actions; clicks carry action.
- replaced reaches service-worker onPush() handlers.
- puter.events.updateSchedule(scheduleId, changes), with the invoker
re-checking a schedule run's guard right before it is sent.
A throwing prepare/shutdown hook stopped the loop, so shutdown() skipped
closeAllConnections and every later hook, including the final metering
flush; each hook now runs and failures are logged. Broadcast, events
forwarding and share digests joined nothing already in flight and could
drop what was queued behind it; their final flushes now wait for it, ship
the tail, and drain once more at shutdown. Local worker disposal and the
OpenAI image metering call are awaited. settleWithin and
FINAL_FLUSH_TIMEOUT_MS bound each final flush.
The listener closes once every onServerDrainStart hook settles, and the
drain window now starts at the signal, so time a hook spends holding the
listener open counts against drainMs instead of extending shutdown. A
failing hook no longer skips the others.
With no domains, the only way on was the add button in the footer, which
the full-page popup pins to the bottom edge, far from the empty state the
eye lands on. The button now sits in the empty state under its hint, and
the footer keeps only Cancel until there's a domain to list.
* feat(gui): redesign the custom domain dialog, full page in its popup
- Domains list as cards with a status badge, trash icon, and inline
Cancel/Confirm for remove and move
- DNS records as cards with copy buttons that confirm with a check
- Status card on the records step; a green check once the domain serves
the site, after which the dialog closes
- Spinners on the button that's working, skeleton while loading, empty
and unavailable states
- Pasted URLs are trimmed to their hostname before adding
- In the attach-custom-domain popup the dialog fills the page, with the
actions pinned to the bottom
* fix(gui): Escape backs out of removing the chosen custom domain
Escape already put back a list row's pending confirmation. On the records
step, it closed the dialog while Remove was asking to confirm; it now puts
the button back first, like the rows.
* fix(gui): each custom domain step opens scrolled to the top
Switching steps reset the scroll of the dialog's body, which only scrolls
when the dialog fills a popup. The floating modal scrolls itself, so a
step opened from far down the list started scrolled past its status card.
* fix(gui): keyboard focus stays in the custom domain dialog
Focus on a control that got hidden or disabled fell to the page behind the
dialog, so Tab started over from there. Switching steps now focuses the
dialog, backing out of a row's confirmation returns to the button that
asked, a request gives focus back to the button that started it, and
removing a row moves focus to the next one.
* fix(gui): a removed custom domain row takes no input as it fades
Its buttons came back on once the delete finished, so a second click in
the fade sent the delete again, and Cancel brought back Use for a domain
that no longer exists. The row is inert until it's gone.
* fix(gui): DNS record copy buttons work without the clipboard API
On an origin with no navigator.clipboard (a Puter served over plain http)
or when the browser refuses the write, the copy buttons did nothing. They
now select the value and try the legacy copy, like the dashboard's form
dialog, and leave it selected to copy by hand if that fails too.
* fix(gui): custom domain rows keep the icon beside the name on phones
The name was sized to the row less 46px for the icon and gap, but the icon
is 36px wide with its border, so the row overflowed by 2px and every name
dropped below its icon. Only the actions wrap now; the name takes the rest
of the first line.
* fix(gui): hostnames in the custom domain dialog wrap whole
Every bold hostname broke at any letter, so one that would fit on the
next line was split mid-word at the end of this one ("puter.lo / calhost").
It now moves down whole and breaks only when it's wider than the line.
* fix(gui): readable Remove and Confirm buttons in dark mode
The red fill under their white labels used the danger text color, which
dark mode lightens to #f87171: 2.8:1 against white. Fills now use their
own token that stays #dc2626 (4.8:1); light mode is unchanged.
* fix(gui): the switch-to-www link shows when it's working
The link that switches a root domain to www had no disabled or loading
style, so while its request ran it still looked clickable and showed
nothing, while every other control dimmed. It now dims like them when
waiting and shows a spinner when it's the one working.
Each domain in the list gets a Remove button, confirmed in place like Move,
and the records pane can remove the domain just added. A removed domain is
never handed back as the dialog's answer.
Also fixes two strings that went stale: the claim prompt (an active domain
can be claimed too now) and the redirect note (root domains get two A
records).
PUT-2338
Co-authored-by: Claude Opus 5.5 <[email protected]>
* fix(puter-js): background push syncs never prompt for sign-in
The load-time sync and the resync after the browser replaces a
subscription ask for the key and announce the subscription without
opening Puter's sign-in; only enable() may. The confirmation and the
removal never open it, and looking up the user for the opt-in uses a
non-interactive /whoami. A rejected token during a background sync is
not reported as a sync failure.
* fix(puter-js): turning push off in one tab stops other tabs' syncs
Tabs of a site take a shared Web Lock to subscribe and announce, and
again to confirm, without holding it while the confirmation push is on
its way. A load sync re-reads the opt-in inside the lock and stops when
it is gone. disable() tells the other tabs first, so their syncs and any
enable() in flight stop, then forgets the opt-ins and removes the
subscription inside the same lock. Without Web Locks the re-reads and
the message still apply.
* fix(puter-js): wait for a push confirmation only once the lock is held
A sync queued behind another tab's lock started its 30 second
confirmation timer and listener before it got the lock, so a long wait
failed the sync or left the timeout unhandled. The listener now starts
inside the locked step, right before the subscription is announced.
* test(puter-js): reach the push sync in the boot sign-in e2e
The push page now has the user's opt-in and cached user from an earlier
load, starts its sync before boot's own requests come back, and gets a
rejected token from the push routes, so the spec covers the key request
that must not open a sign-in.
* fix(puter-js): sign-out and disable() remove only Puter's push subscription
Removal now looks at the subscription's application server key and ends
it only when it is Puter's: the key stored at the last subscribe, or the
server's key when none is stored. Any other subscription on the site is
left as it is and the server is not told about it. Without a named
worker, as at sign-out or disable() without serviceWorker, every
registration on the origin is checked, so a subscription under a folder
worker is the one removed.
* fix(puter-js): sign-out and a user change end this browser's pushes
Sign-out stops this tab's syncs, tells the site's other tabs, and inside
the push lock forgets every opt-in and tap, ends Puter's subscription in
the browser, then sends the removal with the leaving user's token as a
keepalive request so it is still sent if the page reloads.
puter.auth.signOut() returns a promise that resolves once that is done;
the token is still discarded at once.
A different user signing in over a session, or after the token was
dropped, gets the same cleanup with the token of the user signed in
before. A new token for the same user, or a dropped token, keeps
everything.
A page load finding a Puter subscription with no opt-in for the
signed-in user, or with nobody signed in, ends it in the browser. Opt-in
records that can't be read leave it as it is.
* fix(puter-js): record a push opt-in before other tabs can check it
enable() now writes the user's opt-in inside the push lock, right after
the server has the subscription and before the lock is released. A tab
loading at the same time waits for the lock, reads the opt-in, and keeps
the new subscription. A failed announce, or push turned off while it
ran, records nothing.
* fix(puter-js): push cleanup runs without the lock where Web Locks refuse
In a frame where navigator.locks.request rejects, such as a sandboxed
frame, sign-out, disable(), the load-time check and syncs now run their
step without the lock instead of rejecting, so signOut() still resolves
and the subscription is still ended.
* fix(puter-js): send the sign-out removal again when keepalive is refused
A keepalive request that needs a CORS preflight is refused by some
browsers. The removal at sign-out is then sent once more as a normal
request with the same token and without a sign-in prompt, after the
browser has already ended the subscription.
* test(puter-js): run the push boot test on full Chromium and skip it without push support
The headless shell keeps Notification.permission at 'denied' after the
permission is granted, so onPush() never synced there and the test saw
no key request. The test now launches full Chromium itself, since a
channel can't be set per test, and is skipped on WebKit and wherever the
page has no PushManager or notification permission.
* fix(puter-js): a user change finishes its push cleanup before enable()
enable() waits for the cleanup a user change started. When the token
was dropped and a different user signs in from enable(), the pushes of
the user signed in before end first, and the new user keeps their new
opt-in and subscription. A token that sign-out already cleaned up is
not cleaned up again when the next user signs in.
* fix(puter-js): a tap without a URL opens the site the push came from
A page that calls onPush() or enable() saves where taps resolve for its
worker: the worker's folder when the page is inside it, else the site's
root. push-sw.js and the notifications a page shows itself read relative
URLs from it, and fall back to the worker's scope when nothing is saved.
* fix(puter-js): push taps go to top-level windows only
push-sw.js routes taps, and holds back a page's notification for a focused
window, among top-level windows only, as the GUI's worker does. Challenges
and subscription changes still reach every window of the origin.
* fix(puter-js): deliver a push tap to the page once
push-sw.js keeps a tap before posting it to an open page. The page runs
its click handler only after taking that copy out of IndexedDB in one
readwrite transaction, and acknowledges only then. When no
acknowledgement comes in time, the worker takes the copy itself and opens
the tap's URL only if the page hadn't. The wait starts at the post and
ends at most 800 ms after the click, so openWindow still runs in time.
Pages and workers without this keep today's behaviour.
* fix(puter-js): clear stored push taps that nobody reads
push-sw.js removes taps kept over five minutes on every push and tap, and
a page sweeps them once when onPush() syncs. A page opened for a tap
ignores a kept tap older than that, and removes it when no click handler
is added within 10 seconds of loading.
* fix(puter-js): a late page already at the tap's URL still gets the tap
When no acknowledgement comes in time and the window is already at the
tap's URL, push-sw.js leaves the copy it kept for that page instead of
taking it back, so the page's click handler still runs when it answers
late. A copy nobody takes is removed with the other old taps.
* fix(puter-js): a push tap runs at most once when IndexedDB fails
A page that can't take a kept tap out of IndexedDB leaves it to the
worker instead of running it, and a worker whose claim fails leaves it
to the page instead of opening the tap's URL.
* fix(puter-js): take a tap back and keep it for the next page in one step
When no acknowledgement comes in time, push-sw.js takes its copy back and
keeps it, without the id, for the page it opens in one readwrite
transaction, leaving more of Firefox's window-opening allowance for
openWindow.
* test(puter-js): shorten the fake IndexedDB comment
* test(puter-js): keep the push boot test comment to one line
* fix(puter-js): a user signing back in with the same token gets the user-change cleanup again
The token that sign-out already cleaned up is skipped only for the next
user change. Once the same token is set again, a later sign-in by a
different user ends its pushes as usual.
* fix(static): push-sw.js refreshes at the CDN within five minutes
* feat(puter-js): the push worker can skip saved handlers
* fix(puter-js): off() keeps a saved push handler another tab still uses
* docs(notifications): self-hosting the push worker
* docs(notifications): name the pinned push worker version as a placeholder
* fix(puter-js): pages with push handlers can enter the back/forward cache
* test(puter-js): a restored page saves its push handler again
* docs(notifications): pin the push worker only with a pinned puter.js
* fix(puter-js): background push syncs never prompt for sign-in
The load-time sync and the resync after the browser replaces a
subscription ask for the key and announce the subscription without
opening Puter's sign-in; only enable() may. The confirmation and the
removal never open it, and looking up the user for the opt-in uses a
non-interactive /whoami. A rejected token during a background sync is
not reported as a sync failure.
* fix(puter-js): turning push off in one tab stops other tabs' syncs
Tabs of a site take a shared Web Lock to subscribe and announce, and
again to confirm, without holding it while the confirmation push is on
its way. A load sync re-reads the opt-in inside the lock and stops when
it is gone. disable() tells the other tabs first, so their syncs and any
enable() in flight stop, then forgets the opt-ins and removes the
subscription inside the same lock. Without Web Locks the re-reads and
the message still apply.
* fix(puter-js): wait for a push confirmation only once the lock is held
A sync queued behind another tab's lock started its 30 second
confirmation timer and listener before it got the lock, so a long wait
failed the sync or left the timeout unhandled. The listener now starts
inside the locked step, right before the subscription is announced.
* test(puter-js): reach the push sync in the boot sign-in e2e
The push page now has the user's opt-in and cached user from an earlier
load, starts its sync before boot's own requests come back, and gets a
rejected token from the push routes, so the spec covers the key request
that must not open a sign-in.
* fix(puter-js): sign-out and disable() remove only Puter's push subscription
Removal now looks at the subscription's application server key and ends
it only when it is Puter's: the key stored at the last subscribe, or the
server's key when none is stored. Any other subscription on the site is
left as it is and the server is not told about it. Without a named
worker, as at sign-out or disable() without serviceWorker, every
registration on the origin is checked, so a subscription under a folder
worker is the one removed.
* fix(puter-js): sign-out and a user change end this browser's pushes
Sign-out stops this tab's syncs, tells the site's other tabs, and inside
the push lock forgets every opt-in and tap, ends Puter's subscription in
the browser, then sends the removal with the leaving user's token as a
keepalive request so it is still sent if the page reloads.
puter.auth.signOut() returns a promise that resolves once that is done;
the token is still discarded at once.
A different user signing in over a session, or after the token was
dropped, gets the same cleanup with the token of the user signed in
before. A new token for the same user, or a dropped token, keeps
everything.
A page load finding a Puter subscription with no opt-in for the
signed-in user, or with nobody signed in, ends it in the browser. Opt-in
records that can't be read leave it as it is.
* fix(puter-js): record a push opt-in before other tabs can check it
enable() now writes the user's opt-in inside the push lock, right after
the server has the subscription and before the lock is released. A tab
loading at the same time waits for the lock, reads the opt-in, and keeps
the new subscription. A failed announce, or push turned off while it
ran, records nothing.
* fix(puter-js): push cleanup runs without the lock where Web Locks refuse
In a frame where navigator.locks.request rejects, such as a sandboxed
frame, sign-out, disable(), the load-time check and syncs now run their
step without the lock instead of rejecting, so signOut() still resolves
and the subscription is still ended.
* fix(puter-js): send the sign-out removal again when keepalive is refused
A keepalive request that needs a CORS preflight is refused by some
browsers. The removal at sign-out is then sent once more as a normal
request with the same token and without a sign-in prompt, after the
browser has already ended the subscription.
* test(puter-js): run the push boot test on full Chromium and skip it without push support
The headless shell keeps Notification.permission at 'denied' after the
permission is granted, so onPush() never synced there and the test saw
no key request. The test now launches full Chromium itself, since a
channel can't be set per test, and is skipped on WebKit and wherever the
page has no PushManager or notification permission.
* fix(puter-js): a user change finishes its push cleanup before enable()
enable() waits for the cleanup a user change started. When the token
was dropped and a different user signs in from enable(), the pushes of
the user signed in before end first, and the new user keeps their new
opt-in and subscription. A token that sign-out already cleaned up is
not cleaned up again when the next user signs in.
* fix(puter-js): a tap without a URL opens the site the push came from
A page that calls onPush() or enable() saves where taps resolve for its
worker: the worker's folder when the page is inside it, else the site's
root. push-sw.js and the notifications a page shows itself read relative
URLs from it, and fall back to the worker's scope when nothing is saved.
* fix(puter-js): push taps go to top-level windows only
push-sw.js routes taps, and holds back a page's notification for a focused
window, among top-level windows only, as the GUI's worker does. Challenges
and subscription changes still reach every window of the origin.
* fix(puter-js): deliver a push tap to the page once
push-sw.js keeps a tap before posting it to an open page. The page runs
its click handler only after taking that copy out of IndexedDB in one
readwrite transaction, and acknowledges only then. When no
acknowledgement comes in time, the worker takes the copy itself and opens
the tap's URL only if the page hadn't. The wait starts at the post and
ends at most 800 ms after the click, so openWindow still runs in time.
Pages and workers without this keep today's behaviour.
* fix(puter-js): clear stored push taps that nobody reads
push-sw.js removes taps kept over five minutes on every push and tap, and
a page sweeps them once when onPush() syncs. A page opened for a tap
ignores a kept tap older than that, and removes it when no click handler
is added within 10 seconds of loading.
* fix(puter-js): a late page already at the tap's URL still gets the tap
When no acknowledgement comes in time and the window is already at the
tap's URL, push-sw.js leaves the copy it kept for that page instead of
taking it back, so the page's click handler still runs when it answers
late. A copy nobody takes is removed with the other old taps.
* fix(puter-js): a push tap runs at most once when IndexedDB fails
A page that can't take a kept tap out of IndexedDB leaves it to the
worker instead of running it, and a worker whose claim fails leaves it
to the page instead of opening the tap's URL.
* fix(puter-js): take a tap back and keep it for the next page in one step
When no acknowledgement comes in time, push-sw.js takes its copy back and
keeps it, without the id, for the page it opens in one readwrite
transaction, leaving more of Firefox's window-opening allowance for
openWindow.
* test(puter-js): shorten the fake IndexedDB comment
* test(puter-js): keep the push boot test comment to one line
* fix(puter-js): a user signing back in with the same token gets the user-change cleanup again
The token that sign-out already cleaned up is skipped only for the next
user change. Once the same token is set again, a later sign-in by a
different user ends its pushes as usual.
* fix(puter-js): a page load checks push status instead of registering again
* fix(puter-js): subscriptions made with an earlier Puter key are replaced
* fix(puter-js): a replaced browser subscription is picked up on the next visit
* fix(puter-js): a page load that only checks push status still looks for a newer worker
* fix(puter-js): the worker subscribes again only where a Puter page used it
* docs(puter-js): soften the note on subscriptions Puter made earlier
* fix(puter-js): a status answer on load leaves a subscription another tab just turned on
* feat(events): fetch can start from the newest event
GET /events/fetch takes from=end: with no cursor it answers an empty page
whose cursor sits at the newest row of the slice, so later reads return
only rows created or replaced after it. puter.events.fetch() passes
`from` through. Adds a (user_id, audience, app_uid, id) index on
notification so the newest row of a slice is one seek.
* fix(puter-js): onPush starts its feed from the newest notification
A notifications listener started without catch-up asks for from: 'end'
and keeps the cursor it gets back instead of reading every stored
notification to mark them as seen. A server that ignores `from` answers
with the oldest page, and the listener reads through it as before.
* fix(events): read the newest notification id from the primary
* fix(events): mark the end cursor at the slice's latest replacement
The end cursor's replacement mark is the newest (replaced_at, id) in the
slice rather than the serving node's clock, since replaced_at is stamped
by whichever node wrote it.
* docs(events): describe the end cursor's replacement mark
An app created for an origin keeps its uid as name and title, so its
notifications showed that uid as the sender. appMessageSender now names
such an app by the hostname of its index_url, and keeps the stored name
when index_url doesn't parse. The check AppDriver used for these rows
moves to util/appView so both share it.
The Settings app list shows a generic label for an app whose title is
still its uid.
* feat(events): schema for user-scope handlers
Add event_user_handlers and event_subscriptions.handler_scope in all three
dialects. event_handlers is unchanged.
* feat(events): scope the handler and durable subscription stores
Handler store methods take a scope (app, or a user's scope of an app) and read
the matching table. App-scope behavior, SQL and cache keys are unchanged; user
scopes get their own cache keys, a 20-handler cap, and the inline @<sha256>
name. Adds user-scope listing, counting, deletion and unbound-handler reads.
Durable subscriptions store and return handlerScope, and listByHandler
filters by scope and holder.
* fix(events): hold the user-scope handler cap under concurrency and drop all scope caches on app delete
A create that lands past the cap removes itself and is refused. deleteForApp
drops each holder's cached set in batches, here and on peers. Batch scans over
user scopes and unbound handlers take limit and cursor.
* feat(events): set a CPU limit on events worker deploys
* test(events): cover redeploy and resend of an evicted events script
* test(events): name the deploy-on-miss test for what it checks
* fix(events): keep a deploy-on-miss call inside the delivery lease
* feat(workers): cap rented units per plan and refuse only new ones
* feat(events): publish handlers into a user scope, with inline create
* test(events): cover user-scope publish, inline create and listing
* test(workers): name the credentials gate without a vendor
* feat(events): run user-scope handlers as the holder
A user scope deploys one script per handler under the holder's own
identity, throttled per scope, with an eager deploy after publish that
skips an upload another holder already made. Delivery and schedule runs
address the holder's handler, carry the holder on the invoke headers,
batch calls, the miss handler and the rehydrate route, and stay owed
while the flag is off. Suspensions of a user scope notify the holder,
teardown (app delete, consent revoke, account delete) drops the scope,
and a job deletes unbound handlers after a day.
* test(events): cover user-scope deploy, address, notices, teardown and the sweep
Runs an inline user-scope handler through the local worker runtime as its
holder, and covers the holder on the wire, the flag-off path, revoke and
account-delete teardown, and the unbound-handler sweep.
* test(events): a lagging replica read does not suspend a user-scope schedule run
* feat(puter-js): inline user-scope handlers and scope option for handlers and workers
* docs(events): document inline and user-scope handlers, with playground examples
Cover inline handlers on onSchedule and onPersistent, handlerScope, the
scope option on handlers and workers, the user-scope limits and rented
units, and the new error codes. Add six playground examples and pin the
new limit constants in limits.test.ts.
* feat(schedules): carry the handler scope on the schedule view
* docs(events): describe user-handler cleanup, schedule handlerScope and suspension notices
* fix(events): gate the rented-unit cap on user handlers and let an account session destroy its own user scope
* fix(events): keep user-scope rows off app handlers across mixed versions and bound their deploys and reads
Cached durable rows carry an explicit scope marker; an unmarked entry with a
handler is resolved from the table and rewritten. User-scope rows are parked
before credit checks, billing, client delivery and suspension while the flag
is off. Delivery-time handler checks and addressing share the replica cache
entry. Concurrent eager deploys of one user-scope script upload once.
* fix(events): keep a user scope's age across handler churn for rent
event_user_handlers.scope_since records when the scope first held a handler.
A new row takes the scope's existing minimum, so replacing code does not make
the scope look newer to the monthly sweep. The count uses it instead of the
oldest remaining handler's created_at.
* fix(events): drop cached entries no row backs; document account-session scope destroy
* test(jobs): list the user-handler sweep among the system jobs
* fix(events): let the user-handlers migration apply on its own
* fix(events): fail a cached-row read whose scope lookup fails instead of returning app-scope rows
* fix(events): touch a reused user-scope handler before binding so the sweep cannot take it
* fix(events): bound the deploy-on-miss wait by the call's deadline
stream.pipeline adds seven close listeners to the response; with the ones
metering, compression and tracing add, every streamed /drivers/call passed
Node's ten-listener limit and logged MaxListenersExceededWarning. pipe plus
explicit error and close handling adds two, and keeps the same behavior: a
source failing mid-body aborts the response and logs once, and a client
hanging up stops the source.
* feat(gui): type a path into the dashboard Files path bar
Clicking the path bar anywhere but on a crumb, or typing /, swaps the
breadcrumbs for a text field. Enter opens the folder typed (or a file's
folder with the file selected); ~ is home and relative paths start from
the folder on screen. Escape or clicking away puts the crumbs back, and
a path to nothing shows an alert and keeps the field open.
* fix(gui): closing the path alert with Escape gives the path field back
UIAlert only settles when a button is clicked, so an alert closed with
Escape left the path field waiting forever: it no longer closed on blur
and never got the focus back. Wait for the alert window to close
instead.
* fix(gui): look a typed path up once, however often Enter is pressed
A second Enter (or a held key) during the lookup started another one,
and for a path to nothing stacked a second alert on the first.
* fix(gui): leave Enter and Escape to an IME composing in the path field
Picking a candidate with Enter while typing a name through an input
method went to the typed path, and Escape to cancel the composition
closed the field.
* fix(gui): arrow keys go on from rows the Files tab selects for you
Selecting a typed file, or the items just uploaded or pasted, left the
keyboard's starting row on whatever was selected before, so Down jumped
next to that row instead of the new selection.
* fix(gui): opening the path field no longer moves a narrow Files header
On phones and in narrow lists the field was shorter than the crumb row
it replaced and dropped its divider, so the buttons and the listing
jumped up as it opened. It now takes a crumb's height and keeps the
divider.
* fix(gui): space the path field from the Files toolbar buttons
Its border ran up against the sort button, a few pixels from the icon,
where the toolbar's other items keep 10px apart.
* fix(gui): phone keyboards leave a typed path as typed
iOS autocorrect rewrote folder names in the path field as they were
typed. Turn it off, as the GUI's other name fields do, and label the
keyboard's Enter key Go.
* fix(gui): a typed / or .. reaches the root folder
The root can't be stat'ed, so the path field reported "/" as not found,
even though Up goes there. Like Shared, it is now opened without a
lookup.
* fix(gui): Escape calls off a path lookup still running
Escape closed the path field, but the lookup behind it carried on: the
tab still went to the path once it answered, or alerted that it wasn't
found, and a path typed into the reopened field was ignored until then.
* fix(puter-js): a tap without a URL opens the site the push came from
A page that calls onPush() or enable() saves where taps resolve for its
worker: the worker's folder when the page is inside it, else the site's
root. push-sw.js and the notifications a page shows itself read relative
URLs from it, and fall back to the worker's scope when nothing is saved.
* fix(puter-js): push taps go to top-level windows only
push-sw.js routes taps, and holds back a page's notification for a focused
window, among top-level windows only, as the GUI's worker does. Challenges
and subscription changes still reach every window of the origin.
* fix(puter-js): deliver a push tap to the page once
push-sw.js keeps a tap before posting it to an open page. The page runs
its click handler only after taking that copy out of IndexedDB in one
readwrite transaction, and acknowledges only then. When no
acknowledgement comes in time, the worker takes the copy itself and opens
the tap's URL only if the page hadn't. The wait starts at the post and
ends at most 800 ms after the click, so openWindow still runs in time.
Pages and workers without this keep today's behaviour.
* fix(puter-js): clear stored push taps that nobody reads
push-sw.js removes taps kept over five minutes on every push and tap, and
a page sweeps them once when onPush() syncs. A page opened for a tap
ignores a kept tap older than that, and removes it when no click handler
is added within 10 seconds of loading.
* fix(puter-js): a late page already at the tap's URL still gets the tap
When no acknowledgement comes in time and the window is already at the
tap's URL, push-sw.js leaves the copy it kept for that page instead of
taking it back, so the page's click handler still runs when it answers
late. A copy nobody takes is removed with the other old taps.
* fix(puter-js): a push tap runs at most once when IndexedDB fails
A page that can't take a kept tap out of IndexedDB leaves it to the
worker instead of running it, and a worker whose claim fails leaves it
to the page instead of opening the tap's URL.
* fix(puter-js): take a tap back and keep it for the next page in one step
When no acknowledgement comes in time, push-sw.js takes its copy back and
keeps it, without the id, for the page it opens in one readwrite
transaction, leaving more of Firefox's window-opening allowance for
openWindow.
* test(puter-js): shorten the fake IndexedDB comment
* fix(puter-js): sign-out and disable() remove only Puter's push subscription
Removal now looks at the subscription's application server key and ends
it only when it is Puter's: the key stored at the last subscribe, or the
server's key when none is stored. Any other subscription on the site is
left as it is and the server is not told about it. Without a named
worker, as at sign-out or disable() without serviceWorker, every
registration on the origin is checked, so a subscription under a folder
worker is the one removed.
* fix(puter-js): sign-out and a user change end this browser's pushes
Sign-out stops this tab's syncs, tells the site's other tabs, and inside
the push lock forgets every opt-in and tap, ends Puter's subscription in
the browser, then sends the removal with the leaving user's token as a
keepalive request so it is still sent if the page reloads.
puter.auth.signOut() returns a promise that resolves once that is done;
the token is still discarded at once.
A different user signing in over a session, or after the token was
dropped, gets the same cleanup with the token of the user signed in
before. A new token for the same user, or a dropped token, keeps
everything.
A page load finding a Puter subscription with no opt-in for the
signed-in user, or with nobody signed in, ends it in the browser. Opt-in
records that can't be read leave it as it is.
* fix(puter-js): record a push opt-in before other tabs can check it
enable() now writes the user's opt-in inside the push lock, right after
the server has the subscription and before the lock is released. A tab
loading at the same time waits for the lock, reads the opt-in, and keeps
the new subscription. A failed announce, or push turned off while it
ran, records nothing.
* fix(puter-js): push cleanup runs without the lock where Web Locks refuse
In a frame where navigator.locks.request rejects, such as a sandboxed
frame, sign-out, disable(), the load-time check and syncs now run their
step without the lock instead of rejecting, so signOut() still resolves
and the subscription is still ended.
* fix(puter-js): send the sign-out removal again when keepalive is refused
A keepalive request that needs a CORS preflight is refused by some
browsers. The removal at sign-out is then sent once more as a normal
request with the same token and without a sign-in prompt, after the
browser has already ended the subscription.
* fix(puter-js): a user change finishes its push cleanup before enable()
enable() waits for the cleanup a user change started. When the token
was dropped and a different user signs in from enable(), the pushes of
the user signed in before end first, and the new user keeps their new
opt-in and subscription. A token that sign-out already cleaned up is
not cleaned up again when the next user signs in.
* fix(puter-js): a user signing back in with the same token gets the user-change cleanup again
The token that sign-out already cleaned up is skipped only for the next
user change. Once the same token is set again, a later sign-in by a
different user ends its pushes as usual.
* fix(puter-js): background push syncs never prompt for sign-in
The load-time sync and the resync after the browser replaces a
subscription ask for the key and announce the subscription without
opening Puter's sign-in; only enable() may. The confirmation and the
removal never open it, and looking up the user for the opt-in uses a
non-interactive /whoami. A rejected token during a background sync is
not reported as a sync failure.
* fix(puter-js): turning push off in one tab stops other tabs' syncs
Tabs of a site take a shared Web Lock to subscribe and announce, and
again to confirm, without holding it while the confirmation push is on
its way. A load sync re-reads the opt-in inside the lock and stops when
it is gone. disable() tells the other tabs first, so their syncs and any
enable() in flight stop, then forgets the opt-ins and removes the
subscription inside the same lock. Without Web Locks the re-reads and
the message still apply.
* fix(puter-js): wait for a push confirmation only once the lock is held
A sync queued behind another tab's lock started its 30 second
confirmation timer and listener before it got the lock, so a long wait
failed the sync or left the timeout unhandled. The listener now starts
inside the locked step, right before the subscription is announced.
* test(puter-js): reach the push sync in the boot sign-in e2e
The push page now has the user's opt-in and cached user from an earlier
load, starts its sync before boot's own requests come back, and gets a
rejected token from the push routes, so the spec covers the key request
that must not open a sign-in.
* test(puter-js): run the push boot test on full Chromium and skip it without push support
The headless shell keeps Notification.permission at 'denied' after the
permission is granted, so onPush() never synced there and the test saw
no key request. The test now launches full Chromium itself, since a
channel can't be set per test, and is skipped on WebKit and wherever the
page has no PushManager or notification permission.
* test(puter-js): keep the push boot test comment to one line
* fix(ui): honor the default font in showFontPicker on the desktop
String arguments are normalized to { defaultFont } in the IPC handler,
matching showColorPicker, instead of throwing on property assignment.
UIWindowFontPicker reads defaultValue/defaultFont/default case-
insensitively, and the SDK fallback and docs accept the same aliases.
* fix(ui): document the showFontPicker cancel result and alias order
The JSDoc gains overloads for the string and options call forms and
states that closing the picker resolves false in an app and null on a
website; the docs page and examples match. e2e coverage adds a
scroll-into-view check for the preselected font and a close-without-
selecting case.
* feat(db): add nowMsExpr, the database clock in epoch milliseconds, and share the retry backoff
* feat(config): type the jobs config as an augmentable IJobsConfig and resolve its namespace in one place
* feat(jobs): add JobScheduler for fleet- and region-wide system jobs
* refactor(jobs): run core's periodic sweeps as system jobs, and publish the generation bumps of reaped subscriptions
* feat(events): seams for schedules run by an extension, with events_disabled where none is installed
* feat(notifications): add notification.send_key, the deferred mailbox write and notify({ sendAt }) through the schedules contract
* feat(puter.js): schedules, notify({ sendAt }) and notifications.cancel(), and treat a 503 events_disabled as final
* docs(events): document schedules, sendAt and cancel(), with recipes
* fix(events): stop resuming a holder's later pages once the credit sweep's signal aborts, and keep its checkpoint
* fix(events): pre-release fixes for jobs, schedule seams and delayed sends
- The delayed-send write is a field on the notifications driver, not a
method, so /drivers/call can't reach it.
- A delayed send checks for its own earlier write by uid, so it takes no
gap lock that every other notification insert waits on.
- The notification send_key index builds in place without locking writes.
- Reaps re-check expiry and suspension on the primary, so a row resumed
since the replica read is kept.
- Revocation settles skip schedule rows they can't settle.
- An empty handler set read from a replica is re-read on the primary.
- One ended notice per settle, not one per page.
- Sweeps that run once per fleet take more batches per run.
- The global usage rate check keeps its 14-minute minimum window.
- shutdown() waits for prepare hooks a drain already started.
* fix(puter.js): quota refusals are final, and schedule error codes match the server
Schedule, subscription and handler quota 429s no longer go through the
generic retry. ScheduleErrorCode lists what the schedule methods throw,
and the notify({ sendAt }) overload has type tests.
* docs(events): schedule and delayed-send accuracy and style pass
Spread and send-time wording match the runner, the per-user runs-at-once
and rate-limited stored notification limits are published, and the new
pages follow the docs writing rules.
* docs(events): add the Change Data at a Set Time recipe
A one-time schedule that changes data when it runs, cancelling and moving
it, and a repeat-a-few-times variant with maxRuns.
* fix(events): delayed sends recheck the app's access when they fire, and a handler set missing the run's handler is re-read on the primary
Also stops job polling at drain start and marks replayable notification sends.
* feat(events): batch handler invocations to the events dispatcher
Invocations that come due within a few milliseconds go out as one
/invoke-batch request (up to events.invokeBatchSize, default 60). The
dispatcher runs a few at a time and streams a started line and an answer line
per call, so a slow handler delays no other call's answer. A call's timeout
runs from its started line, so time queued behind other calls is never the
handler's; queue time comes out of the connection-wait share instead, and a
call still queued past it is never started and fails as Puter's.
A lone call still uses /invoke, and a dispatcher without the batch route gets
single calls for five minutes before the batch route is tried again.
Chat Completions rejects `prompt_cache_breakpoint.ttl` whatever its
value, so a request whose content parts name a breakpoint lifetime is
handed to the Responses provider like a request-level `ttl: '12h'`. The
`/openai/v1/responses` translator keeps `prompt_cache_breakpoint` on
text, image and file parts instead of dropping it with the other unknown
fields.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
GPT-5.6 and later write prompt-cache entries with a 30m or 12h lifetime
and bill them at 1.25x and 2.5x input; every write was metered at the
30m rate. The OpenAI calculators now split `cache_write_tokens_details`
into `cache_write_tokens` (30m) and `cache_write_12h_tokens` (12h), the
GPT-5.6+ catalog carries the 12h rate, and `usageDetails` reports
`cacheWrite30mTokens` / `cacheWrite12hTokens` instead of labelling the
writes as 5m entries.
Callers pick the lifetime with `prompt_cache_options` ({ mode, ttl }) on
`puter.ai.chat`, `/openai/v1/responses` and `/openai/v1/chat/completions`.
The Chat Completions provider hands a 12h request to the Responses
provider, the only API that writes 12h entries. `prompt_cache_retention`
is forwarded on Chat Completions too, and the hyphenated `in-memory`
spelling maps to the `in_memory` value the upstreams accept.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
* docs: complex fs recipe (PUT-1978)
A drop folder that reacts to files landing in it, using `puter.events` with an
`fs:` subject instead of polling `readdir`.
One use case per the recipe guidance: watch, ignore your own writes, recover
from a gap, stop watching. The gap section works out what is already done from
the folder itself rather than a counter, so a dropped delivery costs a listing
and never a file.
* docs: rework fs recipe into a CSV importer (PUT-1978)
Renames the recipe to "Import Files From a Folder" and reframes it around
importing CSV files with puter.fs and onLocal(). Watches the app's own
inbox folder, imports files moved in from another folder, and catches up
on open and on gaps. Drops the event.self check, which skipped every file
the user added.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* refine 2
---------
Co-authored-by: Reynaldi Chernando <[email protected]>
Co-authored-by: Claude Opus 5.5 <[email protected]>
* docs: refresh KV sharing, Events and Notifications docs and recipes
Bring the KV, Events and Notifications reference pages and the recipes that
use them in line with current behavior, and tighten them.
- KV: share levels, re-share and recipient rules, handle scoping, list
limits, the 399 KB value limit, and full error tables per method.
- Events: kv:<handle> subjects, what each way of losing access does to a
subscription, fetch() notification types, handler ownership, and accurate
anchor-deletion and deleted-app behavior.
- Notifications: where a push goes, desktop and website behavior, sensitive
taps, share-notify limits, and enable()/status() errors.
- Recipes: fix broken code paths (undefined helpers, single-page share
listings, read-modify-write races, oversized batch writes, missing value
fallbacks), use events.update(), onlyIfAbsent and batch share recipients
where they fit, and cut repetition.
- New recipes: Add a Notification Settings Switch, Clean Up Background Work.
- Rename push-when-closed to reach-a-user-when-your-app-is-closed, drop
playground examples that need an app the reader owns, and redirect both.
* fix(workers): send CORS on router 404s and readable 500 bodies
Unmatched paths and methods now carry Access-Control-Allow-Origin like
every other router response, so a browser sees the 404 instead of a CORS
failure. A handler response with immutable headers is copied rather than
turned into a 500, and a thrown puter.js error object is sent as JSON.
* fix(kv): one error code for oversized values, and SDK types that match the store
The store now rejects a value over 399 KB with value_too_large, as the
SDK's own check and a growing path write already did. get() declares its
batch form, and the JSDoc gives the 399 KB limit and says watch shares
can list keys.
* docs(notifications): notifications sent within 250 ms reach live listeners as one
* test(events): give the per-handle subscription cap test room on a loaded runner
It fills the whole cap of 1,000 session subscriptions one add at a time,
which went past vitest's 5 s default on a busy CI shard.
"carries on from its record when one run runs out of time" seeds and
retires a full batch of share handles. It takes under a second alone but
went past vitest's 5 s default on a busy CI shard. The timed-out body kept
running with Date.now moved ahead, and "runs once when two runners race
for it" then failed with it.
* fix(events): queue every row's single delivery before any handler runs
A dispatch queued each matched single row's event and then waited on that
row's hand-over, which can be a whole handler run, before queueing the next
row's. An event matching several rows queued the later ones only after the
earlier handlers finished, and lost them if the process stopped in between.
A batched filesystem write did the same across its entries.
The event is now queued in line and the hand-over runs detached; anything it
doesn't finish is retried by the pending sweep. A hand-over that throws no
longer raises the 'could not be queued and is lost' alarm, since the event
is still queued.
* perf(events): address an app's events worker from a cached handler set
Every handler invocation read the app's handler set (names and hashes)
from the primary database to work out which script to call. The delivery
path now reads it through a Redis cache. Publishing, updating or removing a
handler, and deleting the app's handlers, drop the entry on this node and
every peer, so only a read that raced one of those writes can address the
superseded script, for at most 30 seconds. An empty set isn't cached.
Deploys and the events-worker listing still read the primary.
* perf(events): skip leased queues without a database read, and sweep in parallel
The pending sweep read each queue's subscription row from the primary
database before finding out whether anything in the queue could be claimed.
A queue whose head is in flight or waiting out a retry now goes to the back
of the line on a Redis check alone; its row is read once the lease lapses.
The backstop re-check of a queued row's access waits for that lapse too, and
nothing is handed out in the meantime.
The pass also worked through its subscriptions one at a time, so one slow
handler run held up every queue behind it. It now works on up to ten at
once.
* fix(events): don't count a wait for a dispatcher connection as a handler timeout
Invocations share one pool of connections to the events dispatcher per
process, and the invoke timeout started when a call was made, not when it
got a connection. With the pool busy, a call could spend its timeout
waiting, be reported as a handler that ran past it, and count toward
suspending a subscription whose handler never ran.
A call now waits for a free connection before its timeout starts. A call
that waits more than half the timeout for one fails as Puter's
(unavailable): retried on the usual schedule and not counted, as the
documented failure rules already say. Wait and run together stay inside the
worker lease.
* fix(events): end a background row whose consent a delivery finds withdrawn
Withdrawing an app's events:background consent is settled by a listener on
the node that handled the revoke. If that settle never ran or failed, the
row stayed in service: every delivery found the consent gone, minted no
token and was deferred, so the backlog was retried forever and the row was
never suspended or reported.
When the delivery path's consent check answers no, it now runs the same
revocation settle (permission_revoked, backlog purged, holder told, worker
session revoked), one at a time per holder and app. A consent check that
throws still only defers the delivery.
* perf(auth): record no session or user activity for events handler calls
Every API call an events handler made authenticated through its worker
session and touched the session and the user, so background handler runs
wrote session and user activity rows on the primary database (each at most
once a minute), and replaced the session's last IP and user agent with the
worker runtime's.
Calls made with an events handler token no longer touch either. A handler
run isn't the user being active, and the handler session expires only when
it is revoked, so nothing reads its activity to keep it alive. The handler
session's last activity in the session list now reads as when it was
created.
* fix(events): settle withdrawn consent only on a fresh answer
The delivery path's consent check reads through the permission scan cache
and the cached user-to-app grants, which can trail a grant, for example on
a node that cached the app's grants before the consent was given. A stale
no then ended the subscription for good (permission_revoked, backlog
purged, holder told, worker session revoked), where before it only
deferred the delivery until the caches caught up.
Before settling, the no is now asked again with a new `primary` scan
option, which skips the scan cache and reads user-to-app grants from the
primary database. Only a fresh no settles. A fresh yes, or a check that
throws, leaves the delivery deferred.
* test(events): assert no forwarded delivery, not no traffic at all
The region's own connect announces presence to its peers on the forward
queue's next flush. Now that a dispatch returns before the hand-over, the
wait for the socket send can outlast that flush, and the test saw the
presence announce as forwarding.
* fix(events): budget the settles deliveries start for withdrawn consent
A settle a delivery starts when it finds background consent withdrawn ends
rows for good. If the permission check itself answered no wrongly, every
row a delivery reached would be suspended, its backlog purged and its
holder told, with nothing to bring it back.
Each process now settles at most 20 such pairs a minute. Past that, the
rest stay deferred, as a cached no always left them, and a warning alarm
says so. A spent budget skips the fresh read too. A pair a fresh read just
found consented isn't read again for a minute, so a stale cached no costs
one primary read rather than one per delivery. Each settle is logged.
* feat(perms): add a pushNotifications resource to puter.perms.request
notifications:push gets a resource like backgroundEvents, so it can be
batched with other access behind one prompt. requestPermission() and
hasPermission() ask through it.
* fix(puter-js): read puter.gui_features once at load
The desktop's feature list was read from location.search on each call, so
an app whose router rewrote the URL lost it and enablePush() and the other
desktop dialogs answered as if the desktop lacked them.
* feat(gui): ask for notifications:push when an app calls enablePush without it
enable() and enablePush() inside the desktop answered unavailable for an
app without the push permission. They now show the permission dialog,
whose Allow also starts the browser registration, and answer with the
browser's push state once it settles, or off when denied. A browser that
blocks notifications or has no push service still answers without a
dialog.
* fix(kv-share): charge mint and grant budgets only for share calls that change something
A share call now spends the per-call mint budget and the per-recipient grant
windows after it has looked up which recipients already hold the share. A call
whose recipients all hold the share at the requested level and label spends
neither. Recipients without the share, including unknown usernames and refused
recipients, still spend a grant unit each. Every named recipient spends from a
new 1,200/min lookup window before any lookup runs.
* perf(events): skip the subject split for rows not made through a share handle
kvHandleFromSubject checks the stored subject's kv:kvh- prefix before
splitting it, so own-namespace and fs rows return null without parsing.
* feat(notifications): resolve enable() on a website before the confirmation push
enable() waited up to 30 seconds for the confirmation push, so a prompt
the same click started next had lost its activation. On a website it now
resolves once the browser answered and the server has the subscription,
records the opt-in, and finishes the confirmation in the background. A
failed confirmation goes to onPush's onError and the next load's resync
tries again. disable() and sign-out stop a confirmation still waiting.
The GUI keeps waiting, since it reads the registered device right after.
* fix(notifications): show a page's own notifications only while push is on
A hidden page with an onPush handler kept showing notifications after
disable(), and its receipts kept claiming showsOwn, which held pushes back
from the user's other devices. Both now need the user's opt-in, cached in
the page, refreshed on enable, disable, sign-out and each sync, and re-read
when another tab of the site changes it.
* fix(events): leave subscriptions suspended for good out of the durable quota
countForHolder no longer counts rows suspended with a reason that never
resumes (permission_revoked), in both the pre-insert check and the recount
after insert. Rows suspended for a resumable reason still count. The store now
owns the list of terminal reasons, and isResumable reads it.
listActiveForHolder with includeSuspended returns only rows suspended for a
resumable reason, so its quota-sized read still covers every row a revocation
has to settle. Each holder keeps at most 500 terminal rows; a subscribe past
that deletes the oldest ones.
* feat(notifications): add status() to read whether pushes reach this browser
status() resolves 'on', 'off', 'blocked' or 'unavailable' without asking
anything. On a website it reads the notification permission, this
browser's subscription and the user's opt-in. Inside the desktop a new
pushStatus IPC, advertised as push-status, answers the browser's push
state, 'on' only while the app holds notifications:push.
* feat(notifications): disable() inside the desktop revokes the app's push permission
disable() did nothing inside the desktop. A new disablePush IPC,
advertised as disable-push, revokes the calling app's notifications:push
grant through the same route Settings uses, so its notifications stop
going out as pushes and a later enable() shows the permission dialog
again.
* feat(notifications): allow @ in a notification group, up to 128 characters
A group is an opaque display tag, hashed for the Web Push topic and used
as the notification tag, so it can carry identifiers such as an email
address.
* docs(notifications): say when a page with an older Puter.js holds a push back
* fix(notifications): let disable() win over an enable() still in flight
An enable() whose subscription reached the server after a disable() went
on to record the opt-in, so the next page load turned pushes back on. It
now rejects with push_verification_canceled and records nothing.
* docs(notifications): list push_verification_canceled and reword sentences that led with code
* fix(events): carry KV values through a share handle only at read or write
The delivery re-check now reads the share's level off the live handle along
with whether it still holds. A row on a watch share loses its includeValue for
that delivery, so its events name the key without the value; the owner's own
rows on the handle keep theirs. Decided per delivery, so raising or lowering a
share applies to subscriptions already made, and subscribing with includeValue
at watch is still accepted.
The live-updates recipe now shares at read, which its events need to carry
the score.
* fix(events): retry worker runs Puter could not complete without counting them
Invocations that never reached the handler (transport and dispatcher errors,
unmarked answers, failed or throttled deploys, no configured runtime, a bad
invoke key or missing token) now report an 'unavailable' outcome. So does a
handler that throws after one of its own puter.js calls got a 5xx, 429 or
network error from Puter's API, which the events runtime reports as
'platform-error'. Those runs back off like retriable ones, honor the answer's
retry-after up to the backoff ceiling, and no longer count toward the
consecutive-failure suspension. A run past the invoke timeout still counts.
* test(notifications): cover the pushNotifications resource and status() in the API suites
* test(gui): Allow on a batch that includes notifications:push turns push on
* fix(events): hold a share-handle row's backlog when its share can't be checked
The share-handle delivery check now tells a share that is gone (handle not
live, not the row's holder, identity gone, grant withdrawn) from one it can't
check right now (a lookup that throws, an owner who is suspended). Only the
first settles a queued row as permission_revoked and purges its backlog. The
second delivers nothing, leaves the row in service with its backlog, and sends
it to the back of the sweeper's line, so delivery resumes once the lookup
recovers or the owner is unsuspended. A revocation settle skips such a row and
leaves it to the delivery check. kvSharedRegionAuthorized returns null when
the permission check throws, and that answer is not cached.
* feat(events): update a persistent subscription's context in place
POST /events/subscriptions/update replaces a durable row's context, with the
same validation and 4 KB cap as subscribe, keeping its subId and backlog. The
row's owner region cache is rebuilt from the table and the generation bumped so
every region delivers the new value. It spends the subscribe call budget.
puter.events.update(subId, { context }) wraps it; null clears the context, and
a function handler this client registered for the subscription gets the new
ctx.
* fix(events): serve a fan-out's holders in turns and cap values per delivery
#route now orders an event's candidate rows before any cap applies: the
keyspace owner's rows first, then the other holders taking turns, one row per
holder per round, starting from a random holder each event. The filter
evaluation cap, the delivery cap and the gap markers all count in that order.
Values no longer drop for every delivery once more than 128 rows match or the
filter evaluation stops early. The first 128 deliveries that asked for the
value carry it, in serving order, and later ones carry the key alone.
EVENTS_KV_VALUE_OMIT_MATCHED_SUBSCRIPTIONS becomes
EVENTS_KV_VALUE_DELIVERIES_PER_EVENT. The caps themselves are unchanged.
* fix(email): file inbox mail as a delivery, not the request actor's write
storeInboxMessage and copyIntoInbox now run their filesystem writes with a
platformWrite context flag, and fs event dispatch leaves actingUserId unset
under it. Mail filed into an account's inbox from inside that account's own
request (an app mailing the signed-in user) is delivered with self: false, the
same as external ingress. Handler depth and viaWorker are still carried from the
actor.
* feat(notifications): raise the default daily app message limit to 1000
The per-day default for `app.message` goes from 300 to 1000 per user and
app, matching the daily push cap. The per-minute default stays at 30, and
`notificationAppMessageLimits` still overrides both.
* feat(notifications): look up an unseen notification by group
`notification` gets a nullable `group_key` column, a sha256 hex digest of
the group a row was written with, indexed on (user_id, app_uid, group_key).
`NotificationStore.create` takes a `group`, and `getUnseenByGroup` returns
the newest row an app wrote for a user under that group that is neither
shown nor acknowledged. Rows written before the column exist never match.
`updateValue` takes `onlyUnseen`, which also skips a shown row, and
`refreshCreatedAt`, which dates the row to now.
Migrations: sqlite 0098, mysql_mig_52, postgres_mig_41.
* feat(notifications): replace an app's unseen message in the same group
An `app.message` with a `group` now replaces the app's earlier notification
to the same user with that group while it is neither shown nor
acknowledged. The row keeps its uid and takes the new value and time. With
no group, or once the earlier one was shown or dismissed, it is a new
notification as before.
A replacement spends a per-minute slot but no per-day slot. If the earlier
row is marked between the lookup and the rewrite, the message is written as
a new notification and spends a day slot then.
`app.message` is groupable, so the replacement goes through
`notifyUpdate`. For types kept off the desktop, `notifyUpdate` skips the
desktop wire and dispatches the rewrite again under the same uid, so app
subscribers and receipts see it, and `notif.written` carries `updated`.
The driver's write path takes the recipient id rather than the actor, so
rate limits key on the recipient.
* feat(workers): report the app a worker runs as
workers.create now returns app_uid and app_name for the app its token is bound
to (null for a user-scoped worker), and list/get items carry app_name beside
app_uid from the app rows they already load. The dev center workers table shows
an App column, and the SDK typedefs and docs describe the fields and that a
sandboxed worker's notifications and pushes come from its sandbox app.
* feat(events): refuse handlers with free variables on REST publish
POST /events/handlers/publish and /publishAll now run the SDK's own
scanHandlerSource on each source before publishing and answer 400
events_handler_free_variable, naming the identifier. The backend imports the
module from src/puter-js; the build marks dist/src/puter-js as an ES module
package so the compiled backend can require it. Sources over the per-handler cap
are left to the store's size refusal rather than tokenized.
* docs(events): say Puter-side retries never suspend and stay within backlog caps
* fix(events): redeploy an events worker that answers bad-key
A script deployed before the internal secret rotated keeps the old invoke key,
so every invocation answered bad-key and nothing redeployed it. A bad-key answer
now goes through the same miss handler as a dispatcher miss (the deployer, with
its hourly throttle) and is retried once with the deployed header. A throttled
or failed redeploy stays 'unavailable' and backs off without counting.
* perf(events): check the deploy throttle before reading handler sources
EventsWorkerDeployer now reads the app's handler names and hashes first,
answers no-handlers or stale from those, and checks the hourly deploy throttle
before loading the sources and generating the worker. A throttled retry costs a
hash read and a counter bump instead of a full source read and build, and a
stale request still does not spend the throttle. The generated set is checked
against the script again in case it moved between the two reads.
* fix(workers): move a redeployed worker's row to the app it now runs as
Redeploying an existing worker under a different app minted the new app's
token but left subdomains.app_owner on the old one, so list/get and hot-reload
kept naming and minting for the old app. The redeploy now sets app_owner to the
binding #resolveWorkerAppBinding authorized (null for a user-scoped worker)
through a new SubdomainStore.setAppOwner, since app_owner stays read-only to
update patches.
* fix(workers): revoke the old binding's worker session on a rebind
A redeploy that binds a worker to a different app (or to none) now deletes the
owner's worker sessions under that name other than the one for the new binding,
through the same deleteWorkerSessionsByName path worker deletion uses (which
also announces the revocation and drops access-token grants). SessionStore's
deleteWorkerSessions takes an exceptAppUid filter for it, null meaning the
user-scoped session. The app_owner move and the revocation now both run only
after the new script deployed successfully, so a failed redeploy leaves the
live script, its token and the row on the old binding.
* feat(kv-share): store a notify flag on share handles
kv_share_handles gains a notify column, off by default, so every existing
share reads as not allowing notifications (sqlite 0099, mysql_mig_53,
postgres_mig_42). The store reads it on every row, including the live
handle cache and the holder listing, writes it on mint, and changes it
through update().
* feat(kv-share): let the owner turn notifications on for a share
POST /kv/shares and puter.kv.share() take notify, off by default. Leaving it
out of a re-share keeps the stored value, and turning it on or off counts as a
change, so it spends the mint budget. Turning it on for a holder who now
refuses the owner's shares is refused, like a raise or a new label.
The kv.share.received notice is sent only when a share starts carrying
notify: a new share made with it, or an existing one switched on. Its hourly
bound and the app-less namespace exclusion are unchanged.
The share response, the owner's listing and the holder's listing report
notify.
* feat(kv-share): resolve the other party of a share for a notification
KvShareService.resolveNotifyRecipient(actor, handle) returns the recipient
user id, app uid and handle for a notification one party of a share sends
the other through its handle. The handle must be live with an unsuspended
owner, the caller must be its owner or holder acting from the app whose data
it is, the share must have notify on, the holder's grant must still stand,
and the recipient must not refuse shares from the sender.
An unknown, retired or foreign handle, a caller who is not a party, and a
caller in another app all get subject_does_not_exist. The account itself gets
forbidden, a share without notify kv_share_notify_not_enabled, and a lookup
that fails service_unavailable. Once every check passes it spends one unit
from KV_SHARE_NOTIFY_LIMITS (30/min, 500/day per sender, recipient and app),
peeking both windows first so a refusal spends nothing.
* feat(notifications): hand a replaced notification to listeners again
A replacement now records `replaced_at` (epoch ms) on its row, added to the
same unreleased migrations as `group_key` (the sqlite file is renamed to
0098_notification-groups.sql). Its live event and its fetched copy both
carry `replaced: true` and use that time as `ts`, so the pair versions the
uid identically on both paths.
`onNotification()` dedupes on uid plus that version: an original counts as
version 0 and a replacement as its `ts`. A newer replacement is handed over
again with `replaced: true`, while a copy already handed over, a replay, or
an older state is dropped. Listeners started with `catchUp: false` still
get replacements of what was already there.
* fix(notifications): replace only the newest notification in a group
The group lookup takes the newest row an app wrote under the group and
returns nothing when that row was shown or dismissed, instead of falling
back to an older row that is still unseen.
The docs state the rule as it is: a grouped notification replaces the
newest one in its group unless that was shown or dismissed. Puter.js
marks neither, and the only thing that does is a tap on the push inside
the Puter desktop, which dismisses it.
* feat(notifications): notify the other party of a KV share through its handle
`app.message` takes `to: { handle }`. The driver validates the message,
then `to` must be an object with only a non-empty string `handle`, else
400. `KvShareService.resolveNotifyRecipient` checks the share and spends
the pair limit, and the message is written for the share's other party in
the share's app. The recipient's per-app limits and group replacement
apply to their own rows, and the push is billed to them through
`notif.written`.
A call with `to` answers with `{ uid }` only, so nothing about the
recipient's row reaches the sender. puter.js gets the
`NotificationRecipient` type for `to`.
* docs(notifications): document notifying the other side of a KV share
notify.md covers `to: { handle }`: who can be reached, the errors, the
recipient's limits plus the per-pair share limit, the uid-only return and
that the recipient pays for pushes. push.md and rate-limits-and-quotas.md
point at the share path. The chat notification recipe sends through each
share's handle and keeps the per-member handler only for members who need
to reach each other; the alert recipe says when its handler is the right
fit.
* fix(notifications): reveal nothing to the sender of a notification through a share
A call with `to: { handle }` answers with an empty object, and puter.js
resolves it with no value: a uid would show whether the recipient's last
notification in a group was replaced. `notify()` gets one overload per
form, and `ShareNotificationInput` is exported.
Over the per-pair share limit or the recipient's own limits, such a call
gets the same 429 `too_many_requests` with the same generic message, so the
sender can't tell which limit it hit.
* fix(notifications): serialize grouped app messages per group
Grouped app.message sends take a Redis lock on (recipient, app, group) around the lookup and the replace-or-insert, so concurrent sends to an empty group write one row and spend one day slot. A lock still busy after the wait, or an unreachable Redis, sends unlocked as before. Sends without a group take no lock.
* fix(events): attribute Puter API failures to the invocation that saw them
The events worker runtime counted Puter API failures on the cached per-token client, so a concurrent invocation on the same token could answer platform-error for its own unrelated throw. A cached client now serves one invocation at a time and records failures and retry-after on that invocation; an invocation overlapping another on the same token gets its own client, and idle clients stay cached per token.
* fix(events): drop KV values from a queued backlog once its share is at watch
A queue delivery is stored with its value. Every hand-out of a stored
entry (backlog drain to a socket or another region, the worker, and a
declined delivery passed to the worker) now re-reads the share's level
and removes the value at watch. A share that can't be checked still
holds the backlog.
* fix(events): return notifications replaced behind the fetch cursor
The notification fetch cursor now holds the last row id and the last
(replaced_at, id) it returned. A read from it merges two indexed reads:
rows inserted after the id, and rows up to the id replaced after that
mark. A replacement made while a client was away therefore comes back
on its next catch-up. Adds idx_notification_replaced on (user_id,
audience, app_uid, replaced_at).
* fix(kv): harden the KV share lifecycle
- Retire a deleted app's live shares before an origin app is registered
again under the same uid. The retire runs before the app row exists and
throws on failure, so a failed launch leaves no row and the next one
retries.
- Record deleted-app retires and uninstall cleanups (retire, then purge) as
reserved items before they run. A lease keeps one node on a job; a failed
run raises an alarm and backs off, and a run that hits its time budget or
is cut short by a restart resumes on the next check. Each job is bounded
to handles made before it was recorded.
- Uninstalling with deleteData records the job and returns; the retire and
purge run off the request. The purge checks whether the app was
authorized again before deleting each page, so data written after that
is kept.
- Take a per-region lock when minting and re-check for a live handle under
it, so concurrent shares of one region make one handle.
- Retire the handles an account holds before the account is deleted, so the
owners' subscriptions through them end instead of staying active.
* fix(events): bound share-handle session rows by the per-connection cap
The count across a holder's connections also counted connections that
had dropped but weren't reaped yet (a crashed node, a deploy, a network
blip the server hasn't noticed), so resubscribes after a reconnect could
be refused with `events_subscription_limit`. Rows made through a share
handle now count toward the 50-per-connection cap like any other, and
with the open-connections cap per account that bounds what one holder
can put in the owner's keyspace.
The handle index stays for ending rows on retire. Rows are added to it
with the row itself and taken out when the row is dropped, and members
whose row is gone are cleared when a retire reads it.
* fix(events): keep a retire's durable settle running if ending sessions fails
Ending a retired handle's session subscriptions reads Redis. A failure
there is now logged and the durable suspension that follows still runs,
so those rows' backlogs are still purged.
* perf(events): ask once per handle when a refresh screens rows, and share handle reads
A socket refresh asks whether a share handle still stands once per
handle, holder and app, rather than once per row: rows through one
handle share its grant. Concurrent cache misses on one handle share a
single read; a forget drops the shared read so later callers start a
fresh one.
* fix(permissions): chunk the exact-match subtree delete
The holder-scoped subtree delete names every matched grant, so a large
subtree could exceed an engine's limit on bound parameters. It now
deletes 500 grants per statement.
* fix(kv): give each holder a share of a namespace's handle budget
One holder could spend a namespace's whole handle budget and starve the
others. Each holder now gets at most 3,750 of the namespace's 15,000
units per 10 seconds, and the owner's own calls through a handle spend
none. The holder's budget is checked before the grant lookups, so
throttled retries cost none; the namespace's is only told to callers the
grant admits. Multi-unit charges use a new counted claim that keeps two
counters per key instead of a sorted-set member per unit.
* fix(kv): refuse handles older than their app and claim cleanup jobs globally
- A share handle created before its app row reads as gone: in the KV
driver, in the handle cache, on the events subscribe path, and in the
delegate implicator. An origin app registered again under a deleted
app's uid can't reach its predecessor's shares, whatever created the row.
The app-token launch no longer retires inline; it records a background
retire and never waits on it or fails because of it. The clock-skew rule
is shared with app sessions.
- Cleanup job records now carry their lease, attempts and retry time, and
a node claims a job by compare-and-set on its record. A job stays with
the deployment that recorded or last ran it, and another one adopts it
only after it has gone unclaimed well past due.
- The job check pages through records from where the last look stopped, so
waiting records don't hide due ones. After 10 failed runs a job stops
retrying with a warning alarm, and its record expires after 30 days.
- A mint that waits out the region lock looks for the share again and
returns it, or goes ahead, instead of failing with too_many_requests.
- The record-failure alarm dedups; an unfinished run is a log line.
* fix(kv): hide shares older than their app from re-shares and listings
After an app is registered again under a deleted app's uid, its
predecessor's live handles stayed visible: sharing the same region again
handed back the old, dead handle, and both sides' listings showed the old
handles and labels. findLive and both listing queries now leave out a live
handle created before its app row, the same rule handle resolution uses,
so a re-share makes a new handle and the listings, totals included, skip
the old ones until their retire lands.
A new share also retires, grants and all, the handles its owner gave the
same holder for the earlier app first, so withdrawing those later can't
take the new grant with it.
* fix(events): keep a loose cap on subscriptions through one share handle
Without a count across connections, a holder could put up to 20,000 rows
in the owner's keyspace (50 per connection, 400 connections), each one
evaluated on every owner write. The cap is back at 1,000 per holder per
handle, far above what connections that dropped without being reaped
leave behind. The index is only read row by row once it's past the cap.
* fix(kv): harden the share cleanup jobs
- Job records are keyed by domain, so a deployment that shares the table
but not the database or broadcast (staging beside production) never
runs another's jobs.
- A job whose record can't be written runs unrecorded instead of not at
all, and a failed check for live handles records the job anyway so it
retries. Failures that used to be swallowed are logged.
- Recording a job again keeps its attempts and backoff; one that gave up
gets one more attempt.
- A system retire gives back the grant of a live share it doesn't cover
inside a withdrawn region, and a share made under a reused uid grants
again once its row stands, so a concurrent retire can't leave a live
handle without its grant.
* fix(kv): don't cache a live handle a retire overtook during its app lookup
The live read decided whether it could cache before awaiting the app
lookup, so a retire's forget that landed during that lookup was
overwritten with the handle as live for up to 60 s. It's now checked
after the lookup.
* chore(kv): word the job key comment for any install
* fix(permissions): chunk the exact-match subtree delete
The holder-scoped subtree delete names every matched grant, so a large
subtree could exceed an engine's limit on bound parameters. It now
deletes 500 grants per statement.
* fix(kv): give each holder a share of a namespace's handle budget
One holder could spend a namespace's whole handle budget and starve the
others. Each holder now gets at most 3,750 of the namespace's 15,000
units per 10 seconds, and the owner's own calls through a handle spend
none. The holder's budget is checked before the grant lookups, so
throttled retries cost none; the namespace's is only told to callers the
grant admits. Multi-unit charges use a new counted claim that keeps two
counters per key instead of a sorted-set member per unit.
* fix(events): bound share-handle session rows by the per-connection cap
The count across a holder's connections also counted connections that
had dropped but weren't reaped yet (a crashed node, a deploy, a network
blip the server hasn't noticed), so resubscribes after a reconnect could
be refused with `events_subscription_limit`. Rows made through a share
handle now count toward the 50-per-connection cap like any other, and
with the open-connections cap per account that bounds what one holder
can put in the owner's keyspace.
The handle index stays for ending rows on retire. Rows are added to it
with the row itself and taken out when the row is dropped, and members
whose row is gone are cleared when a retire reads it.
* fix(events): keep a retire's durable settle running if ending sessions fails
Ending a retired handle's session subscriptions reads Redis. A failure
there is now logged and the durable suspension that follows still runs,
so those rows' backlogs are still purged.
* perf(events): ask once per handle when a refresh screens rows, and share handle reads
A socket refresh asks whether a share handle still stands once per
handle, holder and app, rather than once per row: rows through one
handle share its grant. Concurrent cache misses on one handle share a
single read; a forget drops the shared read so later callers start a
fresh one.
* fix(events): keep a loose cap on subscriptions through one share handle
Without a count across connections, a holder could put up to 20,000 rows
in the owner's keyspace (50 per connection, 400 connections), each one
evaluated on every owner write. The cap is back at 1,000 per holder per
handle, far above what connections that dropped without being reaped
leave behind. The index is only read row by row once it's past the cap.
* fix(permissions): delete user-to-user grants by their exact spelling
MySQL compares the permission column case-insensitively, and LIKE folds
case on MySQL and SQLite, so withdrawing a grant could also remove one
spelled differently, which checks and the flat view treat as distinct.
The single, batch and subtree deletes for one holder now match the
permission byte for byte.
* fix(auth): refuse key-value share grants on /auth/revoke-user-user
Withdrawing a share's grant there left its handle live and listed. The
route now refuses kv-share permissions and points to puter.kv.unshare().
* perf(kv): ask whether a delegate app is authorized once per check
The app-delegate implicator re-read the app's authorization for every
option a refused check offered, one read per ancestor of a deep key.
* fix(puter-js): default a kvShare consent request to read
puter.kv.share() creates read shares by default, so a consent asked for
with the defaults (watch) didn't cover the share made with the defaults.
* fix(kv): treat a private entry as missing for a share holder's writes
Through a handle, a private entry reads as absent, but writes to it were
refused with an error naming it as private, and onlyIfAbsent answered
false. Deleting one through a handle now answers as deleting a missing
key does and leaves the entry in place. Any other write is refused with
kv_handle_access_denied, the same error as a call the share's level
doesn't allow, onlyIfAbsent included. Cross-app access is unchanged.
* fix(kv): cap handle traffic per owner namespace
Every call through a share handle lands on its owner's namespace, and
per-holder limits put no bound on what all holders together could send
there. Handle calls into one namespace now share a budget of 6,000 units
per 10 seconds, counted from the storage reads and writes each call
consumes (at least one), and are refused with too_many_requests past it.
* fix(events): keep a share's prefix out of handle subscribe errors and limits
A `kv:<handle>:<key>` pattern is now checked against the 256-character
limit as the holder wrote it, before it's composed onto the shared
prefix. The composed pattern is compiled with room for the longest
prefix a grant can carry, at subscribe and at dispatch, so neither the
limit nor its error depends on the prefix.
A handle subscription's `match` is read off the holder's own subject
instead of the stored composed pattern, so whether it's `null` no
longer depends on how deep or long the prefix is.
* fix(events): cap and end session subscriptions made through a share handle
Session subscriptions made through a share handle are indexed under the
handle in the owner's keyspace. A holder may keep 100 of them through
one handle across all their connections; past that, subscribe fails
with `events_subscription_limit`. The owner's own subscriptions through
the handle don't count.
Retiring a handle now ends its session subscriptions in the same
region and sends `events.ended` with `permission_revoked`, alongside
the existing durable settle. A socket refresh no longer renews a row
whose handle was retired or whose grant is gone: it ends it the same
way, which is how a retire made elsewhere reaches these rows.
* fix(kv): read a forgotten share handle from the primary
After a handle or its owner is forgotten, reads of that handle go to
the primary for the next minute, so a replica that hasn't caught up
can't put the old row back in the cache. A read that a forget lands on
while it's in flight is answered but not cached.
* fix(events): read a socket ref's subId after the last `|`
A KV token carries the subscribed key, which can contain `|`, so
splitting the ref at the second one cut the token short. A subscription
on such a key couldn't be unsubscribed, and a reap left its row behind
in the owner's keyspace. SubIds never contain `|`, so the ref is split
at the last one.
The base leg checked out the base branch from the PR's head repo. For a
fork PR that is the fork's copy of main, which can predate the current
Node version and fail npm ci.
/login/otp and /login/recovery-code marked the otp-login token used
through a rate-limit check that admits on a store error, so while the
store was unavailable a captured token could complete another sign-in.
The check now fails closed.
Also drops the check-translations scripts and the AGENTS.md step that
runs them; the script they call was removed in #2873.
An acked broadcast leaves its callback in socket.io's per-socket ack map on every socket it reaches, and nodes other than the asker free it only when that socket answers or disconnects. Each socket's map is now a BoundedTtlMap capped at 64 entries and 60 s, so a socket that never answers holds a bounded number and a late answer is ignored. BoundedTtlMap drops expired entries on set so its TTL frees memory too.
When the upstream realtime connection closes, for any reason, with a
started response that never sent response.done, the relay now records that
response at its credit hold (8k audio in + 4,096 audio out at the session
model's rates, through the AI cost factor) instead of releasing the hold
uncharged. The hold is released once the charge is recorded, and a warning
alarm is still raised.
Client conversation.item.retrieve events and MCP tools in session.update or
response.create are refused with an invalid_event error and a 1008 close.
Each notification in the dashboard list gets a remove (✕) control next to
mark-as-read. It calls the new POST /notif/delete, which deletes the user's
own row and sends `notif.removed` to their other tabs so the entry and any
toast for it disappear everywhere.
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Daniel Salazar <[email protected]>
Follow-up to #4408.
The 503 returned when a process holds too many large bodies is load
shedding, so it records an info alarm instead of a critical one.
One user can hold at most 128 MiB of large bodies at once; past that the
request gets 429 with Retry-After, so a single account can't take a
process's whole budget, e.g. with slow uploads.
Through /drivers/call only puter-chat-completion takes a body over 50 MiB;
other drivers keep the default limit and get 413. The check reads the
parsed body's interface, since route matching ignores case and trailing
slashes.
* fix(ai): accept larger Anthropic message requests
* fix(ai): accept 128 MiB bodies on /drivers/call and OpenAI routes
Extend the 128 MiB JSON limit to /drivers/call (puter.ai.*) and the
OpenAI-compatible routes, alongside the Anthropic ones.
A body over the default 50 MiB is read only after a header or cookie
credential resolves; otherwise the global parser rejects it with 413, so
unauthenticated callers can't make the server buffer and parse 128 MiB.
Large bodies skip the rawBody copy, and a per-process budget caps how many
bytes of them are held at once, returning 503 with Retry-After past it.
The prompt token estimate no longer splits text on whitespace, which took
seconds and allocated a string per word on large prompts. It counts
whitespace runs in a loop, scans at most 1 MiB per prompt and extrapolates
the measured density past that.
---------
Co-authored-by: Daniel Salazar <[email protected]>
The backend runs from dist/src/backend, so Node resolves modules from the
root node_modules, never src/backend/node_modules. Since sharp ^0.35.5
(#4081) only the backend asked for that version, so npm nested it under
src/backend while the root copy stayed miniflare's dev-only 0.35.4, which
the image doesn't ship. The 26.10.1 image then crash-loops on start with
"Cannot find module 'sharp'" from the Replicate image provider.
Declaring sharp ^0.35.5 at the root hoists the backend's version, the
same way other backend runtime deps are reachable from dist.
Fixes#4394
Co-authored-by: Daniel Salazar <[email protected]>
* ai-chat: fallback: false fails on the first route instead of rerouting
A chat request whose first provider fails is retried on up to two other
providers that serve the same model, and the caller pays for whichever
one answers. Callers who want one specific route, or who want to handle
a vendor failure themselves, had no way to turn that off.
Passing fallback: false (puter.js option, /drivers/call arg, and the
OpenAI-compat and Anthropic-compat request bodies) makes the first
attempt's failure final: the route is still marked unhealthy, and the
error is classified the same way an exhausted chain would be.
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* ai-chat: cover fallback: false in the SDK suite
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* ai-chat: fallback: false rejects a pinned provider with no route for the model
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* docs: plainer wording for the fallback option
Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
---------
Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
* docs(install): explain how to install Docker instead of a dead end
The installer stopped with 'missing required command: docker' and nothing
else, which left self-hosters on a clean machine with no next step. Since
Puter self-hosting is a Docker Compose stack, that check is also the very
first thing a new user hits.
Print the official install links for macOS/Windows and Linux plus the
'docker compose version' sanity check, and cover the same ground in
doc/self-hosting.md under Requirements and Troubleshooting.
The installer still does not install Docker itself — per the maintainer's
note on #4346, pulling a remote script into a system package manager is
too intrusive. This only makes the failure actionable.
* fix: remove extra sh stuff already merged
---------
Co-authored-by: Daniel Salazar <[email protected]>
* fix(notifications): keep app messages in their app, off the desktop
An app's notifications now reach only that app: its live feed on
notif:app-user and, through the app's own sockets, receipts and pushes.
The desktop's listing, unread replay, live wire and notif:app-user
subscription leave them out, the same way key-value share notices
already are, which covers rows stored before this change too. Puter's
own notifications and shares still show on the desktop.
The desktop and its notifications panel also ignore an app message
they are handed, and the desktop no longer notes one as received.
The notification docs now say where an app's notifications show.
* docs(notifications): say an app's notifications reach the app, not the panel
* test(notifications): wait for the unread replay instead of sleeping past it
* fix(gui): show notifications a frozen tab missed once it reconnects
A tab that was frozen or asleep keeps its socket registered until it
resumes, so a notification created meanwhile counts as shown and no
unread replay carries it: the tab never showed it, not even after a
reload.
On a socket reconnect the desktop and the dashboard now read the
undismissed notifications created since the page loaded and raise the
ones that page has not shown or seen dismissed, once. Every unread
replay is taken too, not only the first, and no longer only while the
events feed is down; a per-page log keeps either from raising anything
twice. App messages stay out.
* fix(gui): catch up after a reconnect without the page's clock
The reconnect catch-up compared each row's server time with the page's
own load time, so a page whose clock ran behind raised, on its first
reconnect, every undismissed notification from before it loaded.
A page now notes the undismissed notifications it knew at load (the
desktop from one listing, the dashboard from its first) and a reconnect
raises only those it neither knew then nor has raised since. The
dashboard reads them from the listing its reconnect refresh already
takes. The per-page log is bounded, and a caught-up notification shows
its title as the body when it has no text, as the replay does.
* style(gui): keep the reconnect comments to one line
* test(ai): end every realtime relay test's upstream before the next
The fake upstream now answers response.create with response.created first,
as OpenAI does, so closing those sessions no longer waits out the settle
window.
* feat(ai): ask realtime clients to reconnect before a restart
Open realtime sessions get a puter.reconnect event when the drain starts,
with close_in_ms set to the drain window, and new sessions are still refused
until the 1012 close at shutdown. A host extension can announce a restart
early by emitting serverRestartAnnounced; that only sends the notice, so a
false alarm leaves the server working normally. A session gets the notice at
most once a minute.
onServerDrainStart now receives the drain duration.
* fix(ai): retry realtime reconnects for about a minute and wait out offline browsers
Five attempts gave up after about 15 seconds, shorter than a server restart.
The conversation makes ten attempts with the backoff capped at 10 seconds,
and while the browser reports being offline it waits for the online event,
up to two minutes per reconnect, instead of spending attempts. close() ends
either wait and removes its listener.
* feat(ai): restore recent conversation context after a realtime reconnect
The provider starts a fresh session on every reconnect, so the conversation
lost all earlier items. The SDK now records finished items it can recreate
from the provider's own events: user text, user speech with a transcript,
assistant replies as text, and function calls with their outputs. After the
new session acknowledges its settings, and before ready fires, it adds back
the newest of them, up to 40 items and 48 KiB, which stays under the relay's
upload burst.
Assistant replies are cut to what the user saw or heard: the deltas shown
before an interruption, and the share of the transcript matching the audio
played before a truncation or a drop. Deleted items, unfinished replies,
images, speech without a transcript and incomplete function calls are left
out, as are function outputs whose call falls outside the window. Restored
copies take over their new item IDs. A refused item is reported once as
context_restore_failed without failing the reconnect.
* feat(ai): move a realtime conversation to a new session on the restart notice
On puter.reconnect the conversation waits until it is idle: no reply
requested, generating or playing, no user turn being spoken or transcribed,
no function call awaiting its output and no acknowledgment pending. Then it
reconnects through the usual path, emitting reconnecting (code 1012) and
ready with reconnected: true, without an error. Until then the session keeps
working, and if the server closes it first the normal 1012 reconnect takes
over. close() at any point stops the wait.
* docs(ai): realtime restart notice, context restore and reconnect limits
* test(ai): realtime reconnects against the local server
The mock provider answers with item events and whole replies like OpenAI.
New cases cover a dropped connection (cut through a TCP proxy so the client
sees 1006), a host-announced restart, and a draining server refusing the
reconnect; the first two check the provider receives the restored items
before the next turn. The browser media test waits for captured audio and
the reply instead of a fixed sleep. The live suite gains a tool and text
context round trip across a restart notice.
* ci: run the live realtime conversation tests with the AI provider suite
They need the OpenAI key, which only this workflow has, and skip themselves
without it.
* fix(ai): open the replacement before leaving a realtime session on the restart notice
The handover closed the current session and then reconnected, so every
notice cost a second or two in which sends failed and microphone audio was
dropped. On a single server that refuses new sessions while it drains, the
retries ran out during the drain and the conversation closed sooner than if
it had stayed. The conversation now opens a replacement session as soon as
the notice arrives and switches only once it is open and the conversation is
idle. A refused replacement is retried with backoff while the current
session keeps working; when the server closes it first, the held replacement
is used right away.
The idle check also missed some in-progress work:
- a reply created while the user was still speaking ended the user's turn;
speech now lasts until its audio is committed or cleared, and a committed
turn waits for the reply server turn detection creates
- only the newest function call was checked; every finished call since the
last user message needs its output, and calls cut off by a cancellation
don't count
- native session.update and conversation.item.create events the provider
hadn't acknowledged could be lost; the switch waits for them, up to the
acknowledgment timeout for items
- only the newest transcription was waited on; every committed item is
- clearing playback didn't recheck the switch
Restored items stay internal: the new session's copies of them no longer
reach listeners. At a reconnect, every queued item the user didn't hear is
cut, not just the one playing. A notice from a failed reconnect attempt is
dropped.
* test(ai): realtime replay after split settings, and a draining server refusing the replacement
The relay accepts the context replay right after two full-size settings
updates. Against the local server, a draining relay refuses the replacement
session while the current one keeps answering.
* ci: build the SDK in the live realtime step so it stands on its own
* fix(ai): a failed realtime reconnect attempt no longer poisons the next
An attempt that dropped after sending its session.update but before the
acknowledgment left that event ID queued, so the next attempt's
acknowledgment was matched to it and timed out, and so on until the
conversation closed. Turn state is now reset after each failed attempt.
* fix(ai): bound the realtime handover's waits and stop retries that can't succeed
- The first replacement opens within a randomized 3 seconds of the notice,
so sessions notified together don't all open at once.
- A replacement refused with a code retrying can't fix, such as
insufficient_funds, ends the attempts; the server's close still falls back
to the usual reconnect.
- A held replacement that gets an error or its own server's notice is closed
and replaced.
- A held replacement used after a drop gets a 3 second acknowledgment
timeout, since its socket may have died with the network.
- close() during the switch closes the held replacement too.
- The reply to a committed turn and each transcription hold the switch for
at most 10 and 30 seconds, like app items already did, and an app item is
released only by an added item of its type, role and call ID.
* docs(ai): the realtime restart notice isn't guaranteed before every restart
* feat(ai): relay OpenAI's Realtime API as puter.ai.assistant
Adds a WebSocket relay at /puterai/openai/v1/realtime on the api host,
one upstream OpenAI connection per client, frames passed through 1:1.
The client socket is accepted before auth so socket.io's 1 s
unhandled-upgrade timeout can't kill slow handshakes; failures arrive as
OpenAI-shaped error events and a 1008 close. Usage is metered per
response.done at the model's token rates, sessions are capped per user
(realtime.maxConnectionsPerUser), and each node logs counters,
event-loop delay and memory every 30 s.
puter.js gets puter.ai.assistant.connect(), returning a session with
send/on/off/close over the same OpenAI events.
realtime.upstreamUrl points the relay at a mock for load tests;
tools/realtime-stress has the mock upstream, a load generator with a
JSON report, and an agent runbook. The relay README lists its quirks.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
* fix(ai): backpressure and upload budget on the realtime relay
Neither direction applied backpressure: a client that stopped reading
grew a queue up to 64 MiB, and a client streaming 16 MiB frames was read
and forwarded as fast as it could send. A few such sockets pinned the
event loop and pushed RSS past 1 GB.
Each side now stops reading the other once 1 MiB is queued toward it,
and a client that keeps the upstream paused for 30 s is closed. Client
uploads are budgeted per session (1 MiB/s sustained, 16 MiB burst,
realtime.maxClientBytesPerSecond), pre-open buffering pauses reads at
1 MiB instead of rejecting at 32 MiB, and clients get a 30 s ping so
half-open sockets release their upstream and slot. Upstream frames use
a zero mask and skip UTF-8 validation, which the relay never needed.
The stats line gains elu. tools/realtime-stress gains abuse.mjs and a
runbook phase for event-loop abuse.
* fix(ai): drain, transcription metering and credential re-check on the realtime relay
Shutdown now drains: a new onServerDrainStart hook (run when SIGTERM
starts the drain, before the listener closes) makes the relay refuse new
sessions with 1012, open ones keep running through the drain window, and
at onServerPrepareShutdown each gets up to 5 s to finish an in-flight
response before closing with 1012 so the client reconnects elsewhere.
Input transcription is metered. The relay reads the type from each text
frame's first 96 bytes and, after forwarding, parses response.done,
input_audio_transcription.completed and session.created/updated (for the
transcription model) on setImmediate, so metering never holds up frames.
Unknown transcription models bill at the highest known rate.
Credentials are re-checked: a successful auth is cached in the kv.js
singleton for 60 s under a hash of the token, and a 15 s sweep
re-authenticates sessions whose entry expired, closing revoked or
suspended ones with invalid_api_key. Lookup errors keep the session and
retry on the next sweep.
* docs(ai): API shape proposal for puter.ai.conversation() across OpenAI, Gemini Live and xAI
* feat(ai): add composable realtime conversations with bounded transport
* fix(ai): realtime billing, subscription gate and conversation reconnects
Relay:
- Cancel and bill a response still generating when the client leaves,
instead of cutting the upstream before response.done.
- Record realtime usage through the AI cost-factor hook.
- Drop the upstream byte and message caps; replies arrive faster than real
time and tripping them closed sessions and skipped billing.
- Price usage as it arrives, before any forwarding decision, so a closing
session never drops a usage event.
- Bill the model the session reports and refuse a session.update that
changes the model.
- Require a paid plan.
SDK (puter.ai.convo):
- Reconnect after a dropped connection with the same settings, keeping
listeners and media; emits reconnecting and ready { reconnected }.
- Errors reject only the operation they name; a failed send rejects only
its own request.
- configure() sends only the given settings and settles on its own ack;
commitAudio follows turn detection set natively.
- interrupt() right after sendText cancels the reply once it starts.
- Microphone: capture at the device rate, drop chunks instead of stopping
on stalls or a congested socket, no timeout on the permission prompt,
permission_denied on refusal.
- Speaker: 300 s playback bound without interrupting, played position
accounts for output latency and gaps between chunks.
- Audio control events keep headroom over queued audio.
- Upgrade prompt on subscription_required / insufficient_funds.
Tests that need the built SDK and Chromium move to the puter.js suite.
* chore: remove stray node_modules symlinks
* feat(ai): retire assistant API and document realtime beta
* test(ai): wait for the matching realtime billing batch
* test(kv): exclude health probes from warm read measurements
* Revert "test(kv): exclude health probes from warm read measurements"
This reverts commit 631003f755.
* fix(ai): upstream message size, null usage, team 2FA and reconnect settings for realtime
Relay:
- Upstream messages may be up to 4 MiB; client messages stay at 64 KiB.
An OpenAI event above 64 KiB (an echoed item near the client limit, a
long reply, a retrieved audio item) closed the upstream before in-flight
responses reported usage.
- An upstream that closes with a response still in flight raises a
warning alarm.
- A null usage counts as none instead of closing the session.
- Seats whose team requires 2FA need it for realtime too, at connect and
on the credential recheck.
SDK (puter.ai.convo):
- A reconnect reapplies every acknowledged setting, native session.update
events included, split to fit the message limit.
- connect() during a reconnect settles with it.
- Reconnect delays are jittered, and closing cancels a pending delay.
The credential-recheck and browser worklet tests now fail when the
behavior they cover breaks.
* feat(ai): credit holds, attempt limits and late-response billing for realtime (#4407)
Relay:
- Each response holds 8,000 audio input and 4,096 audio output tokens of
the account's budget, at the session model's rates, from
response.created until its usage is recorded. A session opens only if
one hold fits.
- Session attempts are limited to 60 per minute per credential, checked
before authentication.
- A response the client requested that starts after the client leaves is
cancelled and billed.
- Input transcription is priced at the costlier of the transcription
models in force at commit and at completion.
- rate_limits.updated isn't forwarded, and provider organization IDs are
removed from error messages.
SDK (puter.ai.convo):
- microphone.stop() during the permission prompt rejects the pending
start() with session_closed.
- speaker.start() rejects with { code, message } for browser exceptions.
- connect() waits 20 seconds for the session, past the server's 15-second
setup.
- Convenience events fill IDs the provider leaves out; types cover
turn.done, error, and client events for send().
---------
Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Daniel Salazar <[email protected]>
* fix(socket): count sockets that never acknowledge instead of asking them
askWithAck sent an acked broadcast to sockets outside the ack room only to
learn whether any were there. Those sockets never answer, so every node held
the ack callback until they disconnected. Each node now answers a server-side
count of them instead; a node that does not answer in time still reads as
holding one.
* feat(notifications): read focus and own display from receipt answers
A receipt answer may carry focused and showsOwn beside visibilityState; both
pass through to notif.delivery.settled when they are booleans. An answer with
received: false is not a responder, and an ask answered only that way settles
as unreceived.
* fix(notifications): name an app message's bad field before the size cap
An app message over the 16 KB value cap answered notification_value_too_large
even when one field was what broke a rule of its own. The fields are checked
first now, so the caller gets bad_request naming the field; the total cap still
applies after. Character counts skip the code point walk on strings that can't
fit.
* fix(push-sw): match the server's tags, keep taps, and show pages' notifications
- A push is tagged with the server's group, `<app uid>:<group>` or the uid, and
alerts again when that replaces one showing. Handlers get the app's own group
and the send time in ms from `ts`.
- The app's icon stands in when the notification has none.
- Default and relative tap URLs resolve against the worker's scope, still only
on its origin.
- A tap posted to an open window waits briefly for the page to take it; without
a click handler there, the window is taken to the URL, or one is opened.
- A page that got a notification live while away can ask the worker to show it;
the worker holds it back while another window of the origin has focus.
* fix(notifications): answer every receipt ask
A page now answers each socket that asks about a notification, not only the
first, and answers { received: false } when the notification doesn't arrive
within the wait or the page holds too many asks, so the server's nodes can
let go of them. A received answer carries focused and showsOwn beside
visibilityState.
* fix(notifications): keep web push to users who turned it on, and show what a push would
Subscription:
- onPush() renews the subscription on load only for a user who turned push on
on this origin with enable(); enable() records that, and disable(), sign-out
and push_revoked forget it. One renewal runs per page and worker, however many
onPush() calls there are, and push_disabled, push_revoked, push_unavailable
and too_many_requests stay quiet there.
- puter.auth.signOut() on a website removes this browser's subscription with
the leaving user's token and forgets the opt-in and kept taps, without
holding up the sign-out.
- A subscription another service on the site made is left alone and rejected
as push_subscription_conflict; one made with Puter's earlier key is replaced.
- A subscription refused with push_endpoint_revoked is replaced once, like
push_subscription_invalid.
- Another worker holding the scope rejects push_worker_conflict, naming
importScripts and the serviceWorker option, instead of waiting out the
confirmation push.
- enable() rejects user_gesture_required outside a click instead of prompting,
and a refused browser subscribe rejects push_subscribe_failed.
Live side:
- A page that is hidden or unfocused shows every app notification it receives,
with or without a handler for its channel, through push-sw.js so a focused
window of the app holds it back. It uses the server's tag, the app's icon as
a fallback, the generic app line for sensitive ones, and the registration of
its own worker.
- Page handlers get 5 seconds, as in the worker (push_handler_timeout).
- A feed stopped by a sign-out starts again on the next sign-in.
- onPush() still delivers live when IndexedDB is unavailable.
- onPush() rejects invalid_handler for a handler the worker could not rebuild
from its source (methods, bound and built-in functions).
- Saving and removing a handler's source for the worker is ordered per key, so
a quick unmount and remount keeps it.
- A tap posted by push-sw.js is acknowledged only when a click handler takes it.
* fix(gui): show app pushes under the app's name and Puter's icon
- On Puter's own origin, an app's push takes the app's name as its title, with
the app's title and text in the body, and Puter's copy of the app's icon; an
icon the app chose is never shown. A sensitive one keeps its generic line.
- A push's ts is its timestamp, and a newer push in a group alerts again.
- Re-registering this browser counts only apps that push to Puter's devices
(puterDevices), so a website's Web Push confirmation alone never registers
it.
* fix(gui): free the push dialog's button when registering fails late
A registration that failed after the dialog's deadline left the button
spinning and disabled. It now clears the attempt, settles on a state the
browser moved to meanwhile, or offers a retry.
* fix(permission): a user's own app grant clears a narrower via on the row
An app grant that already existed was skipped, so a row another path recorded
with extra.via kept it after the user approved the same permission in Puter.
A grant without via now rewrites such a row without it, keeping its other
extras; a grant with via still never touches an existing row.
* refactor(notifications): export the notification text cleaner
Extensions that word notifications can use the same cleaning as app messages.
* docs(notifications): describe when pushes go out, taps, worker rules and enable() errors
Pushes go out when no open page answers that it's visible and focused or
shows the notification itself. Documents the page-shown notification, the
sticky opt-in, tap routing, group and sensitive behavior, service worker
rules, requirements, every enable() and onPush() error, push caps for
unlisted apps and websites, and sending from workers and events handlers.
The push playground example is removed (the playground can't serve the
site's service worker) and redirects to the guide. The minimal page catches
enable() errors and sends after 5 seconds. The recipe no longer sends on
every load, and app.message is listed in events fetch and the catch-up
recipe.
* docs(notifications): per-app browser caps and when push_endpoint_revoked appears
* docs(notifications): the page's handler timeout
* fix(notifications): leave a site's own service worker alone when enabling pushes
Registering /puter-sw.js at a scope another worker holds queued it behind that worker, so enable() rejected with push_worker_conflict but swapped the site's worker out once its tabs closed. Now enable() checks the scope first and rejects without registering.
* docs(notifications): a background page shows score alerts itself
Adds worker requirements, per-browser/OS support (iOS Home Screen, macOS/Windows/Android settings), and troubleshooting to the push setup page. Website taps now documented on on('click'). New recipes: notify chat members of new messages, alert users when shared data changes.
The worker is published next to puter.js at https://js.puter.com/push-sw.js.
Docs and the worker's own header now point there. The js subdomain serves
/push-sw.js for self-hosted setups and keeps /v2/push-sw.js as an alias.
* feat(notifications): report how each live notif.message was received
SocketService.sendWithAck sends an event to rooms or sockets and waits,
up to a timeout, for an acknowledgement from each socket that listed the
event in its handshake (`auth.acks`); every other socket gets a plain
emit. It lists sockets across the cluster with fetchSockets and asks the
listed ones through the adapter's broadcast acks, and counts this node
alone when the adapter cannot list the others.
The emitting region now sends notif.message through it, with the window
from `notificationAckTimeoutMs` (default 5000), and emits the node-local
`notif.delivery.settled` with the outcome: received, unreceived,
no-socket or remote-only (sockets only in a peer region), whether any
socket there acknowledges, and each responder's visibilityState. Peer
regions keep sending their own copy as before; the GUI event carries
`region_delivered` so this region does not send it twice.
The desktop and dashboard list notif.message in their handshake and
answer its callback once with document.visibilityState. Nothing in core
acts on the settled event.
* fix(notifications): send live notif.message at once and report what the count covers
sendWithAck now emits to the whole room straight away as a broadcast with
ack, and lists the room's sockets alongside it only to fill in the
counts, so nobody waits on the listing. Whether the adapter can count
the cluster's servers is checked once; where it cannot, sends go out
per socket on this node with a one-time warning. The result carries
`complete`: false when only this node's sockets were counted, either
for that reason or because the listing failed or took over 1 s, which
is warned about at most once a minute.
notif.delivery.settled gains `sendId`, `type`, `complete` and
`peerRegions`. An incomplete count with no socket found settles as
unreceived rather than no-socket, and the peer-region read runs
alongside the send instead of after it. notif.written carries the same
`sendId`, minted per send (an update gets a new one) and passed through
notif.created to the socket adapter on the fold-in path.
The comment on revoking sockets no longer says fetchSockets is
unavailable with our Redis client; it works with ioredis cluster.
* fix(notifications): keep a failed server count from ending the process
socket.io awaits the adapter's serverCount without a catch on every
broadcast with an ack, and the redis-streams adapter's count rejects as
soon as one cluster node fails to answer. The count on the root
namespace's adapter now never rejects: a failure reports the last good
count (1 before any), and is warned about at most once a minute. A stale
count only lets an ack wait run to its timeout.
Whether sendWithAck takes the cluster path is no longer a one-shot check
awaited before the emit. Until a count has succeeded, sends go out per
socket on this node at once while a count is retried in the background,
at most every 30 s (SocketService.COUNT_RETRY_MS); the first success
switches to the cluster path for good.
* fix(notifications): ask only acknowledging sockets, through an ack room
A socket whose handshake lists an event in `acks` now joins
`<room>:acks:<event>` for each of its rooms and its own id.
sendWithAck sends the ordinary broadcast to the target rooms except
those ack rooms, which keeps it on the adapter's stream, and a broadcast
with an ack only to the ack rooms. Sockets that never acknowledge are no
longer asked, so other nodes hold no pending ack for them and a send
completes as soon as every acknowledging socket answered. The per-socket
listing that matched socket.data.acks is gone; the ack-capable count
comes from the room membership the listing already returns. The
per-socket path stays only for a node whose adapter has not counted
servers yet.
A failed server count during a send now marks its counts incomplete, so
a stale count can't make a listing that missed a node read as complete.
With no count ever succeeded (an in-memory Redis), the failure is warned
once per process instead of once a minute.
* fix(notifications): say whether every socket on a live notification acknowledges it
A tab on an older bundle beside one that acknowledges made the settle read
as ack-capable, so a listener could not tell that the older tab may have
shown the notification. notif.delivery.settled now carries everySocketAcks.
* feat(notifications): app pages acknowledge the live copy of an app message
NotificationService.appReceipt() asks an app's own sockets whether a
notification reached a page, and settles it as notif.delivery.settled with
appUid, the same outcomes as the desktop's. Callers ask only when the answer
decides something, since it is a round trip to every node.
- SocketService.askWithAck asks without listing sockets: sockets that don't
acknowledge get an acked broadcast too, which can only time out, and that
says whether an older client shares the target.
- puter.js declares notif.receipt on its filesystem and events sockets. A page
answers once one of its notification feeds received that uid, before any
handler runs, and never when no feed on the page got it.
- Docs describe the push route as "no page of the app received it live
within 5 seconds".
* fix(notifications): count a socket that acknowledges other events as a current client
Every desktop page also holds puter.js's own socket, which never shows
notif.message, so "every socket acknowledges notif.message" never held there.
sendWithAck now reports `declaring`, the sockets that listed any event in
auth.acks, and everySocketAcks on the desktop's settle means no socket here
declares nothing.
* fix(puter-js): fall back to the Continue dialog when a popup can't be opened
Some browsers report user activation but still return null from
window.open (iOS home-screen web apps, Safari after awaited work), so
permission prompts, signIn, enablePush, and GUI action popups failed
without showing anything.
PuterDialog.launch() now owns the open-now-or-show-Continue decision for
every popup flow and shows the consent dialog when the direct open
returns null. On cross-origin-isolated pages signIn leaves settling to
the /login/wait relay on both launch paths.
* fix(notifications): show a live push when the page's window isn't focused
onPush showed the handler's result as a system notification only when
the tab was hidden. A visible tab in a window the user left showed
nothing. It now also shows when the document doesn't have focus. Inside
the Puter desktop the desktop's banner shows the notification, so an
app's frame still never does.
* build(puter-js): publish push-sw.js exactly as written
The build emits dist/push-sw.js next to puter.js, but terser minified it
along with the bundle. It is now marked as already minimized, so the file
apps import into their service worker is the source unchanged.
* fix(notifications): send live notif.message plainly and ask for receipts on request
Every live notif.message used to be sent with an ack, list the user's sockets
across the cluster (fetchSockets ships each socket's handshake between nodes)
and wait out the receipt window, whether or not anything would act on it.
notif.message is a plain send again. NotificationService.receipt() asks the
desktop's sockets, or with appUid an app's own, the way app receipts already
did: an ask with no socket listing, settled as notif.delivery.settled. A
caller asks only when the answer changes what it does.
- The desktop and dashboard sockets answer notif.receipt from the page's
puter.js receipt log and note each live notif.message in it, so every socket
on the page answers alike. A page whose puter.js has no log lists no acks.
- SocketService.sendWithAck, the socket listing and region_delivered are gone.
askWithAck counts this node's sockets when the adapter cannot count servers.
- The send id stays on notif.written and the settle, and leaves the wire.
* fix(socket): join only the ack room receipts are asked in
A socket listing events in auth.acks joined an ack room beside each of its
rooms and its own id. Receipts are only asked in the user's room, or the app's
for an app socket, so that is the one ack room it joins.
* docs(notifications): publish the push limits of 12 a minute and 1,000 a day
Pushes are capped per user per app at 12 a minute and 1,000 a day, and at 6 a
minute and 200 a day while an app is new. The rate limits table and the
notification pages said one a minute and 100 a day.
---------
Co-authored-by: Daniel Salazar <[email protected]>
A 401 outside the reauth flow used to run the full logout, which ends the
session on the server and takes every device registered under it. The tab
now drops its token and reloads instead; the session cookie signs it back
in when it still holds. A second 401 right after that reload falls back
to the full logout. A peer tab signing out is mirrored the same way.
* feat(push): notifications:push app permission
Apps need a per-app notifications:push grant for their notifications to
go out as pushes. hasPushConsent checks it as the app acting for the
user, so only that user's grant to that app answers yes, and a revoke
takes effect on the next check. The permission dialog describes it and
the perms docs list it.
* fix(push): canonical app uid in the push consent check
* fix(push): let users grant apps notifications:push
The grant route now only accepts permissions from a fixed set of
app-grantable families, which refused notifications:push, so an app could
never be given push consent. Register it next to events:background.
* refactor(permission): keep the notifications:push constant with the permission code
The consent check that reads it lives with the delivery code, outside
core. The grant flow test checks the grant through the permission
service directly.
* feat(notifications): node-local notif.written hook
NotificationService emits notif.written after each non-silent write and
after an in-place rewrite, on the node that wrote the row, so an
extension can act on new notifications without a second bus round trip.
It stays off the published event stream; notif.created carries the
notification itself.
* feat(puter-js): puter.notifications
An app notifies the user it runs for, reads its own notifications back
and follows new ones, hears clicks on them from the desktop, and asks
for or checks the notifications:push grant. Puter's own apps also
register, list and remove the user's devices.
* feat(puter-js): device calls report push_unavailable without a push service
A deployment with no push service has no /push routes, so core answers
the device calls with its catch-all 404. registerDevice, listDevices and
removeDevice turn that into push_unavailable, kept apart from a missing
device, which comes back as device_not_found.
* feat(homepage): serve the service worker at /sw.js
/sw.js serves the GUI's sw.js, preceded by one importScripts per script
an extension registered with registerServiceWorkerScript. Whether the
GUI registers the worker follows the pushEnabled gui param, which only
an extension sets. Notification reads also carry the row's app_uid, so
the GUI can route a tap to the app a notification is about.
* feat(gui): notifications settings for push devices and apps
Settings -> Notifications turns pushes on and off for this browser
through a push client an extension registers, lists the user's devices
and the apps allowed to push, and switches Puter's own pushes. The tab
shows only when the pushEnabled gui param is true. The sessions window
shares the user agent formatting with it.
* feat(gui): service worker that shows pushes and routes taps
sw.js shows each push as a notification and, on a tap, focuses or opens
a Puter window and hands it the tapped notification. The desktop routes
the tap to the app the notification names, opening it when needed, or
to the notifications panel. The worker is registered only when the
pushEnabled gui param is true.
* feat(gui): notifications settings read a missing push service as unavailable
Without a push extension the /push routes answer core's plain 404. The
GUI maps that to push_unavailable, so the tab says pushes aren't
available on this server instead of reporting a load failure, and a
device removal only counts as done when the device itself is missing.
The push client doc describes the payload sw.js reads and where the
push endpoints come from.
* fix(gui): keep the service worker on servers without push
Unregistering /sw.js wherever pushEnabled is unset ended the browser's
push subscription, so a single page load on such a server stopped pushes
from every server. Turning push off already ends the subscription.
* feat(gui): the service worker tells open tabs when the push subscription changes
* fix(gui): removing this browser in Settings also forgets its push registration
* fix(gui): removing this browser also deletes it on the server when its local record is gone
* fix(notifications): notif.written hands out a copy with the audience
Listeners get their own copy of the value, so what the socket carries is
what was written. Both emits name the audience, and an update names the
row's app when the row or the value says which.
* fix(notifications): read the app off the notification on an update
* fix(gui): route push taps through the stored notification
A tap from the worker names only a notification; the desktop and the
dashboard read the signed-in user's own row and route from its app,
target and data, as a tap from the URL already did. Without a row the
dashboard opens the list and the desktop does nothing.
* fix(gui): local banner tags carry the app
An app message's group is tagged under its app, as the server tags a
push, so two apps using the same group name keep separate banners.
* fix(gui): a tap delivered twice routes once
The page remembers the notifications it handed over in the last ten
seconds and drops a repeat, whichever way it arrives.
* fix(gui): the service worker never reuses cached imports
The worker is registered with updateViaCache set to none, so the scripts
it imports are fetched fresh on every update check.
* fix(gui): turn on can't run twice at once
The browser card's button stays out while the client is enabling or
disabling, through any redraw in between, and a status read begun before
the press no longer draws the card.
* feat(gui): turn on browser push when an app is allowed to notify
Allow on a permission request that includes notifications:push also
turns push on in this browser, from the same click, when it is off here.
The decision never depends on it. The permission popup starts the
service worker for such a request, answers the opener first and closes
once the registration is done. The Notifications tab records a browser
the user turned off, per user.
* feat(gui): register this browser for pushes again where it already allows them
On desktop and dashboard boot, and when an app that holds
notifications:push is launched, a browser whose notification permission
is already granted registers again without a click, unless the user
turned push off in this browser.
* feat(sdk): puter.notifications.enablePush() and the enable-push popup
An app on its own domain can offer to turn push on in the user's browser
from its own button: puter.notifications.enablePush() opens a popup on
the Puter origin whose single button supplies the click the browser's
prompt needs, and inside the desktop the same dialog is shown over IPC.
The popup hands the opener no token. Docs for the new call and for the
paths that now call the push client's enable().
* fix(gui): wait for the browser's prompt before closing the permission popup
The deadline on a registration begun from a click counts no time while
the browser's own notification prompt is open, in the permission popup
and in the enable-push dialog. The enable-push dialog keeps one
registration in flight, settles on whenever it resolves while open, and
offers a retry only after a real failure. A permission dialog that ends
in a denial after Allow began a registration turns push back off and
restores the opt-out it cleared.
* fix(gui): one enable-push dialog at a time, only for apps that may send pushes
The desktop and the popup show the enable-push dialog only when the
asking app holds notifications:push. The IPC branch answers a second
call unavailable while a dialog is up, as the feedback dialog does. The
silent registration on boot and app launch runs once at a time. A popup
whose opener cannot be attested answers the enable-push request before
it closes.
* feat(events): isOnline presence check for a user and app
* feat(notifications): onPush and enable with Web Push on the app's own origin
puter.notifications.onPush(key, handler) handles an app's notifications for
one channel: live over the events socket while the app is connected, and as
a browser push otherwise. enable() asks for notification permission on the
site's origin and subscribes through the site's worker, which imports
push-sw.js (served at js.puter.com/v2/push-sw.js). A new subscription is
confirmed by a challenge push the worker hands back to the page. disable()
removes it.
The GUI uses the same flow through /sw.js when the server publishes
pushWebPushKey.
* fix(notifications): visible sender for app messages; local banners follow push consent
An app title of only invisible or blank-filler characters no longer leaves
an app message without a sender; it falls back to the app's name, then its
uid. Hidden-tab banners now show only while push is on in this browser, and
for an app's message only when the app holds notifications:push.
* feat(notifications): enable() asks through Puter's prompt after a revoke
When the user turned an app's notifications off in Puter, the browser's
permission on the app's origin no longer re-enables them. enable() asks
again through Puter's permission prompt and retries once.
* fix(notifications): enable() rejects on a revoke, onPush() syncs quietly, one permission check per app a minute
enable() no longer opens Puter's prompt after a push_revoked error; apps call requestPermission() from a click and enable() again. onPush()'s page-load sync no longer reports push_unavailable or push_revoked. Hidden-tab banners for an app's messages check the app's push permission once a minute instead of once per message.
* docs(notifications): describe the confirmation step as what happens; one-line comments and neutral fixtures
* docs(notifications): guide for app push notifications, limits and prices
* fix(puter-js): subscribe once the worker is active, and retry a subscription the push service hasn't settled
* fix(gui): the desktop's hidden-tab banner is for Puter's own notifications only
* docs(notifications): app pushes at $2.50 per million
---------
Co-authored-by: Daniel Salazar <[email protected]>
* fix(workers): bind credentials and cleanup to the owning site
* test(workers): deploy valid source in the site-bound token test
Where workerd is installed the deploy starts the script, and the bare
identifier `src` threw at startup as an unhandled rejection.
* fix(networking): recover shared relay clients and preserve write order
* fix(networking): clear the flush flag with the last queue check
A write landing between the drained queue and the connect path resuming
was queued with nothing left to send it.
* fix(networking): keep a throwing socket listener out of connect and close
An open listener that threw was caught as a failed connect and could
retry onto a second stream; an error listener that threw stopped the
socket from ever closing. Both now rethrow on their own tick.
* fix(ai): meter converted audio from reported output duration
* fix(ai): size undocumented voice changer output formats as the default
* fix(ai): skip WAV headers when metering and renew streamed charge holds
A wav_* output's RIFF header was counted as samples, rounding a 3 s clip
up to 4 s. The streamed-charge hold now renews until the charge lands, so
a slow reader can't outlast it.
* fix(ai): time Opus output by its pages and renew holds through slow opens
Opus is variable bitrate, so a mostly silent clip billed far under its
length at the nominal rate; its length now comes from the last Ogg
page's granule position. The streamed-charge hold renews from before the
upstream opens, since opening can run as long as the hold's TTL, and is
released even if recording the charge throws. Speech-to-text extra_body
can no longer override response_format or stream, which billing reads.
* fix(ai): finish async media accounting and recheck output access
* fix(ai): settle late media jobs on their outcome and keep their slot
A job still running when the poll window closes no longer charges its
estimate up front. The caller still gets the 504; the job is followed in
the background, holding its credit, until it settles, and is metered from
the provider's outcome: what it reports, nothing for a failure that
reports no cost, the estimate only if it never settles.
The driver concurrency slot is now held until the handler and any work it
carries on after the response settle, so a disconnect no longer frees a
slot while polling continues.
A revoked puter_output_path is reported as metadata.outputSaved = false.
* fix(ai): bill a lost media job the same in and after the window
A poll error that isn't transient now ends the same way whether it hits
inside the window or after it: a client-error refusal is a failure the
provider doesn't bill, while auth, not-found and unclassified errors mean
we lost a job the provider still runs, charged its estimate. A Hailuo
clip already billed retries a transient file lookup instead of failing,
and a concurrency slot renews from the moment it is held.
* feat(events): simplify onPersistent delivery
Replace delivery/targets with a boolean single. Without single, every
event runs the app's published handler in its events worker and is also
sent to every connected client; the server no longer routes between the
two. single keeps leasing each event to one consumer that acks.
delivery: 'single' | 'broadcast' is still accepted and targets is
ignored. Stored rows are read by their delivery column alone.
* docs(events): compare onLocal, onPersistent and single
* feat(events): rename single to queue
onPersistent({ queue: true }) is the work-queue mode: each event goes to
one consumer at a time and is offered again until acknowledged.
delivery: 'single' is still accepted as an alias, and subscription
objects report queue instead of single.
The docs explain the three modes (onLocal, default onPersistent, queue)
and each events recipe says which one it uses and why.
* docs(events): retries are the only repeat for a queued event
* feat(events): queue deliveries fall through to the worker on decline
A queued delivery gets one socket attempt under a 15 s lease instead of
two 60 s ones. A client with no handler for it, or whose handler fails,
sends events.nack and the worker takes it without waiting for the lease.
Declines are relayed to the region holding the lease like acks are, and
a decline for a lease that is no longer the client's is ignored.
* feat(events): drop thrown errors, retry only temporary ones
A queued handler that throws now drops the event and counts one failure
toward the pause. Network errors, timeouts, rate limits, 5xx responses
and errors thrown with retry: true are retried with the existing
backoff. One classifier is shared by puter.js and the events worker
runtime, which inlines it at build time. A dropped event is billed once,
like a completed one. ack() remains as an optional early release.
* docs(events): queued events reach every tab with a handler
enable() no longer opens Puter's prompt after a push_revoked error; apps call requestPermission() from a click and enable() again. onPush()'s page-load sync no longer reports push_unavailable or push_revoked. Hidden-tab banners for an app's messages check the app's push permission once a minute instead of once per message.
When the user turned an app's notifications off in Puter, the browser's
permission on the app's origin no longer re-enables them. enable() asks
again through Puter's permission prompt and retries once.
An app title of only invisible or blank-filler characters no longer leaves
an app message without a sender; it falls back to the app's name, then its
uid. Hidden-tab banners now show only while push is on in this browser, and
for an app's message only when the app holds notifications:push.
puter.notifications.onPush(key, handler) handles an app's notifications for
one channel: live over the events socket while the app is connected, and as
a browser push otherwise. enable() asks for notification permission on the
site's origin and subscribes through the site's worker, which imports
push-sw.js (served at js.puter.com/v2/push-sw.js). A new subscription is
confirmed by a challenge push the worker hands back to the page. disable()
removes it.
The GUI uses the same flow through /sw.js when the server publishes
pushWebPushKey.
The desktop and the popup show the enable-push dialog only when the
asking app holds notifications:push. The IPC branch answers a second
call unavailable while a dialog is up, as the feedback dialog does. The
silent registration on boot and app launch runs once at a time. A popup
whose opener cannot be attested answers the enable-push request before
it closes.
The deadline on a registration begun from a click counts no time while
the browser's own notification prompt is open, in the permission popup
and in the enable-push dialog. The enable-push dialog keeps one
registration in flight, settles on whenever it resolves while open, and
offers a retry only after a real failure. A permission dialog that ends
in a denial after Allow began a registration turns push back off and
restores the opt-out it cleared.
An app on its own domain can offer to turn push on in the user's browser
from its own button: puter.notifications.enablePush() opens a popup on
the Puter origin whose single button supplies the click the browser's
prompt needs, and inside the desktop the same dialog is shown over IPC.
The popup hands the opener no token. Docs for the new call and for the
paths that now call the push client's enable().
On desktop and dashboard boot, and when an app that holds
notifications:push is launched, a browser whose notification permission
is already granted registers again without a click, unless the user
turned push off in this browser.
Allow on a permission request that includes notifications:push also
turns push on in this browser, from the same click, when it is off here.
The decision never depends on it. The permission popup starts the
service worker for such a request, answers the opener first and closes
once the registration is done. The Notifications tab records a browser
the user turned off, per user.
The browser card's button stays out while the client is enabling or
disabling, through any redraw in between, and a status read begun before
the press no longer draws the card.
A tap from the worker names only a notification; the desktop and the
dashboard read the signed-in user's own row and route from its app,
target and data, as a tap from the URL already did. Without a row the
dashboard opens the list and the desktop does nothing.
Listeners get their own copy of the value, so what the socket carries is
what was written. Both emits name the audience, and an update names the
row's app when the row or the value says which.
- The access level and Share button appear beside the field only once
someone is entered, instead of a full-width button that sat disabled.
- The access list sits directly under the field. Each person's menu ends
with Remove access (or Cancel invitation), replacing the trash column;
the inline confirmation stays.
- General access is one row: a lock or globe icon, the switch with its
explanation beneath, and the level on the right.
- A footer holds Copy link and Done. Copy link is offered for any single
item, since the link opens it for anyone who has access, and confirms
in place. Status messages live in the footer; successes fade.
Co-Authored-By: Claude Opus 5.5 <[email protected]>
A user-to-user grant check loaded the holder's whole row set, from one
Redis blob or one SELECT, and filtered it in JS. Every KV share handle a
user receives adds a row, so the cost of each check grew with incoming
shares.
Holders with up to 200 grants keep the whole-set cache. Past that the
cache holds a marker and each check reads only the permissions it names,
off a new (holder_user_id, permission) index. Matches stay exact in JS,
since MySQL compares `permission` case-insensitively. The batched
multi-holder read shares the same chunked query.
Migrations: mysql_mig_51, postgres_mig_40, sqlite 0097.
* fix(dashboard): keep Files keys away from the rows under a publish dialog
With Publish as website or Publish as Worker open and focus on one of its
buttons, the arrow keys moved the listing's selection behind the dialog,
Ctrl/Cmd+A selected every row, and Delete trashed whatever was selected.
The Files shortcuts already stood aside for the share and properties
modals; they now do for the dashboard dialog overlay too.
* fix(dashboard): let type-to-select in Files find names with spaces
Typing "my z" to reach "my zebra.txt" stopped at "my notes.txt": the space
went to the image preview instead of the name being typed, and the search
went on as "myz". A space typed while a name is being typed now belongs to
it; on its own, Space still opens and closes the preview.
* fix(gui): say so when a rename can't reach the server
A rename that failed on the network (offline, connection dropped) put the
old name back without a word, so in Files it looked as if the rename had
been ignored; only failures the server answered with a message were
reported. Every failure is now reported, with the unknown-cause message
when there is no other. The rename's own promise was also left to reject
unhandled on every failure, conflicts included; the error callback already
handles it.
* fix(dashboard): show a grid tile's full name on hover
A tile name cut short to fit shows in full on hover, but in the grid views
the hover never reached it: the badge layer is the size of the whole tile
and sat on top of the name. The name now sits above that layer, so its
tooltip shows; clicking and dragging by the name behave as before.
* fix(dashboard): make the empty-folder notice readable in dark mode
"No files in this directory." and "This folder couldn't be opened." were
styled inline with no color of their own and inherited the page default,
black, which on the dark theme's background could hardly be seen. Both now
share a class with the muted text color the Apps tab's empty state uses,
in either theme.
* fix(dashboard): let a folder be published again once its website is removed
Disassociating a folder's website in Properties took the globe badge off
its row, but the row's entry still said it had a website: its menu kept
Publish as website greyed out, and going into another folder and back
brought the badge back. The entry the menu and the listing read is now
updated with the row, and the sites cache is refreshed, as publishing does.
* fix(dashboard): keep a just-published worker's badge across navigation
Publishing a .js file as a worker badged its row, but the row's entry still
listed no worker. Going into a folder and back redrew the row from that
entry (the folder's cached listing), so the badge went away and the menu
offered Publish as Worker again, which would deploy the file a second time.
The entry now records the worker, as publishing a website records its site.
* fix(dashboard): badge a folder published from inside it once you go up
Publishing the folder on screen (from the listing's background menu) and
going Up showed its row without the globe badge, and with Publish as
website on offer again: the parent's listing came from puter.js's cache,
and adding a website sends no fs event to clear it. Removing a website from
inside the folder (Properties > Disassociate) left the badge behind the
same way. Both now drop the parent folder's cached listing.
* fix(dashboard): don't offer a second website for a published folder
A folder's own row greys out Publish as website once the folder has a
site, but its menu on the sidebar, a breadcrumb or the listing's
background kept offering it, and publishing again gave the folder a second
address. Those menus now check the user's sites as well.
* fix(dashboard): give Files' row menu button a finger-sized target on tablets
On a touch screen with room for the full list columns (an iPad in
landscape, say), a row's '⋯' was a 20x23px target right beside a row whose
tap opens the item, so a near miss opened the file instead of its menu.
On touch it now answers across the row's height and 44px wide; phones'
two-row layout keeps its 44px square, and mouse pointers are unchanged.
* fix(dashboard): keep Files' Back arrow clear of the phone menu button
On phones the breadcrumb row was shorter than the fixed menu button beside
it, so the button hung over the arrows below, and its enlarged tap area
took the top quarter of Back: a tap there opened the menu. The crumb row
is now as tall as the button, which also lines the crumbs up with it, and
on touch the crumbs themselves get a taller tap target.
* fix(dashboard): fit the Files image preview to the image and the listing
The Space preview was a strip the full width of the listing whatever the
image's size (a 64px icon sat in a card 900px wide), and at 3px from each
edge rather than the 20px it was given, its padding coming on top of its
width. In a short listing (a phone in landscape) it ran past the bottom of
the screen and cut off the file name. The card now wraps the image, stays
20px inside the listing both ways, and shrinks the image to keep the name
in view.
* fix(dashboard): let the last grid tile scroll clear of the phone footer
On a phone with a home indicator (a web app on the home screen, or Safari
with its bars collapsed) the Files footer grows by the bottom safe area.
The list makes room for that, but the grid views clear the footer with a
fixed 30px margin, so the bottom 17px or so of the last tile stayed behind
the footer even scrolled to the end. The grids' margin now grows with the
footer; select mode already made room for its bar and is unchanged.
* fix(dashboard): don't claim a large shared selection is restricted
Sharing more than 50 items skips listing who has access, but General
access still said "Restricted: only people with access can open these
items" without having looked, though some of them may be open to anyone
with the link. Choosing Restricted to close them did nothing either, since
it was already the selected option. It now says the link access wasn't
checked, and either choice applies to every item.
* fix(dashboard): don't let "Add to all" lower access on an item it couldn't list
Sharing several items lists who has access to each. When one item's
listing failed, the person's row counted that item as one they couldn't
reach and offered "Add to all", which granted it at the row's mode: someone
who could edit that item was quietly brought down to view. "Add to all"
now extends a grant only to items whose access was listed.
* fix(dashboard): let a click through as soon as a Files dialog closes
The share, properties and publish dialogs fade out for 200ms after they
close, and while they did, their invisible backdrop still took the
pointer: a click on a row right after Escape or Close was lost. The
backdrop now takes clicks only while the dialog is showing.
* fix(dashboard): don't make a removal under way look cancelled on Escape
In the share dialog, Escape on an open "Remove access" confirmation puts
the person's row back. Pressed after Remove was clicked, while the request
was still out, it did the same: the row came back as if the removal had
been called off, and then vanished when it finished. Escape now leaves a
removal that is under way alone.
* fix(dashboard): keep Share down while a share is on its way
Editing the name in the share dialog while a share was still being made
enabled the Share button again, so Enter sent a second share alongside
the first; the first one's end then took the spinner off while the second
was still running, and the two results raced for the status line. The
button now stays down until the share in flight is done.
* fix(gui): find the web address of files inside a published folder again
Files under a folder served as a website carry a link badge and their
address on the site. Working that out read `dir_path` and `address` off
each of the user's sites, which the hosting list no longer returns, so no
file ever had one. In Files the badges publishing just put up were gone at
the next listing. The folder and address are now read from the site's
root_dir, where the list carries them.
* fix(dashboard): show the link badge in the dark list view
In dark mode the list view drew a file's link badge (it is served by a
published website) as its black glyph on the dark row, where it couldn't
be seen. The grid view already gave it a light disc; the list view now
draws the glyph light.
* fix(dashboard): let Undo take back a paste of something cut from its menu
A row menu's Cut puts the item's bare path on the clipboard, where the
Files keys and the selection bar put { path, uid }. Pasting it moved the
item but recorded nothing for Undo (and didn't point Back/Forward at a
moved folder), so Ctrl/Cmd+Z then undid whatever came before, a rename
say. A bare path is now taken as an item like the rest.
* fix(gui): undo one action per Ctrl/Cmd+Z
The Undo shortcut sits in a handler bound to both keydown and keyup, and
ran on each: letting Z go before Ctrl/Cmd, as one usually does, undid a
second action straight after the first. In Files that could put back a
rename or delete an upload the user never meant to touch. It now runs on
the key going down only.
* fix(dashboard): re-sort and re-icon a Files row renamed elsewhere
A file or folder renamed in another tab, on another device or in a desktop
window took its new name in the listing but kept the place its old name
sorted to, and the icon of its old type (notes.txt renamed to notes.png
still showed a text icon). A renamed folder also left Back/Forward on its
old path. Such a row is now handled as a rename made in Files is.
* fix(dashboard): stop failed and cancelled Files uploads throwing uncaught
An upload from Files reports a failure in its progress window, through the
error callback, but the upload's promise also rejected with nobody
listening, so every failed or cancelled upload logged an uncaught error. It
is now handled there.
Unregistering /sw.js wherever pushEnabled is unset ended the browser's
push subscription, so a single page load on such a server stopped pushes
from every server. Turning push off already ends the subscription.
Without a push extension the /push routes answer core's plain 404. The
GUI maps that to push_unavailable, so the tab says pushes aren't
available on this server instead of reporting a load failure, and a
device removal only counts as done when the device itself is missing.
The push client doc describes the payload sw.js reads and where the
push endpoints come from.
sw.js shows each push as a notification and, on a tap, focuses or opens
a Puter window and hands it the tapped notification. The desktop routes
the tap to the app the notification names, opening it when needed, or
to the notifications panel. The worker is registered only when the
pushEnabled gui param is true.
Settings -> Notifications turns pushes on and off for this browser
through a push client an extension registers, lists the user's devices
and the apps allowed to push, and switches Puter's own pushes. The tab
shows only when the pushEnabled gui param is true. The sessions window
shares the user agent formatting with it.
/sw.js serves the GUI's sw.js, preceded by one importScripts per script
an extension registered with registerServiceWorkerScript. Whether the
GUI registers the worker follows the pushEnabled gui param, which only
an extension sets. Notification reads also carry the row's app_uid, so
the GUI can route a tap to the app a notification is about.
A deployment with no push service has no /push routes, so core answers
the device calls with its catch-all 404. registerDevice, listDevices and
removeDevice turn that into push_unavailable, kept apart from a missing
device, which comes back as device_not_found.
An app notifies the user it runs for, reads its own notifications back
and follows new ones, hears clicks on them from the desktop, and asks
for or checks the notifications:push grant. Puter's own apps also
register, list and remove the user's devices.
NotificationService emits notif.written after each non-silent write and
after an in-place rewrite, on the node that wrote the row, so an
extension can act on new notifications without a second bus round trip.
It stays off the published event stream; notif.created carries the
notification itself.
The consent check that reads it lives with the delivery code, outside
core. The grant flow test checks the grant through the permission
service directly.
* fix(kv): sign share handle ids and drop retired handles from every node's cache
New handle ids carry a short HMAC (kvh-<uuid>.<mac>); an id whose mac
does not verify is refused before any lookup. Unsigned ids keep working
but no longer fall back to the primary on a replica miss.
Retiring or changing a handle now broadcasts outer.pubsub.kv.share.forget
so every node drops its cached row. Event subscribes and delivery
rechecks refuse a handle whose owner is suspended, as the driver does.
* fix(kv): an app its user removed stops reaching shares
An app acting for a user exercises a share grant only while the user
still has the app authorized: at least one user-to-app permission row,
which every app token path leaves and revoke-user-app '*' removes. The
share itself stays; authorizing the app again restores access. The
answer rides the permission cache, keyed by the app-under-user
generation the revoke bumps, so live subscriptions go quiet too.
* feat(kv): list keys through a watch-level share handle
list with as: 'keys' through a handle needs only watch, which already
learns every key written in the region. Values and entries still need
read.
* feat(kv): filter share listings by app and by state
GET /kv/shares and GET /kv/shares/with-me take an optional appUid: an
app uid, or null for the app-less store. An account session may name
any app; an app is refused any namespace but its own. GET /kv/shares
also takes state=live|revoked|all, all by default.
* feat(puter.js): appUid and state options on the KV share listings
listShared() and listSharedByMe() pass appUid (null for the app-less
store), and listSharedByMe() passes state. Docs cover the new options
and that a watch share can list keys without their values.
* feat(kv): indexes and store counts for share-handle quotas and retention
Adds covering indexes for the owner's live count, the owner-holder pair,
app-filtered listings and retired rows, plus store methods to count a
pair's live handles and delete rows retired before a cutoff. The live
handle cache holds up to 50,000 entries; the miss cache stays at 10,000.
* feat(kv): higher share-handle caps, a per-holder cap, and sharing with several recipients
Live share handles per account go to 25,000 paid / 5,000 free, and per
app to 10,000 / 2,000. One owner may hold out 256 live handles to one
holder on every plan, which bounds the rows a revoke or unshare walks.
Re-sharing an existing region takes no slot and is never refused.
POST /kv/shares takes recipients (1 to 50) as an alternative to
recipient: one region, level and label for all. Caps and a new
per-recipient grant budget (300/min, 2,000/hour) are checked for the
whole batch up front; failures about one recipient are reported on its
item. The single-recipient request and response are unchanged and run
through the same code path.
* feat(kv): delete share handles 30 days after they stop
A daily job deletes retired share-handle rows older than 30 days, in
batches of 500. One node runs it per day; it stops on shutdown.
* feat(puter.js): share a KV prefix with several users in one call
puter.kv.share() takes an array of recipients, positionally or as
recipients in the options form, and resolves to one item per recipient:
the share, or an error with its message and code. The single-recipient
forms are unchanged.
* feat(kv): delete an app's data on uninstall, and close account-deletion gaps
Uninstall:
- POST /auth/revoke-user-app takes an optional `deleteData` boolean, only
with `permission: '*'` (400 otherwise). AuthService.uninstallApp revokes
every grant, deletes the app's worker sessions, then awaits
`app.uninstalled` { userId, userUuid, appUid, deleteData }.
- With deleteData, KvShareService retires the handles the user shares out
of that app's namespace (as the platform, in batches) and then empties
the namespace in the background, 5,000 items per step, stopping on
shutdown. Handles shared with the user are not touched.
- SystemKVStore.purgeNamespace(namespace, { maxItems }) deletes up to
maxItems items of one `v1:<uuid>:<appUid>` namespace, reading keys only,
with no mutation events or usage, and reports `done` once it is empty.
It refuses the system namespace and anything else. flush shares its
page-delete path.
- puter.perms.revokeApp(appUid, '*', { deleteData: true }); the desktop's
uninstall dialog has an unchecked "Also delete this app's data" option.
Account deletion:
- EventsService settles each of a deleted account's apps on `user.delete`
(they cascade without `app.changed`) and drops the owner's subscription
keys.
- The flat permission entries the account held are purged in pages.
Suspension:
- `user.suspended` / `user.unsuspended` events; the share-handle store
drops cached rows owned by that user here and on other nodes.
* fix(permission): await a peer's flat-permission deletes so emitAndWait sees them applied
* fix(kv): strongly consistent purge pages, and an uninstall purge stops once the app is authorized again
A stale empty page could end a purge as done while items remained. The
background purge after an uninstall now checks before each step that the
user hasn't authorized the app again, so data written after that is kept.
* fix(kv): flush keeps eventually consistent page reads; only a purge reads strongly consistent
* fix(user): emit user.suspended and user.unsuspended when the user store changes an account's suspension
The grant route now only accepts permissions from a fixed set of
app-grantable families, which refused notifications:push, so an app could
never be given push consent. Register it next to events:background.
puter.hosting.update(name, null) sends root_dir: null, which the driver
stringified into a lookup for a folder named "null" and rejected, so
disassociating a website from a folder's properties always failed.
The feed admission gate now applies uniformly: declared capabilities
must include a chat capability (TG, Reasoning, VU), request modality
must include Text, and response modality must include Text. Signal-less
entries are admitted only when they match a curated model.
Uncurated entries admitted from the DashScope catalog feed previously
surfaced with their raw feed id. They now get an
alibaba:<vendor>/<model> puterId, vendor-prefixed ids keep their org,
and others infer the org from the curated family sibling. Entries whose
synthesized puterId collides with a curated entry are skipped so the
model is not listed twice.
Cache writes now meter under `cache_write_tokens` at the model's write
rate, split out of `prompt_tokens` by the shared usage splitter, so K3 gets
the same breakdown. The credit hold reserves the prompt and any replayed
reasoning at the input rate, and a stream that ends without a usage chunk
is charged the estimate like every other provider. The per-TTL response
headers, the 502 accounting error and the one-hour write prices are gone:
the cache is scoped to the account and nothing here requests a one-hour
TTL, so those writes never occur and Moonshot publishes no rate for them.
The catalog entries carry the release date so responses normalize by
default, the provider suite no longer imports the SDK, and the docs cover
the reasoning efforts, replay and cache usage keys in their existing
sections.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
An OpenAI-shaped `tool_calls` entry normalizes with its `arguments` still a
JSON string, and hoisting it back into `tool_calls` stringified that string
again, so every OpenAI-compatible provider received a JSON string literal
instead of the call's arguments. Hoisting now sends a string `input` as is
and only serializes object inputs.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
Apps need a per-app notifications:push grant for their notifications to
go out as pushes. hasPushConsent checks it as the app acting for the
user, so only that user's grant to that app answers yes, and a revoke
takes effect on the next check. The permission dialog describes it and
the perms docs list it.
App messages carry the sending app's uid and name (and its icon when it
is an https URL), set by the server; the desktop toast and the dashboard
panel show the name above the title. Title and text drop invisible
formatting marks and control characters, with newlines kept in text and
whitespace-only text left out. The icon is stored as its normalized URL,
and a target may not hold dot segments or non-ASCII characters. Both
rate-limit windows are read before either is spent, so a refused call
counts against neither. The docs say what ttl means for push delivery.
Adds the app.message type (audience app-user). An app, its workers and
the tokens it issues can create it through puter-notifications create,
only for their own user and under their own app. The value is validated
against a fixed format, the write is not silent, and each app has a
per-user budget of 30 per minute and 300 per day, configurable with
notificationAppMessageLimits. Holders keep creating the other types
silently and cannot create app.message.
@@ -159,13 +159,42 @@ The reference has two kinds of page:
An overview page answers "what is this API and is it what I need?", and a function page answers "how exactly does this call behave?". A change to one function usually only touches that function's page, so a new `kv.list()` option goes in `KV/list.md` without a matching update to `KV.md`. Don't copy a function's options, limits, or edge cases onto the overview page for completeness. Where a detail goes is a judgment call, not a hard rule: update the overview when a change affects how someone would describe the API as a whole.
When you add a page, add it to [src/docs/src/sidebar.js](src/docs/src/sidebar.js) under its area, or it won't show up in the navigation. When you add a runnable example, add its HTML to `src/docs/src/playground/examples/<slug>.html`, register the slug in [src/docs/src/examples.js](src/docs/src/examples.js), and link the code block to it by putting the slug after the language with a semicolon (` ```html;kv-set `).
When you add a page, add it to [src/docs/src/sidebar.js](src/docs/src/sidebar.js) under its area, or it won't show up in the navigation.
**Sidebar order.** Order the API areas in the sidebar by how often developers use them, most common first. Overview stays first and Objects stays last. Inside an area, order the pages by use too, and keep pages that make up one feature next to each other, such as `share()`, `unshare()`, `listShared()`, and `listSharedByMe()`. A group goes where its most-used page would go. A helper that only supports another function, such as `listModels()` for `chat()`, goes by its own use. Put create, list, get, update, and delete in that order, and constants such as `MAX_KEY_SIZE` last. The function list on the area's overview page uses the sidebar's order. When you add a runnable example, add its HTML to `src/docs/src/playground/examples/<slug>.html`, register the slug in [src/docs/src/examples.js](src/docs/src/examples.js), and link the code block to it by putting the slug after the language with a semicolon (` ```html;kv-set `).
### Function page layout
A function page opens with one sentence that says what the function does, in its own paragraph, such as "Given a prompt, generate an image using AI." Put any more detail in the paragraphs after it.
After the intro, a function page has these `##` sections, in this order:
1. Syntax
2. Parameters
3. Return value
4. Errors, if the function has any
5. Sections for specific topics, if the page needs them, with `## Model-specific features` last
6. Examples
[chat.md](src/docs/src/AI/chat.md) follows this layout.
- **Options as a table.** Document an options object as a table with Option, Type, and Description columns. Give each option its own row, even when two options go together, such as `width` and `height`. Order the rows by how often developers use them, most common first, without group rows. In chat.md that's the core options, then reasoning, tools, response handling, request control, and output.
- **Common options in the table, model-specific behavior in its own section.** A page whose function works with many models or providers, such as [chat.md](src/docs/src/AI/chat.md) and [txt2img.md](src/docs/src/AI/txt2img.md), splits its options this way:
- **The options table** under Parameters lists the options that work with every model, and describes each one the way it works for every model.
- **`## Model-specific features`**, the last section before Examples, covers everything else. It has a `###` for each model family or provider, named after it, such as "OpenAI models" or "Cloudflare models". Under it go the options only some models accept, and the common options a model handles differently, such as a narrower range, other allowed values, or an option it ignores.
- **Write there only what's different.** A `###` lists an option only when that model or provider adds it or handles it differently. An option that works the same everywhere stays out, even if the provider supports it, and a model or provider with nothing different gets no `###`.
- **Model-specific features is the only place for model-specific details.** Every limit, format, allowed value, default, price, or quirk that applies to some models goes there, in that model's or provider's `###`, and nowhere else. The options table and topic sections (such as Image inputs or Video input) describe what's true for every model and link to Model-specific features for the rest. Don't add a table or sentence there that compares providers or repeats a model's limits. Each copy is another source of truth to keep in sync, and copies drift when a model changes.
- **Link the two.** Directly under the options table, put `Some models accept additional options or handle these options differently. See [Model-specific features](#model-specific-features).` A row whose option behaves differently on some models can also end with `See [Model-specific features](#model-specific-features).`
- **A long topic gets its own `##` section.** When explaining an option or feature takes more than a sentence or two, such as function calling or output dimensions, give it a section between Errors and Examples and link to it from the options table. This keeps the reference sections short.
- **Usage patterns are examples.** A way of using the API, such as stopping a stream, goes in a runnable example that the relevant section links to ("See the [stop a stream example](/playground/ai-chat-stop-stream/)."), not in a section of its own.
- **Each example has a `###` heading** under `## Examples`, so every example shows in the table of contents.
### Writing docs
These apply to reference pages and recipes alike.
- **Document the developer-facing side only.** The reader is building on top of puter.js, so cover what affects their code: signatures, defaults, return shapes, errors, limits, and behavior they can observe. Leave out internal workings (which service handles the call, storage layout, caching, retries) unless they change what the developer has to do. [rate-limits-and-quotas.md](src/docs/src/rate-limits-and-quotas.md) is the exception: it publishes every limit, including timeouts and caps that come from how the backend works.
- **Document Puter as it runs on puter.com.** The docs are for developers building on production Puter. Leave out behavior that only happens on a self-hosted or differently configured server, such as a provider that isn't set up there.
- **Describe the current state.** Docs aren't a changelog. Write how the API behaves now, without "now", "no longer", "existing code is unaffected", or other wording that compares it to an older version.
- **Verify before you document.** Run the calls you describe against a running Puter instead of inferring behavior from the source.
- **Write plainly.** Use the literal words a developer would say out loud, with contractions and everyday words. No idioms or figurative verbs ("walk away from something shared with you" → "remove your own access", "nothing to page through" → "without pagination"). No formal register ("present only while more pages remain" → "includes a `cursor` if there are more pages to load"). Prefer dev terms such as atomic, concurrent, and insertion order.
@@ -173,6 +202,7 @@ These apply to reference pages and recipes alike.
- **Describe behavior directly.** No quoted inner-voice phrases. Write "`isNew` is `true` when the person did not have access before", not "this tells 'I just gave this to someone' from 'they already had it'".
- **Keep references easy to follow.** Name what a list or code block belongs to in the sentence directly above it. Name a thing once and keep that name. Introduce an actor as "a user" before giving them a role: "A user can create a team. The user who creates the team becomes its owner."
- **Sentence mechanics.** Never start a sentence with inline code: write "To store data, use the [`set()`](/KV/set/) method." Link every inline mention of an API to its page, every time. No em dashes, and no colons or semicolons mid-sentence; a colon that introduces a code block is fine.
- **No `##` or `###` headings inside tabs.** The table of contents lists every `##` and `###` heading on the page, including the ones in a hidden tab (an `example-content` block switched by the `example-group` pills), so its links lead to content the reader can't see. Put the heading above the tabs, or use `####` inside a tab, which the table of contents skips.
- **Show the correct way.** No "don't" and no wrong-way-first examples, in recipes and playground examples alike. Mention a wrong approach only when developers already hit it in practice, and state how it fails as a fact: "`add()` reads a plain object as a path map, so the call rejects with …".
### Recipes
@@ -201,6 +231,6 @@ Recipes are tested, copy-pasteable patterns for a task, one layer above the per-
- **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.
- **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).
- **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.
@@ -40,7 +40,7 @@ The following have already been reviewed and determined **not to be vulnerabilit
- **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. This covers **private apps** too: private apps are an unreleased, admin-only beta for paid listings in the Puter App Center. Their `index_url` and other metadata are not secret, the flag is not user-settable, and no production app relies on it. Reports that a private app's metadata is reachable through a sibling route (taskbar items, `/get-launch-apps`, `index_url` drift on the hosting gate, etc.) are normalization work, not vulnerabilities.
- **App metadata or app user-count "leaks".** This information is currently public by design. This covers **private apps** too: private apps are an unreleased, admin-only beta for paid listings in the Puter App Center. Their `index_url` and other metadata are not secret, the flag is not user-settable, and no production app relies on it. Reports that a private app's metadata is reachable through a sibling route (`/get-launch-apps`, `index_url` drift on the hosting gate, etc.) are normalization work, not vulnerabilities.
- **Worker hot-reload driven by a share recipient.** Sharing a worker's source file with write access is how you let a collaborator change that worker's code and have it deploy live. The redeploy running as the file owner, with the owner's worker credentials, is the intended behavior of that grant — the owner chose to hand out write access to executable code. If you do not want that, do not share the source file with write access.
- **Re-claiming a site or worker name its owner deleted.** A hosted site or worker name that its owner deletes is treated as abandoned by that owner. If one of their apps still points at it, whoever claims the name next serves that app's `index_url` and receives what the app is launched with, and a new app on the same origin may share the old one's identity. Guarding app identities against being recycled this way is ongoing hardening, not a security boundary, so new ways to re-claim an abandoned name, or to inherit an app's tokens or data through one, are not eligible.
- **Share-mode downgrades that do not cascade.** Demoting a delegate does not retroactively revoke the grants that delegate already issued downstream. Shares are independent grants once issued, not a live-evaluated chain. Revoking the downstream grant is a separate, supported action.
@@ -47,7 +47,7 @@ Both are supported ways to define an API. **Prefer a controller when you need fi
Provider sign-in requires the account's authenticator or a recovery code when 2FA is enabled, including signup callbacks that resolve an existing account. The five-minute challenge uses the same single-use `/login/otp` and `/login/recovery-code` verification as password sign-in. The GUI carries it in a URL fragment and removes it before initialization. A session and successful popup-return proof are issued only after verification; cancelling retains the popup's signed retry context without marking it authenticated.
Disabling 2FA through `/user-protected/disable-2fa` requires both password or OIDC revalidation and the current six-digit authenticator `code`. Missing, malformed, or incorrect codes leave the secret and recovery codes intact. A team's requirement for 2FA still prevents disabling it.
Disabling 2FA through `/user-protected/disable-2fa` requires both password or OIDC revalidation and either the current six-digit authenticator `code` or an unused recovery code in `code`. Recovery codes are consumed atomically. Missing or incorrect codes leave the secret and remaining recovery codes intact. A team's requirement for 2FA still prevents disabling it.
Email changes through `/user-protected/change-email` require the password or OIDC revalidation cookie, plus a six-digit `code` from the authenticator when 2FA is enabled. Missing or malformed codes return `400 bad_request`; incorrect codes return `401 code_mismatch`. Both include `factor: 'otp'`. Confirmation links are bound to that verified request. A 2FA-enabled account must request a new link if its existing link was issued without second-factor verification.
Puter's desktop shows pushes through `/sw.js`. Core shows Settings → Notifications only when the `pushEnabled` gui param is `true`; core never sets it. The worker is registered only when push is turned on in the browser, and a load removes one left without a subscription. A backend extension that delivers pushes sets it with `services.homepage.setGuiParam('pushEnabled', true)` and can load its own code into the worker with `services.homepage.registerServiceWorkerScript(path)`.
Core subscribes the browser to a push service only when the server also publishes `pushWebPushKey` (see [Built-in Web Push client](#built-in-web-push-client)), and it has no `/push/*` routes of its own. The device, app, preference and subscription endpoints below come from that extension. On a deployment without one, such as most self-hosted servers, they answer core's plain 404 (`code: 'not_found'`), which `puter.notifications` reports as `push_unavailable` and the Notifications tab shows as unavailable. A device the extension can't find is `device_not_found`.
## Push payload
`sw.js` reads the push's JSON body. The message is its `puter` field, or `data.puter` for services that only carry string data, as an object or a JSON string:
`uid` is the notification's uid, a later push with the same `group` replaces the one showing, and `target` and `data` reach the app when the user taps it.
When the browser replaces the subscription (`pushsubscriptionchange`), `sw.js` posts `{ type: 'puter-push-subscription-changed' }` to every open window on Puter's origin. The worker has no token or session, so the page's push client registers the new subscription.
## Receipts
Before a notification is pushed, the server asks the open clients whether they received it live. It sends `notif.receipt` with `{ uid }` on the user's sockets for the app that sent it, or on the desktop's sockets for Puter's own notifications. The SDK remembers the uids its page received through `onNotification()`, `onPush()` or the desktop's feed, and answers once its page has that notification, or with `{ received: false }` when it never gets it. The server waits up to 5 seconds (`notificationAckTimeoutMs`).
An answer carries `visibilityState` and `focused` as they were when the page received the notification. Pages never show a browser notification themselves; pushes do.
- An answer from a page that was visible and focused stops the push (`handled` on `notif.delivery.settled`). An older client that doesn't send `focused` counts while visible.
- Otherwise the notification is pushed, to every browser the user subscribed. A website's pushes go only to the browsers that subscribed through its own worker, never to Puter's `/sw.js`. An app inside the desktop pushes through `/sw.js`, which shows the app's name and icon.
- A client that can't answer, such as an older puter.js, doesn't hold the push back.
The ask reaches only the sockets in the asking region. So that other regions hear about a page too, a page that receives a notification live while visible and focused also sends `notif.handled` with `{ uid }` on the socket it came on. Core relays it as the node-local `notif.handled` event with the socket's user and app, at most 60 a minute per user; an extension that delivers pushes decides what to do with it.
## Registering
The registry is in [src/gui/src/helpers/pushClient.js](../src/gui/src/helpers/pushClient.js). A service script registers its client like this:
```js
service_script(async(api)=>{
api.use()['push.registerPushClient']?.(client);
});
```
`use(id)` throws when nothing is registered under `id`, so the form above also runs on a Puter version without the registry and does nothing there. Any script that runs after the GUI bundle can call `use()['push.registerPushClient']?.(client)` the same way. Registering again replaces the client. Registering dispatches `push-client-registered` on `window`, and an open Notifications tab redraws.
With no client registered, the tab says push notifications aren't available in this browser.
### Built-in Web Push client
When the server also sets the `pushWebPushKey` gui param (a VAPID public key), core registers its own client ([webPushClient.js](../src/gui/src/helpers/webPushClient.js)) unless an extension registered one first. It uses `puter.notifications.enable()` / `disable()`, the same standard Web Push flow apps use on their own origins:
1.`GET /push/web/key` for the key, then `pushManager.subscribe()` on the `/sw.js` registration.
2.`POST /push/web/subscriptions { subscription }`. A subscription the server already has answers `{ status: 'active' }`. A new one answers `{ status: 'pending' }`, and the server sends it a push with `{ type: 'challenge', challenge }`.
3. The worker shows that push and posts `{ type: 'puter-push-challenge', challenge }` to the origin's windows; the page sends it to `POST /push/web/subscriptions/verify`. The device is registered once the page hands the confirmation back.
4.`DELETE /push/web/subscriptions { endpoint }` removes it.
Apps on their own origin do the same through `push-sw.js`, which they import into their own worker. A subscription made with an app token is the app's: its pushes are that app's notifications only.
## Interface
```ts
interfacePushClient{
enable():Promise<{deviceUid: string}>;
disable():Promise<void>;
status():Promise<{
permission:'default'|'granted'|'denied';
registered: boolean;
deviceUid?: string;
}>;
}
```
-`enable()` may ask for notification permission, so core calls it from a click: Turn on in the Notifications tab, Allow on a permission prompt that includes `notifications:push` while this browser is off, and the "Turn on notifications" button of the `/action/enable-push` popup and the matching desktop dialog (`UIEnablePushDialog`). Core also calls it without a click, from `helpers/pushSync.js`, when `Notification.permission` is already `granted`, `status()` reports the browser as not registered, the user has not turned push off in this browser, and an app holds `notifications:push`; the browser does not prompt then. The built-in client runs `puter.notifications.enable()`, which subscribes with the `/sw.js` registration and registers the browser through the Web Push flow above (`platform: 'webpush'`). It then resolves the `uid` of this browser's `webpush` device from `listDevices()`. It rejects when permission is refused or the browser wasn't registered.
-`disable()` runs `puter.notifications.disable()`, which sends `DELETE /push/web/subscriptions { endpoint }` and ends the browser's subscription. The tab calls it for Turn off, and for Remove on the device marked as this browser. Remove on any other device is `DELETE /push/devices/:uid`.
-`status()` reports the state without prompting. `permission` is `Notification.permission`. `registered` is true when this browser has a device registered for the signed-in user, and `deviceUid` is that device's `uid`.
After `enable()` resolves, the tab calls `POST /push/devices/:uid/test` and asks the user whether a notification showed up.
The tab also reads `GET /push/apps` and `GET`/`POST /push/preferences`.
Core keeps its own record of a browser the user turned off, `puter.push.browser-off:<user uuid>` in `localStorage`, written by Turn off and by Remove on this browser's device, and cleared when `enable()` resolves. The silent registration above skips a browser with that record, so a device removed here stays removed while one removed from another device, or a new sign-in in a browser that already allows notifications, registers again.
- Confirm before running anything else: `docker compose version` should print a
version, not `command not found`.
- If Docker is installed but the shell can't see it, make sure your user is in
the `docker` group (Linux) or that Docker Desktop is actually running.
- A **domain** with DNS access — you need a wildcard record (`*.your-domain.com` → server IP). Puter routes by subdomain (`api.<domain>`, `site.<domain>`, `app.<domain>`).
- Optional: **TLS certs** (or `certbot` to grab them — see Step 3).
@@ -657,6 +667,14 @@ Migrations re-apply idempotently across pulls. Volumes are preserved.
## Troubleshooting
**`missing required command: docker` — the installer stops immediately.**
Docker isn't on your `PATH` yet. Puter self-hosting is a Docker Compose stack, so
nothing can run without it. Install [Docker Desktop](https://docs.docker.com/get-docker/)
on macOS/Windows or [Docker Engine](https://docs.docker.com/engine/install/) plus
`docker-compose-plugin` on Linux, then confirm `docker compose version` works in
the same shell and re-run the installer. On Linux, a fresh Docker install usually
needs a new login session before your user picks up the `docker` group.
**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`).
describe('S3Client — bucket in another region',()=>{
letserver: Server;
letclient: S3Client;
letsignedRegions: string[];
// Answers like a bucket that lives in us-west-2: a request signed for any
// other region gets S3's 301 naming the bucket's region.
beforeEach(async()=>{
signedRegions=[];
server=createServer((req,res)=>{
constregion=
/Credential=[^/]+\/\d{8}\/([^/]+)\//.exec(
req.headers.authorization??'',
)?.[1]??'';
signedRegions.push(region);
if(region!=='us-west-2'){
res.writeHead(301,{
'content-type':'application/xml',
'x-amz-bucket-region':'us-west-2',
});
res.end(
'<?xml version="1.0" encoding="UTF-8"?><Error><Code>PermanentRedirect</Code><Message>The bucket you are attempting to access must be addressed using the specified endpoint.</Message></Error>',
Loaded 100 of 1208 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.