Compare commits

...
Author SHA1 Message Date
Nariman Jelveh 3dd8e599de fix(dashboard): cancel a transfer once when its Cancel is double-clicked
A cancelled row stays clickable while it fades out, so a double-click
ran the cancel twice: two abort requests for an upload, and for a guest
session two "save your session" prompts.
2026-10-25 19:48:54 -07:00
Nariman Jelveh a62cf80bdf fix(dashboard): word a transfer's time left in Traditional Chinese, Portuguese and Ukrainian
The estimate passed Puter's language code straight to Intl, which
rejects zhtw (leaving "About 9 left" with no unit) and reads br as
Breton and ua as nothing. It now maps the code with intlLocale like the
rest of the dashboard.
2026-10-25 19:48:25 -07:00
Nariman Jelveh 841d283135 fix(dashboard): name the upload a failed transfer row belongs to
Every failed upload in the tray read "Failed to upload files", so with
several running there was no telling which one failed. The row now says
"Failed to upload report.pdf" or "Failed to upload 3 items".
2026-10-25 19:47:44 -07:00
Nariman Jelveh c6350f1a7b fix(dashboard): let a transfer's title wrap so long names keep their extension
A row's title was one line cut at the end, so "Uploading quarterly-
report-final-v2.pdf" lost its extension and most of its name in a
360px tray. It now runs to two lines, like the status under it.
2026-10-25 19:46:53 -07:00
Nariman Jelveh fedc38faa5 fix(dashboard): announce transfer tray status to screen readers
The tray's summary changed silently, so a screen reader user had no
word that a transfer finished. The summary is now a status region.
2026-10-25 19:45:04 -07:00
Nariman Jelveh 2411a2baaf fix(dashboard): title a finished upload "Uploaded" rather than "Uploading"
A finished upload kept its "Uploading report.pdf" title beside its Done
mark. The tray's complete() now takes the finished task's title, and the
Files upload passes "Uploaded report.pdf" or "Uploaded 3 items".
2026-10-25 19:44:52 -07:00
Nariman Jelveh 81fb4ee84c fix: keep one progress window per copy or move across conflict prompts
Copy and move re-arm their progress timer after a conflict prompt, and
the timer opened a second progress window even when one was already up.
The first was then never closed: on the dashboard it stayed in the
transfer tray as a running task that couldn't be cleared. The timer now
leaves an open progress window alone.
2026-10-25 19:44:00 -07:00
Nariman Jelveh 24c2808a00 fix(dashboard): estimate a transfer's time left from its exact progress
The tray rounded progress to whole percentages before estimating, so a
slow upload whose percentage stood still for the 5s rate window lost
its time-left text about a quarter of the time and underestimated the
rest. The estimate now gets the uploader's hundredths; the bar and label
still show whole percentages.
2026-10-25 19:40:17 -07:00
Nariman Jelveh abc8b15d63 fix(dashboard): keep the Files selection bar clear of the transfer tray
On phones the tray and select mode's action bar both docked at the
bottom, so a running transfer hid every selection action. The tray now
rises above the bar while it shows.
2026-10-25 19:39:38 -07:00
Nariman Jelveh d767802c6a fix(dashboard): stack the transfer tray under the dashboard's drawer, sheets and dialogs
The tray sat on body above every window, so on phones it covered the
open sidebar drawer, the Share and Properties sheets, and the drag
cancel zone, and stayed bright and clickable over their scrims. It now
mounts in .dashboard just under the drawer's scrim, and remounts if the
dashboard it was in is re-rendered.
2026-10-25 19:38:09 -07:00
Nariman JelvehandClaude Opus 5.5 3cbc499324 feat(dashboard): show transfer progress in a non-blocking tray
Uploads, copies and moves in the dashboard no longer open a modal.
Each one adds a row to a tray in the bottom-right corner, and the rest
of the dashboard stays usable while it runs. UIWindowProgress hands
off to UIDashboardTransfer, which returns the same handle as before.

The tray shows how many tasks are running and can be collapsed to that
summary. Each row has its own Cancel button, a progress bar, and a
time-left estimate. A finished upload shows Done for a few seconds. A
failure stays until it is dismissed and opens a collapsed tray. The
tray never takes focus, and it sits above app windows and the Files
footer.

With no modal left to sit under, UIAlert no longer needs to stay on
top of dashboard overlays, so that change is reverted.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 19:24:45 -07:00
Nariman JelvehandClaude Opus 5.5 8ca6ecc9ca feat(dashboard): redesign upload, copy, and move progress as dashboard modals
In dashboard mode, progress for uploads, copies, and moves now shows in
the same overlay card as the other dashboard dialogs instead of a
UIWindow. UIWindowProgress hands off to UIDashboardProgressModal there,
so the shared file helpers drive either one through the same handle.

The card shows a titled header ("Uploading 3 items", "Copying",
"Moving", "Deleting"), the current item or destination, an
indeterminate bar until there is a percentage, and then a percentage
with a smoothed time-left estimate. Uploads end on a brief done state.
An error replaces the progress with a message and an OK button. While
a transfer runs, only Cancel stops it: Escape and backdrop clicks do
nothing.

The dashboard's two upload paths now share uploadFiles. A UIAlert
opened while a dashboard overlay is up now stays on top, so a conflict
prompt raised mid-copy can still be answered.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-25 19:11:58 -07:00
Daniel Salazar 24aac2f6d2 fix: app token (#4338) 2026-10-25 11:50:25 -07:00
Daniel Salazar 1eb4e36cf4 fix: add beta flag to kv share docs to match kvhandle beta status (#4337) 2026-10-25 09:17:12 -07:00
Daniel Salazar 3160b99f11 chore: bump ver (#4335) 2026-10-25 08:24:40 -07:00
Daniel Salazar 589a98b12c feat(kv): share KV prefixes with other users at watch/read/write, and bound what apps can be granted (#4331)
* feat(kv): share key-value prefixes with other users, and bound what apps can be granted

- KV share handles move out of Events into KvShareService, served at /kv/shares
  (the /events/kv-handles routes stay as an alias).
- Shares have watch, read and write levels, encoded in the permission root
  (kv-share, kv-share-read, kv-share-write) with write implying read and watch.
- Every puter.kv method takes optConfig.handle; keys are relative to the shared
  prefix, cursors are sealed, and private entries stay hidden.
- New puter.kv.share, unshare, listShared and listSharedByMe; optional label;
  kv.share.received notice in the app's mailbox only; recipient blocks apply.
- puter.kv.set gains onlyIfAbsent.
- fs and kv events gain viaWorker, and kv events report self from the writer.
- /auth/grant-user-app only accepts canonical permissions from a fixed set of
  families; the consent dialog, puter.perms and docs follow.
- Migrations: kv_share_handles gains revoked_by, label, and grantee and app
  indexes (mysql_mig_49, postgres_mig_38, sqlite 0095).

* fix(kv): never name a private key in a cross-app or handle cursor, and bound flat-permission scans

- Listings of another namespace (cross-app or through a handle) resume after
  the last entry they returned, and their cursors are sealed, bound to the
  namespace or handle, and padded to one length. Old plain cross-app cursors
  are still read when they name the target namespace; other namespaces get 400.
- PermissionStore scans flat permissions a few roots at a time and skips
  roots nested under another root in the same call.
2026-10-25 08:16:19 -07:00
Toshit ChawdaandReynaldi Chernando ec3ad1cb2f docs: easy/medium/hard networking recipes (#4040)
* add net category + http request recipes

* add tcp/tls socket recipes

* add complex recipes

* minor refine

---------

Co-authored-by: Reynaldi Chernando <[email protected]>
2026-10-25 19:22:45 +07:00
Reynaldi ChernandoandDaniel Salazar c7cc64e45c docs: medium events recipes (PUT-1971) (#4333)
* docs: easy events recipe (PUT-1961)

* docs: catch-up-on-notifications events recipe (PUT-1961)

* docs: events recipe corrections from a run against a live server (PUT-1961)

* docs: medium events recipes (PUT-1971)

* docs: simpler wording in medium events recipes (PUT-1971)

* refine

---------

Co-authored-by: Daniel Salazar <[email protected]>
2026-10-25 19:18:55 +07:00
Nariman Jelveh 0c67499dc2 fix(dashboard): serve stale profile pictures to every caller, skip detached roots
While a picture refreshed after its TTL, only the first caller got the
stale answer; every later one waited on the network, so in Files → Shared
one row showed the picture at once and the rest fell back to initials.
paintAvatars now also folds a frame's roots into one pass and skips roots
re-rendered away before the frame, so they fire no lookups.
2026-10-24 19:05:54 -07:00
Nariman Jelveh 34d74d36bd feat(dashboard): show profile pictures on people avatars (#4328)
* feat(dashboard): show profile pictures on people avatars

Avatars that name a person now show that person's profile picture once
it loads, and keep the initial-and-color avatar when there is none:

- Files → Shared: the "Shared by" cell and the people filter chips.
- Share dialog: the owner row and each person with access. Teams,
  invited emails and link shares keep their initial.
- Properties: a new Owner row with avatar for items someone else owns,
  and avatars beside each version's author.

Lookups go through one cache: each person is looked up once and shared
by every avatar of theirs, answers (including "no picture") are kept
for five minutes, at most 100 are kept, and at most four run at once.
Calls made while a listing renders row by row fold into one pass per
frame. The signed-in user's own picture is read from their loaded
profile, so it takes no request and follows changes in the Account tab.
The picture is set as an image's src property, never through markup.

* fix(dashboard): profile picture review — stale refresh, picker avatars, limiter

- Serve a stale picture while a fresh one loads, so a listing re-rendered
  after the cache expires no longer drops everyone back to initials.
- Show pictures on people in the share recipient suggestions, matching the
  rows of the dialog they open from.
- Free a lookup slot when a lookup throws before returning a promise.
- Move profilePictures.js to helpers/, now that a shared helper uses it.
2026-10-24 19:01:05 -07:00
Nariman Jelveh 0bdc2197b0 feat(dashboard): show who shared each item in Files → Shared (#4327)
* feat(dashboard): show who shared each item in the Files tab's Shared view

The Shared view now names the person behind every item:

- List view gets a sortable, resizable "Shared by" column with the
  share modal's initial-and-color avatar. The tooltip names the owner
  too when someone else re-shared the item.
- Grid tiles show the sharer under the item name.
- Narrow listings lead each row's second line with the sharer.
- A people filter above the listing (one chip per sharer, with counts)
  narrows the view to one person. It shows once two or more people have
  shared something, and scrolls sideways on small screens.

Sorting by "Shared by" applies only in the Shared view; other folders
keep sorting by name while it is the saved sort.

* fix(dashboard): keep the name column readable beside "Shared by"

In a listing between the phone layout and about 700px wide, the fixed
160px "Shared by" column squeezed an auto-width name down to a few
characters. Beside an auto-width name, "Shared by" now takes at most 40%
of the room the two split, and resizing it stops at the same width.

* fix(dashboard): keep the "Shared by" header on one line

Narrowed, its two-word label wrapped onto a second line that spilled out
of the 32px header row. It now stays on one line and ends in an
ellipsis, as longer translations need too.

* fix(dashboard): fit "Shared by" to its longest name on double-click

Auto-fitting the column measured names in a font the cell doesn't use
and left out part of its padding, so the longest name still ended in an
ellipsis. It now reads the font, padding, avatar and gap off a rendered
cell.

* fix(dashboard): keep the people filter on coming back to Shared

Opening a folder from a filtered Shared view and going Back reset the
filter to Everyone, so going through one person's folders meant picking
them again each time. The filter now lasts while the user browses what
was shared with them, and clears once they go back to their own files.

* fix(dashboard): update Shared after Remove from Shared

Remove from Shared only took the row away, leaving the people filter
counting the item. Removing the last item of the person filtered to left
a blank pane under a chip that still claimed one. Shared is now listed
again afterwards, which updates the filter, the empty notice and the
footer.

* fix(dashboard): show a notified share past the people filter

Opening a "shared with you" notification, or a share link, while Shared
was filtered to someone else picked out nothing: the item was filtered
away. The filter now gives way to everyone's when it hides an item the
notification or link names.

* fix(dashboard): truncate a long sharer name on phone-width rows

In the two-line row layout, a long username grew the details line past
the row, pushing the date off screen and under the ⋯ button. The name
now ends in an ellipsis, and the line keeps clear of the button as the
name line does.

* perf(dashboard): sort the Shared view by sharer with one collator

Each sharer comparison called localeCompare with options, which builds
a collator per call: sorting 2,000 rows took about 25ms in Chrome. One
shared Intl.Collator orders them the same in under 1ms.

* fix(dashboard): make share avatar initials readable for every name

The initial-and-color avatar drew its letter at too little contrast for
nearly half the hues a username can map to: yellows and greens came out
near 3:1 in light mode, and lower still on a selected row. The letter is
now darker in light mode and lighter in dark mode, reaching 4.5:1 for
every hue on the page, selected rows and the share suggestions' hover
row. The share dialog and the Shared listing change together, so a
person keeps one look.

* fix(dashboard): show every folder when a drag springs into Shared

Dragging an item out of a shared folder and holding it over Up, Back or
the Shared crumb opens Shared to drop into a folder there. The people
filter, which now lasts while browsing shared items, could hide those
folders, and its chips can't be pressed mid-drag. A drag arriving in
Shared now sees everyone's folders.

* fix(dashboard): keep filtering Shared when it can't be listed again

A chip click lists Shared again so its rows are current. When that
request failed, the listing was replaced with "This folder couldn't be
opened" and the chips disappeared, though only a filter had changed. A
filter change now applies to the rows already loaded when the listing
can't be fetched.

* fix(dashboard): return a drag let go over Shared to where it began

A drag that springs into Shared and is let go over the listing rather
than a folder tried to move the items into Shared itself, which is a
query and not a folder: nothing moved, nothing said so, and the user was
left in Shared. Let go over a listing that takes no items, the drag now
goes back to the folder it started in, as the cancel zone does.

* fix(dashboard): don't offer read-only shared folders as drop targets

Folders shared read-only lit up under a drag and took the drop, which
the server then refused with "Forbidden". They no longer light up, and
a drop on one sends the drag back where it began; they still open on a
dwell.

* fix(dashboard): slide a drag back when a read-only folder refuses it

A drop a read-only shared folder refused left the drag chip to vanish
where it was let go, as if something had happened. It now slides back
to where the drag began, as any drop that doesn't land does.
2026-10-24 18:10:27 -07:00
Nariman Jelveh 7759f7afe9 fix(dashboard): seventh adversarial review — bugs, UX, a11y, perf (#4326)
* fix(dashboard): show notification times in Brazilian Portuguese, Ukrainian and Traditional Chinese

The notification panel and toasts handed Puter's language code straight to
Intl, and a few of those codes aren't BCP 47 tags. Brazilian Portuguese
(`br`) is Breton to Intl, so times read "5 munut zo"; Ukrainian (`ua`)
fell back to the browser's default language; and `zhtw` threw, so Taiwan
users got English. Those codes are now mapped to pt-BR, uk and zh-TW
before formatting.

* fix(dashboard): count "yesterday" and "N days ago" in calendar days

Notification times rounded the elapsed hours to whole days, so at 23:30
something from 00:45 the same day read "yesterday", and on Wednesday
morning something from Monday night (34 hours earlier) read "yesterday"
too. Days are now counted from midnight to midnight in the reader's time
zone; anything from today stays in hours.

* fix(dashboard): don't let deploy results crowd notifications out of the panel

The panel listed the 30 newest notification rows and then dropped the
ones it never shows, a worker's deploy results among them. A developer
with 30 deploys since their last share saw "No notifications" and no
badge while the share sat behind them. The listing now asks for up to
200 rows and keeps the newest 30 the panel shows.

* fix(dashboard): keep every unread notification in the panel

The socket replays up to 200 unread notifications on connect, but the
panel's listing held only the 30 newest, and the first refresh after that
dropped the rest: 45 unread read "45" until the panel was opened, then
"30", and "Mark all as read" left the other 15 unread on the server for
the next visit. The panel now keeps unread notifications past the newest
30, up to the 200 a listing returns.

* fix(dashboard): let Undo take back a new Files folder or file

The Files tab creates folders and New › files itself rather than through
the desktop's helpers, and never recorded them for Undo. Undo then
reversed whatever came before: upload notes.txt, create a folder, press
Ctrl+Z, and notes.txt was deleted for good (undoing an upload deletes it
outright, not to Trash) while the new folder stayed. Both now record
themselves, so Undo removes what was just created, as on the desktop.

* fix(dashboard): let Undo take back a Files cut and paste

Pasting cut items in the Files tab moves them itself rather than through
the desktop's move_items, and recorded nothing for Undo. Undo after a
paste reversed the action before it instead, which deletes an upload for
good. The paste now records the move as move_items does, so Undo puts
the items back where they were cut from.

* fix(dashboard): don't drop a Files selection onto one of its own folders

With folders A and B selected, dragging A onto B moved A into B and then
stopped on "Cannot move a directory into itself" for B. Hovering B also
sprang it open mid-drag. A folder row that is part of the drag is no
longer a drop target, so the drag reverts as it does over any other
non-target. A drop elsewhere (a sidebar folder, say) also leaves out the
target folder itself and any folder that holds it.

* fix(dashboard): don't drop a Files refresh asked for during another

A refresh of the folder on screen was ignored while another refresh of
it was loading. Drop two files: when the second finished during the
first's refresh, its own refresh was dropped, the earlier listing (read
before the file existed) was drawn, and the file's item.added was
ignored meanwhile too, so it stayed missing until a manual refresh.
Paste, Refresh and returning to the browser tab could lose items the
same way. A refresh now takes over from one in flight, as opening
another folder already did; a navigation in progress still holds.

* fix(dashboard): keep the phone selection bar after cancelling a delete

In select mode on a phone, tapping Delete permanently and then Cancel
left every item selected but hid the floating action bar, the only way
to act on the selection, until the selection next changed. The same
happened when nothing in the selection could be moved to Trash. The bar
now goes away only once something is actually deleted or trashed.

* fix(dashboard): show the phone selection bar when Select starts with a selection

Uploaded or pasted items arrive selected. Tapping Select on a phone then
turned select mode on with them selected but no floating action bar,
until a row was toggled. Entering select mode now brings the bar up for
the selection it starts with.

* fix(dashboard): give Files footer sizes in the rows' format

The footer used the desktop's byte formatter while the rows use their
own, so a 5-byte file read "5 B" in its row and "5.00 Bytes" below it,
1.5 KB read "1.50 KB", and a folder of folders read "0 Byte". The footer
now formats sizes as the rows do.

* fix(dashboard): title a trashed folder's Properties with its name

Properties from the breadcrumb of a folder opened inside Trash was
titled with the folder's uid, the last segment of its trashed path,
while the crumb itself showed the original name. It now uses that name.

* fix(dashboard): show delete and log-out confirmations without entities

The Files tab's "delete these items permanently?" confirmation and the
dashboard's "close your apps and log out?" one passed HTML-encoded i18n
strings to UIAlert, which encodes its message and button labels again.
In Igbo both questions read "Ị ji n&#039;aka …". These alerts, and the
Files tab's name-conflict and folder-depth ones, now pass plain strings.

* fix(gui): read the Trash's state fresh when updating its icon

refresh_trash_state stats the Trash to pick the empty or full icon, and
its comment calls for a strong read, but it asked for an eventual one.
Once a listing of Home had cached the Trash entry (without `is_empty`),
the stat came back without it: permanently deleting the last items from
Trash in Files left the full-Trash icon up, and other tabs were told the
same. The stat is now strong, as intended.

* fix(dashboard): don't add a Files row to a folder opened meanwhile

A file added from elsewhere (an upload, another tab, an app) is checked
against the folder on screen and then drawn once its icon resolves. A web
link's or app's icon is read from the file, and opening another folder
during that read put the row into the new folder's listing, where it
stayed and opened the file from the old one. The checks now run again
once the icon is in.

* fix(dashboard): let a focused folder or crumb take the Menu key

The Files tab answered the Menu key and Shift+F10 with the selection's
menu wherever focus was. With a file selected and a sidebar folder, a
breadcrumb or Home's Shared row focused, the key opened the file's menu;
with nothing selected it cancelled the browser's contextmenu, so the
focused element opened none. A focused control now gets the browser's
contextmenu event, and its own menu, as with a right-click.

* fix(dashboard): don't drop New Folder during a Files refresh

New Folder did nothing while the folder on screen was refreshing (after
an upload or paste, or on returning to the browser tab). A refresh keeps
the rows up with no spinner, so the click just looked ignored. It now
takes over the refresh and makes the folder once the listing is in;
during a navigation to another folder it is still dropped, as before.

* fix(dashboard): step through Files names on a repeated letter

Type-to-select meant to move past the selected row to the next name with
the same start, but returned early whenever the selected row matched, so
the stepping never ran: with readme, report-a and report-b, pressing "r"
again stayed on readme, and "rr" matched nothing. A repeated letter now
steps through the names it starts, wrapping around, as file managers do;
a longer prefix still stays on a selected row it matches.

* fix(dashboard): open one touch menu per long-press

A long-press on Android fires both contextmenu and taphold. The Files
rows already ignore the second, but a breadcrumb, a sidebar folder,
Home's Shared and Trash rows and the listing's empty space each opened a
second sheet over the first; picking an item or tapping the backdrop
then left the other one up. The touch menu now doesn't open while
another is on screen.

* fix(dashboard): treat Ctrl+click on a Mac as a right-click in Files

On a Mac, Ctrl+click opens the context menu, but the Files rows read it
as a Ctrl-toggle first: Ctrl+clicking one of three selected files
deselected it and the menu then acted on that file alone, and on an
unselected file it was added to the selection. On a Mac it now leaves
the selection alone, as a right-click does; Cmd+click still toggles.

* fix(dashboard): keep arrow keys in the open phone drawer on Apps

With the drawer open over the Apps tab, an arrow key moved focus out of
it to the first app tile behind the scrim, and the next Tab then walked
the page behind it. The open drawer now keeps the arrow keys, as the
notifications panel and dialogs already do.

* fix(dashboard): act once on a held Enter or Space in Apps

Holding Enter on an Apps folder opened it, moved focus to its first app,
and the key's repeats then launched that app; held on the "+" tile, the
repeats picked "Browse the App Center" in the dialog it opened, and held
in the search box they launched the first match again and again. Key
repeats now do nothing on a tile, in the search box or on the add-app
dialog's options.

* fix(dashboard): don't blank the Apps grid when it re-flows on resize

When a resize changed how many columns or rows fit (resizing the window,
collapsing the sidebar, zooming, an Android keyboard), the grid was
rebuilt behind the first load's fade, which waits on page one's icons.
With an icon host that was slow or unreachable the grid went blank for
up to 1.5s each time. A grid already on screen now re-flows at once;
one still loading keeps its fade.

* fix(dashboard): open an app from the add-app dialog at once during a search

"Browse the App Center" and "Build with AI" play an intro from the
app's tile, and wait up to 3s for that tile to appear. With a search
that leaves the app out (the case the "+" tile stays for: a search that
found nothing), the tile never appears, so the app opened only after the
full 3s. The intro now gives up as soon as the search is what hides the
tile, and the app opens straight away.

* fix(dashboard): make the Apps search's clear control a button

The "×" that clears the Apps search was a bare SVG with a click handler:
Tab couldn't reach it, screen readers had nothing to announce, and on a
phone it was a 16px target inside the field. It is now a labelled button
after the field in the Tab order, with a focus ring, and on touch it
hit-tests at 44x44 while keeping its look.

* fix(dashboard): tell screen readers when an Apps search finds nothing

Typing in the Apps search re-renders the grid silently, and the "No apps
match your search" line is drawn into it fresh each time, which screen
readers don't announce. A search that matched nothing gave no feedback
at all. A persistent status region now says so.

* fix(dashboard): keep keyboard focus on an Apps tile across a re-render

The grid is rebuilt wholesale whenever its data or size changes: the
refresh that follows opening the tab, a resize or zoom that changes how
many columns fit, an uninstall that fails. A keyboard user who had
arrowed onto a tile lost focus to <body>, so Enter did nothing and the
next arrow started over from the first tile. Focus now moves to the
same app's or folder's new tile.

* fix(dashboard): don't offer Change username to a temporary account

A temporary account's Account tab offered Change username, whose dialog
asks for an account password the account doesn't have: left empty it
said "All fields are required", and anything typed got a 403 from the
server, which refuses temporary accounts. The button is now left out
until the account is saved, as the Password card already is; saving it
re-renders the tab with the button.

* fix(dashboard): don't offer Change email to a team seat

A team seat's Account tab offered Change email, and after the user
filled in the new address and their password the server refused it:
"Your team set this address. Ask a team admin to change it." The Email
card now shows that note in place of the button, as the Username card
already does for seats.

* fix(dashboard): don't let a slow picture removal undo a newer picture

Saving a profile picture is ordered so an older save can't put its
picture back over a newer one, but Remove picture stood outside that
order. Clicking Remove on a slow connection and then saving a new photo
before the removal answered left the default avatar on screen, and
Remove hidden, while the account had the new photo. A removal now
yields to a picture saved after it.

* fix(dashboard): show placeholders in the Usage headers until they load

The Storage and Resources headers started out as " ·  used of " with the
numbers left blank, and stayed that way when reading the storage space
or the month's usage failed. They now start at "--% · -- used of --",
as Home's cards start at "--", and keep that if the read fails.

* fix(dashboard): write Home's trial dates in the dashboard's language

The free-trial note on Home formats its dates with the browser's
language, not the one chosen in Puter, so with Puter in French and the
browser in English it read "…le Mar 5", and the other way round "First
charge on 5 mars". The dates now follow the dashboard's language. The
notification panel's mapping of Puter's language codes to Intl tags
(Brazilian Portuguese, Ukrainian, Traditional Chinese) moves to
i18n/intlLocale.js so both use it.

* fix(dashboard): write credit and dollar amounts in the dashboard's language

Credits were always formatted with English separators and dollar amounts
with "$" and English separators, so in German Home read "5,000
monatliche Credits", which a German reader takes as five, and Usage
showed "2.5" and "$1,234.50". Both now use the dashboard's language
("5.000", "2,5", "1.234,50 $"); English is unchanged.

* fix(dashboard): say why Create token is off where everyone can read it

When the account's email isn't confirmed, Create token is disabled and
the reason ("Verify your email address to create an API token.") lived
only in a hover tooltip: a disabled button isn't reachable by Tab, a
phone has no hover, and screen readers don't read a plain span's title,
so those users met a dead button with no explanation. The reason now
replaces the card's description while the button is off. The tooltip,
which encoded the already-encoded text again, now uses it as is.

* fix(dashboard): announce that a new API token is shown only once

After a token was created, focus moved to Done and a screen reader read
"Done, button" and nothing else: the done state arrives already filled
in a status region, which isn't announced, and Done is described by the
message only for plain-text results. The token dialog's warning ("Copy
this token now — it won't be shown again…") now describes Done, so it
is read out when focus lands there.

* fix(dashboard): show an email change confirmed in another tab

Confirming a change of email from its link sets the new address and
marks it confirmed, but sends no socket event, unlike confirming a code.
Back in the dashboard the user was re-read on returning to the browser
tab, yet nothing redrew: Account kept the old address under the
username, Create token stayed disabled and Security had no 2FA card
until a reload. Account and Security now redraw after that re-read when
the address or its confirmation changed.

* fix(dashboard): close the share dialog after leaving a share

Someone the owner let share could remove their own row in the share
dialog to leave. The removal went through, but the dialog then reloaded
its access list with the access just given up: the server refused it,
the success line gave way to "Subject does not exist" over an empty
list, and the item stayed in the Shared view until a reload. Leaving
now closes the dialog and refreshes the folder on screen, which drops
the item, as "Remove from Shared" does.

* fix(dashboard): say the access list couldn't load, not that sharing failed

When the share dialog couldn't read who has access (a network error,
which carries no message of its own), it said "Could not share this
item." though nothing had been shared or attempted. It now says "Could
not load who has access."

* fix(dashboard): don't pull focus back after a share change saves

When a change in the share dialog finished saving (a level change, Add
to all, a removal, a share), focus was put back on that row's control or
the add field, wherever the user had gone meanwhile. Someone who changed
a person's level and then started typing a name had the next keystrokes
land on that person's level select, where a matching letter picked
another level and granted it. Focus now goes back only when the save
took it away; focus the user moved elsewhere stays there.

* refactor(gui): move the localized time formatting to i18n/formatTime.js

formatRelativeTime and formatAbsoluteTime lived in the notification
center's module; Files and Properties are about to use them too.

* fix(dashboard): write Files and Properties dates in the dashboard's language

The Files tab's Modified column and Properties' Modified, Created and
version times came from timeago, which has no language but English
registered, so a German dashboard read "3 hours ago" beside "Geändert".
They now use the same localized formatter as the notification panel
("vor 3 Stunden"), which also gives a date past a week rather than
"3 weeks ago". Its formatters are now made once per language, since a
listing formats one date per row.

* fix(dashboard): style the Usage table's Show less button in dark mode

The button's dark variant was written for an `html.dark-mode` class that
nothing sets, so in dark mode it kept its light look: grey text at 3.4:1
on the dark card, a light border, and a light chip on hover. Its rules
move under prefers-color-scheme, as the table's fade overlay already
did.

* fix(dashboard): show the Files breadcrumb separators in dark mode

The chevrons between breadcrumbs are a black stroke at 20% opacity,
which vanishes on the dark header: a nested folder read "Home Documents
Work" with nothing between the names. In dark mode they are now
inverted, like the toolbar's icons.

* fix(dashboard): show the website-link badge on dark Files tiles

In grid view a file inside a published folder carries a link badge: a
black glyph on a disc of the page's background color. In dark mode that
is black on near-black and can't be seen. The badge now gets the white
disc the shortcut and worker badges already have.

* fix(dashboard): skip disabled Back, Forward and Up when tabbing in Files

The Files header's arrows stayed in the Tab order while disabled, drawn
at 10% opacity, which takes their focus ring with them. Tabbing into
Files, the first two stops (Back and Forward, disabled on arrival)
looked like nothing had focus. A disabled arrow now leaves the Tab
order, as a disabled button does, and returns once it can be used.

* fix(dashboard): give the Delete account button an AA-contrast red

The Account tab's Delete account button used the desktop's danger red
(#f00808), which puts its white label at 4.4:1, under the 4.5:1 that
WCAG AA asks of text. It now uses the dashboard dialogs' danger red
(#d90707, 5.3:1) and their darker hover, so the two match.

* fix(dashboard): label collapsed sidebar items for keyboard users too

With the sidebar collapsed, each item shows only its icon, and its name
appeared in a tooltip on mouse hover alone. Tabbing through the sidebar
showed a row of icons with no names. The tooltip now also shows while
an item has keyboard focus.

* fix(dashboard): keep Share and Properties inside the visible screen

Both dialogs capped their height at 85vh (88vh as phone sheets), and vh
is the viewport with the browser's bars hidden. With the bars showing,
on a phone in landscape say, the card plus the overlay's padding was
taller than the screen, and the overlay centers without scrolling: the
title and the buttons were cut off with no way to reach them. Each card
is now also held within its overlay, as the dashboard's dialogs are,
and its body scrolls instead.

* fix(dashboard): outline the off state of the 2FA switch

Off, the switch was a light grey track (#e0e0e0) with a white thumb,
1.3:1 against a white card and 1.2:1 against the amber card the 2FA
toggle sits on while 2FA is off, so the control could barely be seen
where it matters most. WCAG asks 3:1 for a control's boundary. The off
track now carries a 1px slate outline (4.3:1 on the amber card); the
on state is unchanged.

* fix(dashboard): make the keyboard focus ring visible on sidebar, tiles and lists

The sidebar items, the Files folders, crumbs and arrows, the app tiles,
Home's cards and recent apps, the notification rows and the Usage sort
buttons show keyboard focus with a 2px outline and nothing else, drawn
in --select-ring: the selection color at 40% (50% dark), about 1.6:1
against the surfaces it sits on. Keyboard users could hardly tell where
focus was; a focus indicator needs 3:1. Those outlines now use the solid
selection color (3.7:1 on white), as the switch and pager dots already
do. Field halos, which come with a colored border, are unchanged.

* fix(dashboard): bring text in the selection blue up to AA contrast

The selection blue is 3.7:1 against white, and a few places set text in
it or white text on it, under the 4.5:1 that text needs: the active
folder in the Files sidebar, the share dialog's "Add to all" link, the
add-app dialog's Send button, the notification count badge, the drag
count badge, and a breadcrumb whose menu is open. They now use the same
hue darkened to 45% (5.3:1; a lighter one for text in dark mode), from
two new tokens, --select-text and --select-fill.

* fix(dashboard): give the Apps page dots a 24px touch target

The Apps pager's dots, and a folder's, are 17px buttons side by side,
under the 24px a target needs when it has neighbors, so on a phone or
tablet a tap meant for one page often opened the next. On a touch
screen each dot is now 24px around the same 7px dot; mouse layouts are
unchanged.

* fix(dashboard): return from a drag-opened folder while it is still loading

Hovering a dragged file over a folder springs it open. Cancelling the
drag (Escape, the Cancel zone, or a drop outside the listing) is meant
to go back where the drag started, but while the sprung folder was still
loading the return was dropped like any navigation during another, and
the user was left in the folder they had backed out of. The return now
takes over from that load.

* fix(dashboard): drop into a drag-opened folder while it is still loading

When a dragged file springs a folder open, the drop anywhere in the
listing moves it into the folder now shown. Until that folder's listing
arrived, though, the Files tab still counted the folder the drag left as
the one on screen, so a drop in that time was taken as a move to where
the file already was and did nothing. It now goes to the folder being
opened.

* fix(dashboard): say "Incorrect password" when the account password is wrong

The account dialogs showed the server's message as is. A wrong current
password in Change password read "Password mismatch", in English in
every language, beside a dialog that says "New Password and Confirm New
Password do not match" when those two differ, so users corrected the
wrong fields. A wrong code read "Incorrect code." and too many attempts
"Too many requests.", also always in English. These now show the
translated messages the GUI already has, and a wrong password puts the
status on the password field, focused for retyping.

* fix(dashboard): don't send an account change to the value it already has

Change username with the account's own name got "This username is
already taken." and used up one of the hour's password attempts. Change
email with the current address was accepted: it mailed a confirmation
and a change notice to the same inbox, and following the link unlinked
any sign-in provider. Change password to the current password signed
out every other session and mailed a "password changed" notice. Each
dialog now says the value is already the account's and sends nothing.

* fix(dashboard): ask for the new password after a provider-only account sets one

An account that signs in only through a linked provider can set a
password with Change password, after which the server treats it as a
password account. This session's copy still said provider-only, so
Change username, Change email and Delete account went on hiding the
password field and sending the user through the provider's popup until
a reload. The copy now changes with the password.

* fix(dashboard): let Escape cancel a session rename without closing Manage sessions

In Manage sessions (Security), Escape in a session's rename field
cancelled the rename and then closed the whole modal, and Escape in the
search box closed it too, search and all. Escape now cancels the rename
or empties the search, and only an Escape with neither closes the
modal.

* fix(dashboard): show the server's message when a session can't be revoked

Manage sessions put a refused request's raw response body in its alert,
so revoking a session already signed out from another tab read
{"message":"Session not found","code":"not_found"}, and the stale row
stayed listed; a failed rename and "Revoke all other sessions" did the
same. The alerts now show the message itself, and a session found gone
reloads the list.

* fix(dashboard): show 2FA as it is when its dialog closes without success

The Security tab's 2FA toggle assumed nothing had changed whenever its
dialog closed without success. With 2FA turned on in another tab, the
setup here refused ("2FA is already enabled."), the toggle went back to
off, and every retry got the same refusal, with Disable never offered;
a code accepted just as the user closed the setup dialog left the same
state. The tab now re-reads the account and shows its real 2FA state.

* fix(gui): leave Ctrl/Cmd+Z to the text field being typed in

Ctrl/Cmd+Z ran the file Undo wherever focus was, and cancelled the
field's own text undo. Typing a new name (F2) or anything in another
field and pressing it undid the last file action instead; when that was
an upload, the uploaded file was deleted for good. Undo now leaves text
fields alone.

* fix(gui): let Escape on a context menu close only the menu

Escape removed any open context menu and then went on to the Files tab's
own Escape handling, which, finding no menu left, cleared the selection
and closed the image preview. Select three files, open their menu,
press Escape: the menu closed and the selection was gone. When Escape
closes a menu, nothing else now acts on it; a second Escape clears the
selection as before.

* fix(dashboard): close the Files image preview when an app opens over it

The image preview sits above every window, and only a double-click
closed it on the way to an app. Opening the image with Enter, or with
Open or Open With from its menu, started the app underneath the
preview, which went on covering it while keyboard focus moved into the
app, so Space and Escape no longer reached the preview. It now closes
whenever an app window comes on screen.

* fix(dashboard): close the Files image preview when its file goes away

Deleting the previewed image (the Delete key, the menu, the phone
selection bar), moving it, or having it removed from another tab took
its row out of the listing but left the preview up, still showing the
image. The preview now closes with its row; a refresh of the folder,
which redraws the row, keeps it.

* fix(dashboard): download one selected file as itself from the phone selection bar

In select mode, the floating bar's Download zipped whatever was
selected, so a single photo arrived as "photo.jpg.zip" where its own
menu's Download gives "photo.jpg". One selected file now downloads as
itself; folders and several items are still zipped.

* fix(dashboard): don't offer New Folder, Upload or Paste in a view-only share

Inside a folder shared with the user to view only, the Files tab still
showed New Folder and Upload, offered New and Paste in its menu, and
took files dropped on the listing or on a folder in it; each then
failed: an upload window ending in an error, or a new folder row with
its name field open, then taken back with a Forbidden alert. Those are
now left out there, as in Trash. A share whose level isn't known yet
stays as before, the server deciding.

* fix(dashboard): move through Files with Home, End, Page Up and Page Down

The Files listing took the arrow keys but not Home, End, Page Up or Page
Down, and as the listing itself never has focus the browser didn't
scroll it on them either: in a folder of hundreds of items the keyboard
could only step through them one row at a time. Home and End now select
the first and last item, Page Up and Page Down move a screenful, and
Shift extends the selection as with the arrows. With a menu open they
are left to it, as the arrows are.

* fix(dashboard): apply the hidden-files setting to a renamed Files row

With Show hidden off, renaming notes.txt to .notes.txt left the row in
the listing until the folder was next loaded, and with it on the row
wasn't dimmed as other dot-files are; renaming .env to env left it
dimmed. A rename made in another tab did the same. A renamed row now
follows the setting as listing the folder would.

* fix(dashboard): open a recent app once on a double-click

Double-clicking an app under Home's "Recently used" opened it in two new
browser tabs, one per click. The second click of a double-click is now
ignored, as on the Apps tab's tiles.

* fix(dashboard): don't say "No recent apps yet" before Home's list has loaded

Home's "Recently used" card was drawn from the list as it stood when the
page opened, which is empty until the request answers: every visit
first read "No recent apps yet / Apps you use will appear here" and
then swapped in the apps, and if the request failed the card kept
saying there were none. It now stays blank until the list arrives, and
a first load that fails says the apps couldn't be loaded; a list
already shown stays when a later refresh fails.

* fix(dashboard): drop "Save session" when the account was saved in another tab

A temporary account saved in one browser tab stayed temporary-looking
in its other dashboard tabs: Home kept its "Save session" button, which
opened Save Account for an account already saved, and Account and
Security kept their temporary-account layout. Coming back to such a tab
re-reads the account; that now removes the button and redraws the two
tabs when the account is no longer temporary.

* fix(dashboard): let go of the phone drawer when the window widens

With the sidebar open as a drawer below 768px, widening the window past
it (an iPad turned to landscape) drew the sidebar as the desktop one but
left it marked open: Tab stayed trapped in it, so the content couldn't
be reached by keyboard, and the Apps grid ignored the arrow keys, until
an Escape or a click in the content. The drawer now closes when the
window widens, leaving focus where it is.

* fix(dashboard): don't let a #files/<path> link pull Files back to its folder

Files doesn't keep the folder on screen in the URL, but a link like
/dashboard#files/alice/Documents stayed in the address bar once opened.
After the user moved on to Pictures, went to Home and pressed Back, the
browser returned to that link and Files jumped back to Documents; a
reload did the same, and Files' own Back/Forward lost track, as the jump
wasn't recorded in it. The link's path is now consumed once opened, as
a share link's is, and a folder opened from such a link is a step in
Files' history.

* fix(dashboard): close the drawer and Files' dialogs on browser Back and Forward

Browser Back or Forward switched tabs but left whatever was open on
screen: on a phone, Android's Back changed the tab under the open menu
drawer and left it open; in Files, the Share or Properties dialog
stayed up over Home, still acting on Files rows; an open context menu
stayed too. These now close with the tab, as with a sidebar click.

* fix(dashboard): fill Home's storage and usage meters without waiting on the plan

Home read the plan first and only then asked for storage and the month's
usage, so every visit paid for one more round-trip before either meter
filled in. The plan request had no timeout either: one that never
answered kept both meters at "--" and, as later loads wait for the one
in flight, held every later refresh of the cards for the rest of the
session. The three now load side by side, and the plan read gives up
after 15 seconds.

* fix(dashboard): link Home's card headers to their tabs

The headers of Home's cards (Recently used, Usage, Your Plan, Your
Storage, Resources) linked to "#", so opening one in a new browser tab
(middle-click, the browser's menu) showed Apps rather than the tab it
names, and Cmd/Ctrl+click switched tabs in place. They now link to the
tab they open, and a modified click is left to the browser, as on the
sidebar.

* fix(dashboard): prefill the username when saving a session from Home

Home's "Save session" button opened Save Account with an empty username,
so a temporary user had to make one up, while the same action in the
user menu fills in the account's current name and starts at the email
field. Both now open it the same way.

* fix(dashboard): measure Home's storage meter by the amount it shows

On a self-hosted install the storage card names the disk's use (Puter's
files and everything else on it) but filled its meter, and gave its
percentage, from Puter's files alone, so it could read "93 GB used ·
0% of 466 GB". The meter and percentage now come from the same amount
the card shows.

* fix(dashboard): refresh Home's recently used apps on returning to the tab

Recent apps open in a new browser tab, and coming back to the dashboard
refreshed the usage cards but not "Recently used", whose comment said
it followed the same triggers: the app just opened only moved up the
list after switching away from Home and back. Returning to the tab now
re-reads the list too.

* fix(dashboard): leave IME keystrokes to the IME in Apps

Typing Japanese or Chinese in the Apps search or a folder's name, the
keys that drive the input method reached the tab's own handlers:
ArrowDown to pick a candidate moved focus to the first tile and cut the
conversion short, Enter to confirm one named the folder half-written or
launched the first match, and Escape put the old name back. The search
box's Enter meant to skip composition, but read isComposing off jQuery's
event, which never carries it. Keys during composition are now left to
the IME.

* fix(dashboard): leave IME keystrokes to the IME in the share recipient field

With a Japanese or Chinese input method on, even a Latin username is
composed, and the share dialog's recipient field took the keys that
drive it: the arrows moved through its suggestions instead of the IME's
candidates, and Enter to confirm the text picked a suggestion instead.
Keys during composition are now left to the IME.

* fix(dashboard): re-read the Apps layout on returning to its browser tab

The Apps tab read its order and folders only when it was opened, and
saves write its whole copy of them. With the dashboard open in two
browser tabs, a folder made in one was wiped as soon as the other,
showing Apps since before, changed anything (a reorder, a new folder, a
rename), as was the order arranged in the first. The tab now reads the
layout again when its browser tab comes back into view.

* fix(dashboard): undo a drag out of an Apps folder when the drag is cancelled

Dragging an app past the edge of an open folder takes it out, closes the
folder and carries the drag on over the grid, and the removal was saved
right then. Pressing Escape, or the window losing focus, cancels the
drag but left the app out; out of a two-app folder that dissolved the
folder for good, its name with it (dragging the app back made a new
"Folder"). A cancelled drag now puts the folder and the order back.

* fix(gui): show the 2FA setup's confirmations without entities

The 2FA setup window, which the dashboard's Security tab opens, encoded
already-encoded translations for its labels, so in French the two
checkboxes before enabling read "J&apos;ai enregistré mes codes…" and
"Je suis prêt à activer l&apos;A2F". The labels, and the window's other
translated text set the same way, are now encoded once.

* fix(gui): don't leave the 2FA dialogs stuck when a request can't be sent

Turning 2FA on or off from the dashboard's Security tab while offline,
or on a dropped connection, left the dialog busy for good: enabling kept
its spinner up with the code boxes disabled, and disabling kept its
button and password field disabled, with nothing said either way. A
request that gets no answer is now reported like a refused one, and the
dialog can be tried again.

* fix(gui): stop disabling 2FA when the provider sign-in doesn't happen

For an account that signs in through a linked provider, turning 2FA off
opens the provider's sign-in popup. When the popup was closed or
blocked, the dialog showed why for a moment and then sent the request
anyway, whose refusal ("OIDC revalidation required") replaced the
reason. It now stops at the popup's error.
2026-10-24 15:05:56 -07:00
Filip KujundžićandClaude Fable 5.1 bbb780eb31 feat(ai): MiniMax video generation (H3 and Hailuo) (#4309)
* feat(ai): MiniMax video generation (H3 and Hailuo)

Adds a minimax-video-generation provider to the video driver. H3 models go
through MiniMax's v2 task API and are billed per second of output; Hailuo
models go through the v1 API, exchange a file id for a download URL, and
are billed per clip. The shared `minimax` chat key is reused, with the
chat block's `/v1` base URL reduced to its host.

WIDTHxHEIGHT sizes now map onto tiers for catalogs that include a 2k or
4k tier.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

* fix(ai): validate MiniMax video inputs and resolution tiers

---------

Co-authored-by: Claude Fable 5.1 <[email protected]>
2026-10-24 13:49:21 -07:00
Nariman Jelveh 8a55debdae fix(dashboard): sixth adversarial review — bugs, UX, a11y, perf (#4314)
* fix(dashboard): render in the user's saved language

The dashboard never read the account's preferences: only the desktop did,
on boot. Everyone saw it in English whatever language they had chosen,
until the Files tab's Show hidden toggle switched a running session over,
to the browser's language when none was saved, since that was the
fallback the toggle wrote back.

The desktop's preference loading moves into load_user_preferences, which
the dashboard now runs in the background. The first paint uses the
language this browser last loaded, so nothing waits on the read; a saved
language that differs reloads into it, and a changed Show hidden setting
refreshes the open folder. The tab names are read when the sidebar is
drawn rather than when the modules load. A failed read keeps the cached
preferences instead of resetting them to English.

* fix(dashboard): label credit amounts with a key meant for them

Home's allowance and the Usage tab's total and Credits column used the
`credits` key, which the translations render in its acknowledgments sense:
"5,000 Mitwirkende" (contributors) in German, "Авторы" in Russian,
"特别鸣谢" in Chinese. They now use `usage_credits`, which falls back to
English until it is translated, as `monthly_credits` already does.

* fix(dashboard): enable the token and 2FA cards once the email is confirmed

The dashboard had no handler for the `user.email_confirmed` socket event,
and Account and Security pick the token button's state and whether to
offer 2FA when they render. A user who confirmed their email in another
tab, or in this one through the confirmation prompt, kept a disabled
"Create token" and no 2FA card until a reload.

The dashboard now refreshes the user on that event and renders both tabs
again, as it already does after a temporary account is saved.

* fix(dashboard): save only the profile fields that were edited

Edit profile sent both the display name and the bio on every save, taken
from this session's copy of the profile. That copy comes from the cached
user and can be stale (the profile changed on another device) or empty
(the profile read failed), so changing only the bio wrote the old display
name back, and a save from an empty prefill erased the bio.

The dialog now sends just the fields whose value changed from what it
opened with, and closes without a request when nothing did.

* fix(dashboard): don't show storage as 100% while there is room left

Home and Usage rounded the storage share to a whole percent, so an
account at 99.5% or more read "100%" while uploads still fit. The usage
meter already holds at 99% until the allowance is spent; that rule moves
into meterPercent and the storage meters use it too.

* fix(dashboard): keep keyboard focus inside the open phone drawer

Below 768px the sidebar is a modal drawer over a scrim, but opening it
left focus on the menu button, and Tab past its last entry walked into
the dimmed content behind the scrim. Opening it now focuses the current
page's entry, and Tab and Shift+Tab cycle within it while it is open.

* fix(dashboard): tapping into a Files name field edits the name

On a phone or tablet, tapping the name field of a new folder or a rename
(which iOS needs before it shows the keyboard) reached the row's tap
handler, which opened the folder or launched the file and threw the edit
away. A long-press in the field opened the touch sheet instead of
selecting text, and on desktop a right-click in it showed the file menu
instead of the browser's Cut/Copy/Paste.

The row's tap and menu handlers now leave the name field alone.

* fix(dashboard): don't offer Cut on items the user can't move

A file's menu offered Cut on anything that wasn't immutable, and Shared
rows aren't marked immutable. Cutting a file someone shared with you, or
anything inside a read-only shared folder, then pasting it, ended in a
"Forbidden" alert and an emptied clipboard. Cut now needs the same
permission as Delete in that menu, as Ctrl+X and the selection's Cut
already do.

* fix(dashboard): don't paste or upload into the folder being left

Opening a folder clears the listing at once but only moves currentPath
when the new listing arrives. Ctrl+V, a file dropped from the computer
onto the pane, or Paste from the background menu during that wait went
into the folder the user had just left, and since the refresh then
showed the new one, the items seemed to vanish.

Those three now refuse while another folder is loading, as New Folder
already did.

* fix(dashboard): clear the drop highlight when Escape cancels a drag

Escape ends a Files drag with a synthetic mouseup, which jQuery UI
treats as a release outside every target: it fires no `out`, so the
folder row under the pointer kept its dashed drop outline and tint (and
a sidebar folder or breadcrumb its highlight) after the drag was gone.
The Escape branch now takes those off.

* fix(gui): show the "can't rename in Trash" alert without entities

Pressing F2 on a trashed item (on the desktop or in the dashboard's
Files tab) showed "This item can&apos;t be renamed because it&apos;s in
the trash": the message was encoded by i18n and then again by UIAlert.

* fix(gui): show the failed-upload title without entities

The upload progress window writes its error title with innerText, but
was handed the HTML-encoded i18n string, so French showed "Échec de
l&apos;importation des fichiers". The Files tab's two upload paths and
the desktop's now pass the plain string.

* feat(dashboard): open a Files row's menu with the Menu key or Shift+F10

Share, Download, Open With and Properties were reachable only by
pointer: the ⋯ button isn't focusable, and the keyboard's menu keys
fired a contextmenu on <body>, which the listing ignores. Both keys now
open the selection's menu at its row's ⋯ button, where arrow keys and
Enter already work.

* fix(dashboard): let Shift+Arrow shrink a Files selection

Shift+Arrow only ever added the next row, so after Shift+Down twice,
Shift+Up left all three rows selected instead of going back to two. A
run of Shift+Arrows now selects the range from the row it started on to
the cursor, on top of whatever was selected before the run, so moving
back shrinks it. A click or a plain arrow starts a new run.

* fix(dashboard): name folders in the Back/Forward history menus as the crumbs do

Holding Back or Forward listed each folder by its path's basename: `/`
was a blank entry, the Shared view read "shared", Trash stayed in
English, and a trashed folder showed its uid. The menus now use the same
names the breadcrumbs show.

* fix(dashboard): show a shortened Files name in full on hover

Grid tiles and a resized Name column shorten long names in the middle
("Quarterl…draft v2.xlsx"), and nothing showed the rest short of opening
Rename or Properties. A shortened name now carries the full one as its
tooltip.

* fix(dashboard): keep keyboard focus on the Files breadcrumbs

Every listing rebuilds the breadcrumbs, which took a keyboard-focused
crumb with it: pressing Enter on one, or any refresh of the folder while
a crumb had focus, left focus on <body>, and the next Tab started over
from the top of the page. Focus now moves to the same folder's crumb,
or to the one for the folder just opened.

* fix(dashboard): reach Home's Shared and Trash rows from the keyboard

At 480px wide or less (a phone, or a desktop browser zoomed in) the
folder sidebar is hidden and Home lists Shared and Trash as rows instead.
Those rows only had a click handler, so a keyboard user couldn't get to
either. They are now focusable links that open on Enter or Space, with
the sidebar's focus ring.

* fix(dashboard): keep the Files selection across a sort or view change

Sorting by a column, switching between list and grid, or toggling Show
hidden lists the folder again, and the rebuild dropped the selection and
the shift anchor: select three files, sort by Size, and nothing was
selected. Only the refresh on returning to the browser tab carried them
across. That logic is now refreshKeepingSelection, and those three use
it too.

* fix(dashboard): don't drop a folder open during a Files refresh

A refresh of the folder on screen (after an upload or paste, Refresh, a
sort, or returning to the browser tab) set the same "already rendering"
flag that a navigation sets, so double-clicking a folder, a sidebar
folder, a crumb or Back during that round-trip did nothing, with no
spinner to explain it.

Opening another folder now takes over from a refresh in flight; the
refresh notices it was superseded and drops its result. A navigation
during another navigation is still ignored, as before.

* fix(dashboard): keep the Files marquee when an item arrives mid-drag

Adding a row clears the empty-folder notice by removing every non-row
child of the listing, and the selection marquee is one. A file added
from elsewhere (an upload, another tab, an app) while the user was
drawing a selection made the rectangle vanish under the pointer.

* perf(dashboard): don't rewrite unchanged Files names while resizing

Every width change (a window resize, collapsing the sidebar) re-fits the
item names, at most once a frame, and wrote each name's text back even
when it came out the same. Each write invalidated layout for the whole
list. With 300 rows that took ~6.4ms a frame in list view and ~4.9ms in
grid; skipping the unchanged writes brings it to ~0.7ms and ~3.2ms.

* fix(dashboard): tell assistive tech the Files Select toggle's state

The Select button only toggled a class, so a screen reader announced a
plain button with no state; it now carries aria-pressed. Sort and View
open menus and now say so with aria-haspopup.

* fix(dashboard): show a person's strongest grant in the share list

When someone held two grants on one item (the owner gave them view
access, and someone the owner let share gave them edit), the share
dialog showed whichever grant the listing returned first, so their row
could read "Can view" while they could edit. A second grant on the same
item now counts toward the row with the stronger of the two levels.

* fix(dashboard): only offer share controls on grants the user can change

The backend lets an item's owner change or withdraw any share of it,
and anyone else only the grants they issued (or their own access). The
share dialog offered the level select and Remove on every row. Someone
the owner let share who removed a person the owner had added got
"Removed bob" while nothing changed, and lowering their level added a
second grant that changed nothing either.

The access list now records who issued each row's grants. A user who
doesn't own the selection gets the controls only on rows whose grants
they issued all of, plus Remove on their own row to leave; other rows
show their level as a tag.

* fix(dashboard): update Files' shared markers after the share dialog closes

The share dialog marks the Files rows it covers as shared or not from
the listings it reloads after each change, and skipped that whole reload
once it was closed. Closing it while a share or removal was still saving
left the row without its shared marker (or with a stale one) until the
folder was reloaded. The markers are now updated either way; only the
dialog's own list is skipped once it's gone.

* fix(dashboard): mark rows shared after sharing more than 50 at once

The share dialog updates the Files rows' shared markers from the share
listings it reloads after a change, and doesn't reload them for more
than 50 items. Sharing such a selection, with a person or by link,
reported "Shared with bob on 55 items" while no row showed the marker
until the folder was reloaded. Those grants now mark their rows
directly.

* fix(gui): unbind each context menu's mousemove handler with the menu

The submenu-aim code bound a mousemove handler on the document for every
menu and submenu and never removed it. Each one kept its closed menu and
items alive and ran on every mouse move for the rest of the session; ten
right-clicks in Files left five such handlers behind. The handler now
comes off when its menu is removed. Moving diagonally into a submenu
still keeps it open.

* fix(gui): ignore clicks on a context menu's dividers

A divider is an <li> like the items, so a click on one (they're 11px
tall, easy to hit between two items) ran the item handler with no
action behind it and threw "Cannot read properties of undefined". The
item handler now skips dividers; the menu stays open, as a native
menu's separator does.

* fix(dashboard): warn that removing a team removes everyone in it

Removing a team from a single item's share dialog asked "Remove Design's
access to this item?", the wording used for one person. The desktop's
share window adds "Everyone in the team loses it."; the dashboard now
uses that confirmation for teams too.

* fix(dashboard): count folders, not items, in "via N folders"

A person who reaches a multi-item selection through more than one shared
ancestor gets a "via N folders" note in the share dialog, and N was the
number of items reached that way: five files under two shared folders
read "via 5 folders". It is now the number of distinct folders.

* fix(dashboard): don't treat a long tail after a dot as a file extension

Files middle-truncates long names and keeps everything after the last
dot whole as the extension. For "Report v1.2 final draft for the board
meeting" that kept ".2 final draft for the board meeting", so the tile
read "….2 final draft for the board meeting" and lost the start of the
name. Only a short tail without spaces now counts as an extension; this
one shortens to "Report v1.…d meeting".

* fix(dashboard): keep Tab inside the touch context menu

The touch menu sheet is modal, but Tab and Shift+Tab left it for the
page behind the backdrop while it stayed open; closing it then no
longer returned focus, since focus was elsewhere. Tab and Shift+Tab now
move between its items like the arrow keys, wrapping at the ends.

* fix(dashboard): say "the other item" when one item is missing a grant

The share dialog's "Add to all" link told screen readers "Give bob
access to the other 1 items" when the person was missing from a single
item of the selection. That case now has its own singular string.

* fix(dashboard): announce a failed Properties load, on both tabs

When Properties couldn't read the item, the General list showed the
error but stayed aria-busy, so screen readers kept it unannounced, and
the Versions tab stayed blank. The list is no longer busy once the
error shows, and Versions shows the same error.

* perf(dashboard): launch the add-app modal's apps without reloading the grid

"Browse the App Center" and "Build with AI" go through the same intro
as a deep link, which first awaited loadApps(). With the grid already
loaded that started a fresh fetch of every app list and the saved
layout, and the launch waited on it: with an 800ms connection, ~1.35s
before anything opened instead of ~0.1s. The intro now uses the grid
already loaded, and only joins or starts a load when there is none.

* fix(dashboard): don't hide the Apps grid on every search keystroke

Search re-rendered the grid behind the first load's fade-in, which waits
for page one's icons. Each keystroke made fresh <img> elements, so an
icon that was slow or failing (an unreachable icon host) blanked the
whole grid until it settled, about a second per keystroke with a 1s
icon and up to 1.5s if it never answers. Search, its clear button and
entering reorder mode now render the results at once.

* fix(dashboard): stop the Apps pager's dots jumping back mid-flip

A page arrow, a dot, or the wheel starts a smooth scroll and marks the
target page at once, but the scroll handler then read the pages passed
over on the way as the current one. The active dot went forward, back
to the old page, and forward again, and a second click on the arrow
added to the stale page: two quick clicks showed 0 → 1 → 0 → 2 → 1 → 2.

While such a scroll is on its way, the pager (and the open folder's)
now ignores the positions in between, until it arrives or a second has
passed. The user's own scrolling still moves the dots.

* fix(dashboard): keep paging while a dragged app is held at the edge

Holding a dragged tile still at the Apps grid's edge (or an open
folder's) turned one page and stopped: the next dwell only started on
another pointer move, so a mouse held steady stayed on the second page
of three. Once a flip settles, the dwell starts again if the pointer is
still in the edge zone.

* fix(dashboard): don't turn Apps pages on a trackpad pinch

A pinch on a trackpad reaches the page as wheel events with ctrlKey
set, and the Apps grid (and an open folder) read them as a wheel flick
and turned the page while the user zoomed. Those events are now left
alone.

The handlers also called preventDefault() from a listener the app makes
passive, which does nothing and logged a console warning on every wheel
tick; that call is gone, as the scroller has no vertical scroll to stop.

* feat(dashboard): open the first Apps search result on Enter

Typing in the Apps search and pressing Enter did nothing; reaching a
result took ArrowDown and then Enter. Enter in a non-empty search now
opens the first match (or a folder that matches), as launchers do. It
does nothing while an IME composition is open, or when nothing matches.

* fix(dashboard): include an open folder's page dots in its Tab cycle

The open folder keeps Tab inside it, but its list of stops left out the
page dots, which are buttons. Tab never reached them, and once one had
focus (clicking it focuses it) the next Tab walked out of the modal
folder to the page behind. The dots are now part of the cycle.

* feat(dashboard): open an app tile's menu with the Menu key or Shift+F10

An app or folder tile's menu (Open in New Tab, Copy Link, Uninstall,
Remove from Folder; a folder's Rename and Ungroup) opened only on a
right-click or long-press, and browsers don't all turn the keyboard's
menu keys into a contextmenu event (macOS has none). The Menu key and
Shift+F10 on a focused tile now open its menu at the tile, where arrow
keys and Enter work.

* fix(dashboard): keep keyboard focus on the grid after Ungroup

Ungrouping a folder from its menu re-rendered the grid without the
folder's tile, so a keyboard user who had it focused was left on
<body> and the next Tab started from the top of the page. Focus now
moves to the folder's first app, which takes the folder's place.

* fix(dashboard): give links AA contrast in the light theme

Link-colored text (Home's "View more" and "Upgrade", the notification
panel's "Mark all read" and "Retry", Properties' website links, the
token dialog's sessions link) used #5271ff, 4.07:1 on white and 3.97:1
on the content background, under the 4.5:1 AA minimum for text that
size. The light theme's link is now #3d5bd9 (5.7:1), with a darker
hover. Dark mode already passed.

* fix(dashboard): give success text AA contrast in the light theme

Success text (the 2FA "enabled" line, Home's active-plan badge, the
share dialog's confirmations) used #03933a: 4.01:1 on white and 3.80:1
on its green tint, under the 4.5:1 minimum. It is now #15803d (5.02:1
and 4.75:1); the success check icons that share the token darken with
it. Dark mode already passed.

* fix(dashboard): give danger buttons and error text AA contrast

White text on the red fills fell short of 4.5:1: the Apps uninstall
confirm (#e53935, 4.23:1), the delete-account button (#f00808, 4.43:1)
and the Files drag "Cancel" zone (#ef4444, 3.76:1). So did light-theme
danger text on its pink tint (#dc2626 on #fef2f2, 4.41:1), used for
form, share, crop and notification errors and the past-due badge.

The fills are now #d32f2f, #d90707 and #dc2626 (4.8:1 to 5.3:1), each
with a darker hover, and danger text is #b91c1c (5.9:1 on the tint).

* fix(dashboard): give the app feedback dialog's Send button AA contrast

The feedback dialog's primary button (Send, and Close once sent) put
white text on a light-blue gradient, 2.66:1 at the top and 3.42:1 at the
bottom. It now uses the dashboard's primary button colors, which meet
4.5:1 in both themes, like the dialogs around it.

* fix(dashboard): show the drop outline and dwell fill on grid folders

Dragging files over a folder in grid view only tinted it like a
selection: the grid's own border, hover and selected rules out-ranked
the shared drop-target and spring-load rules, and the hover rule's
background shorthand wiped the dwell fill. The folder then opened after
700ms with no sign it was about to. Grid and compact-grid folders now
get the list's dashed outline and filling dwell, with the padding
trimmed to match so the tile doesn't shift.

* fix(dashboard): keep the Files preview and drag ghost above the window

The Space-bar image preview (z-index 9999) and the drag ghost (10000)
are appended to <body>, beside the dashboard window. On a touch-only
tablet with a keyboard every window is pinned to 9999999, so the preview
opened invisibly behind it from the first press; on desktop the
window's z-index climbs with each click and overtakes both in a long
session. Both now sit in the band the sidebar tooltip and the toasts
already use.

* fix(dashboard): give small dashboard controls 44px touch targets

Several controls a phone or tablet user taps were under the 44px
minimum touch size, with nothing enlarging them on a coarse pointer:
the Files toolbar buttons (32px), the touch menu's rows (36px tall),
the drawer's close button (32px), the notifications close (30px), the
Properties close (32px), the feedback dialog's ✕ (30px), the add-app
Back arrow (26px), the Apps modal buttons (34px tall) and the share
dialog's Copy link (38px tall).

Following the earlier touch fixes, the icon buttons keep their look
and get a 44x44 hit area, and the text buttons and menu rows grow to
44px. Mouse and trackpad layouts are unchanged.

* fix(dashboard): keep a landscape phone's notch clear of the dashboard

A phone held sideways and wider than 768px (an iPhone Pro Max, a large
Android) gets the desktop layout, and the page asks for the full screen
with viewport-fit=cover. Nothing in that layout read the side safe-area
insets, so with the notch on the left the sidebar's icons sat under it
(x=24 against a 47px inset), and with it on the right the content's
edge and the toasts did.

The sidebar (open or collapsed) now adds the left inset, and the content
area, including Apps and Files, and the toasts add the right one. The
insets are 0 everywhere else, so other layouts don't change.

* fix(dashboard): size the touch menu and bottom sheets to the visible height

The touch context menu, the app feedback dialog and the profile-photo
crop sheet capped their height in vh. On phones 100vh is the viewport
with the browser bars hidden, so while they show these could be taller
than the screen. The touch menu, which ContextMenu.js keeps inside the
visible height, then ran its last rows off the bottom on a landscape
phone, and the bottom sheets could push their header off the top. They
now cap in dvh, as style.css already does.

* fix(gui): move keyboard focus into the Manage sessions dialog

The sessions dialog is modal but never took focus: opening it from the
keyboard left focus on the button behind the backdrop, and Tab walked
the page underneath instead of the session list. It now focuses its
close button on open, keeps Tab inside, and hands focus back to the
control that opened it when it closes.

* fix(dashboard): return focus after the Security tab's dialogs close

Closing Blocked people, or the 2FA setup or disable dialog opened from
the switch, left keyboard focus on <body>, so the next Tab started at
the top of the page. The 2FA switch also disables itself while its
dialog is up, which drops focus even before then. Focus now goes back
to the button or switch that opened the dialog once it has gone.
2026-10-24 08:30:57 -07:00
Daniel SalazarandReynaldi Chernando f3e8539eaf docs: easy events recipe (PUT-1961) (#3998)
* docs: easy events recipe (PUT-1961)

* docs: catch-up-on-notifications events recipe (PUT-1961)

* docs: notification examples use notif:app-user, which apps can read

* docs: events recipe corrections from a run against a live server (PUT-1961)

* refine and update agents md

---------

Co-authored-by: Reynaldi Chernando <[email protected]>
2026-10-24 15:26:07 +07:00
Nariman Jelveh b3b02b1ebb Fix/dashboard fifth review (#4313)
* fix(dashboard): close the Apps modals and folders only on a press that starts on the backdrop

The uninstall and add-app modals and the open folder closed on any click
whose target was their backdrop. Drag-selecting text in the app request
box and releasing outside the card lands that click on the backdrop, so
the modal and the typed request were thrown away.

The backdrop logic the dashboard dialogs already use moves into
bindBackdropDismiss, and the three Apps overlays use it too.

* fix(dashboard): double-clicking "Add an app" no longer opens App Center

The add-app modal opens under the pointer, so the second click of a
double-click on the "+" tile landed on its first option and launched App
Center (or the AI builder, depending on where the tile sat) without the
user choosing anything. An option now ignores the second click of a
double-click.

* fix(dashboard): act on an app tile once per double-click

A tile acted on every click, so a double-click opened an external app's
site in two browser tabs. Double-clicking an option in the add-app modal
also launched whichever tile sat under the pointer once the modal had
closed (App Center and Recorder together, say). A tile now ignores the
second click of a double-click; the first already launched, focused or
opened what was asked for.

* fix(gui): show app launch refusals in a Puter alert, translated

launchApp reported "You don't have access to …" and "Couldn't open …"
through window.UIAlert, which nothing defines, so both fell through to
the browser's native alert(): a blocking, unstyled, English-only dialog
over the dashboard or desktop. They now use UIAlert directly, with the
text in en.js, and pass the app title unencoded since UIAlert encodes
its message itself.

* fix(dashboard): clicking a toast with nothing to open keeps apps on screen

A toast click brings the dashboard forward before acting on the
notification, hiding every app window. Only share notifications lead
anywhere, so clicking an "events subscription ended" toast (or any other
type) to dismiss it minimized the app the user was working in and showed
a dashboard with nothing to look at. Those toasts now just mark the
notification read.

* fix(dashboard): show an emptied Trash as empty

Emptying the Trash from the Files tab took its rows away but left the
footer on "N items · X MB" and the pane blank, without the empty-folder
notice; the selection bar kept offering actions on rows that were gone.
Emptied from another tab, the listing was cleared with .empty(), which
also dropped the loading overlay and likewise never updated the footer.

Both paths now finish with the empty notice and a fresh footer.

* fix(dashboard): say a folder is empty when its last item goes elsewhere

When another tab, device or app deleted or moved away the last item in
the folder Files was showing, its row faded out and left a blank pane:
the item.removed and item.moved handlers updated the footer but never put
the "folder is empty" notice up. They now do, for rows that were in the
Files listing.

* fix(dashboard): list failed drag-and-drop uploads one per line

The upload-button path was fixed to put each failed item on its own line,
but files dropped onto the listing still joined them with "\n", which
UIAlert's HTML collapses into spaces: every failure ran together on one
line. Both paths now build the message in failedUploadsMessage.

* fix(dashboard): dropping items back into their own folder does nothing

Dropping a selection on the sidebar entry for the folder it came from, or
spring-loading into a folder and coming back to release in the listing,
asked move_items to move every item where it already was, and each one
stopped on a "Cannot move item to its current location" alert in turn.
The breadcrumbs already ignore such a drop; moveDraggedRows now leaves
those rows out too, and a drop with nothing left to move does nothing.

* fix(dashboard): a Ctrl- or Alt-drop on the Trash trashes the items

Ctrl-dragging a file onto the sidebar Trash (or a Trash breadcrumb)
copied it into the Trash: the original stayed put, and the copy carried
no original path, so it was offered Restore and Restore failed. Alt-drops
made shortcuts there the same way.

The four drop handlers' copy/shortcut/move choice moves into
dropRowsInto, which only ever trashes what lands on the Trash.

* fix(dashboard): the first row click after a marquee released outside Files counts

A marquee sets a flag so the click that ends it doesn't clear the new
selection; the listing's click handlers consume it. Released over the
sidebar or outside the window, that click lands elsewhere, the flag
stayed set, and it swallowed the user's next click on a row. The flag now
lapses once the release's own click has had its turn.

* fix(dashboard): Back and Forward follow a folder the user renamed

Open a folder, go back, rename it, then press Forward: history still
held the old path, so Files showed "This folder couldn't be opened", and
the Back/Forward long-press menus listed the old name. A folder renamed
in Files now rebases the history entries in and under it, using the
same path rebase the username-change handler does.

* fix(dashboard): a folder that can't be opened becomes the current folder

When a folder failed to list (deleted elsewhere, share revoked, offline),
Files said "This folder couldn't be opened" but stayed on the previous
folder underneath: history had moved on while the breadcrumbs, sidebar
highlight and New Folder/Upload/Paste still pointed at the old folder,
so a new folder or upload landed somewhere other than where the user
seemed to be.

The tab now moves to the folder it was asked for before showing the
message, and creates nothing in it until it lists again.

* fix(dashboard): double-clicking Save in the photo crop uploads it once

Save closes the crop dialog, but the dialog stays in the page for its
200ms fade-out with its handlers live, so the second click of a
double-click ran Save again and sent the profile picture to the server
twice. Save does nothing once the dialog has closed.
2026-10-23 19:04:36 -07:00
Toshit ChawdaandClaude Opus 5 bdc9aa9e93 Revamp networking api with epoxy try 2 (#3476)
* revamp networking api with epoxy

* update to new epoxy build

* force wisp auth

* emit Error objects from socket 'error' events

main tightened the socket error contract to pass an Error (see
types/modules/networking.d.ts, which documents the reason as
`error.message`); the epoxy rewrite still emitted a bare string,
matching the older `(reason: string)` declaration.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

* test: cover the epoxy-based networking client

Replaces the coverage lost when the wisp implementation was removed:
PSocket.test.js and PWispHandler.test.js were bound to PWispHandler,
parsers.js, and wispInfo, none of which survive the epoxy rewrite.

  PSocket.test.js  - connect/retry, inbound data, write normalisation,
                     close, and the TLS event remapping. Driven through
                     real ReadableStream/WritableStream pairs so
                     reader locking, cancel, and abort behave as they
                     do in the browser.
  index.test.js    - relay-token exchange (auth header, 401 re-auth and
                     retry, malformed responses) and the epoxy client
                     cache (keying, refresh, in-flight sharing).
  requests.test.js - pFetch delegation, cache invalidation, and the
                     api call logger's request description.
  epoxy.test.js    - the hand-packed wisp password extension payload.

The wasm runtime loader is left to integration coverage; it needs a
network fetch and a browser. createPuterPasswordBuilder is exported so
the byte layout can be tested against an injected fake runtime.

One skipped test records a pre-existing defect: on a failed write,
#readLoop's normal-termination path races #closeStreams and emits
close(false) after an error.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

* fix: report socket close flags and epoxy init failures accurately

PSocket #readLoop raced the teardown it was being torn down by. A failed
write sets #closing and hands the close event to #closeStreams, which has
to await reader.cancel() and writer.close() before emitting close(true).
That cancel resolves the loop's pending read with {done: true}, so the
loop broke, reached its own #emitClose(false), and set #closed first --
callers were told the socket shut down cleanly right after an error.
The loop now defers the close event whenever a teardown is under way, in
both its normal and its throwing path, and #closeStreams always emits.

getEpoxyClient swallowed every init failure and resolved undefined, so a
dead relay reached callers as "cannot read properties of undefined
(reading 'connect')" instead of its cause. It now re-throws, still
without caching the failed attempt. A socket reports "wasm unavailable"
or "Failed to create relay token (HTTP 503 ...)" and closes with the
error flag set.

Two related cache faults fell out of that rewrite: the in-flight
early-return ignored `refresh`, handing a caller the very attempt it
asked to replace, and it ignored the cache key, so an attempt started
before a token change satisfied a request made after it. Reuse is now
conditional on both.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

* restore the public API surface dropped in the epoxy revamp

The epoxy rewrite kept the shape of the old networking client but lost
three things callers depend on:

- JSDoc on the exposed methods. Re-added on PSocket (constructor, on,
  addListener, write, close), PTLSSocket and pFetch, along with the
  SocketEvent typedef and a Networking type on netAPI. `on` carries the
  TLS event aliasing docs that used to live on PTLSSocket, since that
  behaviour moved here.

- socket.write's error message, which lost its trailing '!!'. Apps match
  on the message, so it is restored verbatim.

- pFetch's client-side validation. Epoxy reports a bad request as an
  opaque wasm value from deep inside the transport, so the scheme check,
  the Content-Length/body check and the malformed-request TypeError are
  made before a relay is dialled, rejecting with exactly what they
  rejected with before. Validation builds its Request from a clone, or it
  would consume the body epoxy is about to send.

The latter two are what the net API suite asserts on node, browser and
workerd, and it has been failing on all three since the revamp.

addListener takes named parameters so it can be typed, and three unit
tests that pinned pFetch's habit of forwarding unvalidatable input were
updated to the documented behaviour.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

* update epoxy client to 04e4930

connectionPrefs changed shape: the handshake and its required extension
list are one object now rather than a tuple, and handing the new build
the old form throws part way through the handshake.

The bump is not optional. The previous build cannot reach the current
relay at all: it enforces the required puter password extension even when
a relay answers with a v1 CONTINUE, and fails with "Protocol extensions
not supported" -- which is what puter.cafe answers with today.

Verified against a local relay: fetch, POST bodies, https, raw sockets and
TLS sockets, the last exercising connectTls, whose third parameter became
an options object in this build.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>

* bump epoxy

* dont require ext

* chore(puter.js): update Epoxy runtime to 68c6904

* chore(puter.js): update Epoxy runtime to affd9f5

---------

Co-authored-by: Claude Opus 5 (1M context) <[email protected]>
2026-10-23 16:57:25 -07:00
Nariman Jelveh 21c1e4142e fix(dashboard): fourth adversarial review — bugs, UX, a11y, perf (#4312)
* fix(dashboard): keep Escape working when two share or properties dialogs are open

Each share and properties dialog bound its document keydown under one fixed
namespace, so closing either instance unbound every instance's Escape.
Double-clicking Share… or Properties in the context menu opens two; after
the top one closed, Escape no longer closed the one left behind. Each dialog
now binds under its own namespace.

* fix(dashboard): don't close Properties when a text selection ends on the backdrop

Drag-selecting the path or UID in the properties dialog and releasing over
the dimmed backdrop closed the dialog: the click lands on the overlay when
press and release are on different elements. The backdrop now closes it
only when the press started there, as the share dialog already does.

* fix(dashboard): keep focus in Properties after disassociating one of several websites

With more than one website on an item, disassociating one removed the
focused button and returned early before refocusing the dialog, so focus
fell to the page and the next Tab landed in the dashboard behind the modal.
The dialog takes focus back as soon as the entry is removed.

* fix(dashboard): handle the Properties stat failure instead of leaving it unhandled

puter.fs.stat both calls the error callback and rejects. The properties
dialog rendered the error from the callback but dropped the promise, so
opening it on a deleted item or offline logged an unhandled rejection.

* fix(dashboard): don't double-encode the "anyone" and withheld-invite share rows

The share dialog encodes row names when it renders them, but these two
labels came from i18n() already HTML-encoded, so a translation containing
an apostrophe or ampersand would show its entity text. They're now taken
unencoded like every other row name.

* fix(dashboard): make the touch context menu reachable by assistive tech and keyboard

The touch action sheet had no roles and never took focus: it was appended
at the end of the page, so a screen reader announced nothing on open and
focus stayed on the page behind it. It's now a menu of menuitems (with
separators and submenu popups marked), takes focus on open, gives it back
on close, moves between enabled items with the arrow keys and Home/End,
and shows an inset focus ring for keyboard use. Taps draw no ring.

* fix(dashboard): keep Apps folders and order when a settings read fails

The Apps tab read the saved order, folders and uninstalled-apps list with
`.catch(() => null)`, so a failed read looked like a user who had never
saved any. One flaky request emptied the folders and reset the grid to
the default order, and the next drag or folder edit wrote that loss back
to kv for good.

A failed read now keeps what the session already has (the last
uninstalled-apps list included). Until a read of the order or folders has
succeeded, saves stay in memory instead of overwriting the stored value.

* fix(dashboard): drop the Apps tile a failed deep-link launch drew

Landing on /app/<name> (or choosing an option in the add-app modal) adds
the app's tile before the launch runs, because the launch is what installs
it. When the launch failed — no access, no app token, a thrown error — the
tile stayed for the rest of the session, survived refreshes, and every
click on it failed again.

The launch outcome is now passed to settleDeepLinkLaunch, which takes a
tile it drew back out (and restores a previous uninstall record) when
nothing was launched.

* fix(dashboard): put a re-installed app back in its saved slot

Uninstall keeps the app's name in the saved order so a re-install returns
it to its place, but a deep-link landing appended the tile at the end of
the grid. The install animation played on the last page and the tile then
jumped back to its saved slot on the next refresh. The landing now places
the tile through reconcileAppOrder like every other load.

* fix(dashboard): don't double-encode the add-app launch failure alert

UIAlert encodes its message, so passing it the already-encoded i18n string
showed entities on screen in languages with an apostrophe (French read
"s&apos;est mal passé").

* fix(dashboard): say so when an app tile fails to launch

A tile click whose launch rejected (for example the app lookup failing)
only logged to the console: the icon flourish played, settled, and nothing
opened. It now shows the same alert the add-app modal shows for that
failure.

* fix(dashboard): keep focus inside the Apps modals and return it on close

The uninstall and add-app modals are aria-modal but had no Tab trap, so
Tab walked into the search box and tiles behind the scrim, where typing
filtered the grid under the modal. Closing the add-app modal, and
confirming an uninstall, dropped focus to the page body.

Tab now cycles inside both cards; closing the add-app modal returns focus
to the add tile, and an uninstall leaves focus on the tile that takes the
removed app's slot.

* fix(dashboard): upload from a folder's menu into that folder

Upload in a sidebar or breadcrumb folder's menu opened the shared file
picker, whose handler always uploaded into the folder on screen: files
picked for Pictures while viewing Documents landed in Documents. The menu
now hands its folder to the picker.

* fix(dashboard): no New, Upload or Paste where nothing can be created

New Folder, Upload, the New/Paste/Upload menu items and file drops were
only refused in the Trash itself and the Shared view. Inside a trashed
folder they created items without the metadata Restore needs, and at /
every create failed (an upload there threw before its progress window
existed and showed nothing). One check now covers the Trash, folders in
it, the Shared view and /.

* fix(dashboard): make Paste Into Folder work after a cut

The row menu's Paste Into Folder handed a cut to the desktop's
move_clipboard_items, which finds items by their on-screen elements and
plain-string paths. The Files tab's cut stores {path, uid} objects, so
nothing moved and the clipboard was emptied. The menu now takes a paste
handler, and Paste, Ctrl+V and Paste Into Folder share one.

* fix(dashboard): don't cut or copy trashed items out of the Trash

The single-item menu still offered Cut on trashed items, and Ctrl+C
copied them; pasted elsewhere, they kept their raw name in the Trash, a
uid. Trashed items leave through Restore, which puts the original name
back.

* fix(dashboard): Delete Permanently works in every language

The confirmation compared the clicked button's label with 'Delete', so in
any other locale confirming did nothing. The buttons carry values now.

* fix(dashboard): refresh Files on return after a cancelled rename

Escape, or keeping a name unchanged, hid the rename editor but left its
-active class, and the refresh on returning to the browser tab takes that
class for an open editor: the listing stopped refreshing until the next
navigation.

* fix(dashboard): clear a drop target's highlight after the drop

jQuery UI fires no `out` on drop, so the folder row a selection was
dropped on stayed selected (and went to the Trash with the next Delete),
a sidebar folder stayed highlighted as the current one, and a breadcrumb
kept its drop outline.

* fix(dashboard): listing drops keep working after a drop on a folder row

Dropping files from the computer onto a folder row stopped the native
event before the listing's dragster saw it, leaving its enter state stuck:
later drags didn't highlight the listing, and drops on its background
uploaded nothing.

* fix(dashboard): offer Restore only for items Restore can handle

Selecting files inside a trashed folder offered Restore in the
multi-select menu and the selection bar, which then failed once per item:
only direct children of Trash carry restore metadata. The trashed
multi-select menu also drew two separators in a row.

* fix(dashboard): show names as typed in conflict and move-failure alerts

These alerts html-encoded the file name before UIAlert encoded the
message again, so "R&D.txt" read "R&amp;D.txt". "already exists." is
translatable now too.

* fix(dashboard): list failed uploads one per line

The failed-upload list was joined with newlines, which the alert collapses
onto a single line.

* fix(dashboard): translate the shortcut badge's tooltip

* fix(dashboard): select mode toggles rows with any pointer

In select mode, a stylus or mouse tap on a phone or tablet toggled the row
in pointerdown and again in click, leaving it as it was; in a narrow
desktop window a click replaced the selection and the selection bar never
showed. Select mode now toggles in click for every pointer and shows its
bar whenever it is on.

* fix(dashboard): show items added elsewhere while New › Web Link prompts

The New menu's create wrapper suppressed item.added for as long as it ran,
which for Web Link includes its URL prompt, so files added to the folder
meanwhile never appeared. It now counts only from the actual create. A
create that fails now says so instead of failing silently.

* fix(dashboard): keep the Files spinner under the mobile drawer

The loading overlay sat at the maximum z-index, so on phones it drew over
the open drawer and its scrim and took their taps while a folder loaded.
It only needs to sit above the rows.

* fix(dashboard): don't show a failed usage request as zero usage

getMonthlyUsage() resolves with the error body when the request fails, and
Usage and Home read it as a month with nothing used: $0.00 of $0.00, 0%,
and on Usage an empty table replacing the one already on screen. A body
without a usage report is treated as the failure it is, so the last good
numbers stay and a first load shows the "unavailable" note.

* fix(dashboard): draw Files select-mode accents in the selection color

The checked checkbox, the active Select button and the Done button were
filled with --primary-color, the desktop's text color rather than an
accent: charcoal by default, and white (on white text and ticks) for
anyone whose desktop theme uses light text. They use --select-color, the
accent the rest of the selection already uses.

* fix(dashboard): stop the mobile drawer animating under reduced motion

The reduced-motion override for the drawer, its scrim and the menu button
came before the drawer rules it was meant to cancel, and the open
drawer's rule outranked it too, so the drawer still slid and the scrim
still faded. The override now follows those rules and covers the open
state.

* fix(dashboard): make the toast close button readable in dark mode

Dark mode gave the close button the card color and then inverted the
whole element, which turned the disc light grey and the cross white:
about 1.4:1. Without the background the invert gives the light-mode
button's mirror, a dark disc with a white cross.

* fix(dashboard): leave the temporary-account state once the account is saved

Saving a temporary account from the user menu promoted the user in place
without a reload, but Home kept its Save session warning (reopening the
save dialog for a saved account) and Account and Security stayed without
their password and email cards until the page was reloaded. The dashboard
now reacts to account-saved, which fires after the user is updated.

* fix(dashboard): give the API token field 16px text on touch screens

The coarse-pointer rule that sets the token field to 16px, as the other
dialog inputs are, came before the field's own 13px rule of the same
specificity, so the 13px won and iOS zoomed the page when the field was
focused. The touch rule now follows it.

* fix(dashboard): lighten the General access dropdown arrows in dark mode

The dark-mode chevron override covered the share modal's recipient
selects but not the two General access ones, which kept the darker
light-theme stroke on the dark card.

* fix(dashboard): don't leave the sidebar collapse button shaded after a tap

Its hover style was the sidebar's only one not limited to devices that
hover, so on a wide touch screen (an iPad in landscape) the button kept
its hover shade after being tapped.

* fix(dashboard): refresh the Usage tab when the subscription changes

An upgrade from the Usage tab's own button, or a return from the billing
portal, refreshed Home behind it while the Usage meter and plan button
kept the old allowance until the user left the tab and came back. Usage
now handles puter:subscription:changed while it is showing.

* perf(dashboard): load usage and storage once when the dashboard opens

Home and Usage both load the monthly usage and the storage space when the
dashboard opens, whichever tab it opens on, so every open sent
/metering/usage and /df twice. Overlapping loads now share one request.
Nothing is cached: a load after the shared one settles fetches again.

* fix(dashboard): keep keyboard focus on the Usage table's Show more/less

The table redraws without the button that was pressed, so focus fell back
to the page and the next Tab started from the top. Focus now moves to the
button that replaces it, as the sort headers already do.

* fix(dashboard): give primary buttons readable contrast

The dialogs' primary buttons, the share modal's Share and the photo
crop's Save were filled with the link color under white text: 4.07:1 in
light mode, 3.08:1 in dark, where hover lightened it further to 2.38:1.
They take their own fill now, #4a67f5 (4.62:1) darkening to #3d5bd9
(5.68:1) on hover in both themes; links keep their color.

* fix(dashboard): keep collapsed-sidebar labels above the dashboard

The hover label is attached to body at z-index 1000, while the dashboard
window's z-index climbs with every click (focusWindow raises it). Over a
long session the window passed 1000 and the labels rendered behind it,
so hovering the collapsed sidebar showed nothing.

* fix(dashboard): bring muted and tertiary text up to AA contrast

The muted and tertiary greys carry real information (form hints, file
dates and sizes, share "via" lines, the sidebar's team name,
notification text and times) but fell short of 4.5:1: 2.56:1 and 3.54:1
on white, 3.18:1 and 3.13:1 on dark cards. They now clear it on the
cards and the sidebar in both themes (light #5f6b7d / #707070, dark
#8b95a5 / #94949c). Disabled buttons keep the fainter grey through their
own token, so they still read as disabled.

* fix(dashboard): name the plan on self-hosted installs instead of "--"

A self-hosted install has no /marketplace/subscriptions/current route, and
Home treated its 404 like any failed read, leaving "Your Plan" at "--" for
good. A 404 now shows the plan every account starts on. The upgrade badge
and link, which have nothing to reach there, go too, unless the hosted
upgrade dialog is loaded.

* fix(dashboard): make hover and press feedback visible in dark mode

Dark mode's hover color, #2a2a2a, was 1.05:1 against the #262626 cards
and #252525 sidebar, so hovering a notification or sidebar item and
pressing a touch-menu or drawer item showed next to nothing. #333 sits
as far off them as light mode's hover sits off white, and stays below
the selected item's #3d3d3d.

* fix(dashboard): follow a username change in the Files tab

Changing the username, or saving a temporary account under a chosen
name, moves the home folder. The GUI rewrote data-path attributes and the
path globals, but the Files tab kept its current folder, back/forward
history, clipboard and drawn rows on the old path, so sorting, New
Folder, Upload, Up, Back and the breadcrumbs all failed with "This folder
couldn't be opened" until a reload. update_username_in_gui now announces
the change, and the Files tab moves its paths over and redraws.

* perf(dashboard): lay out only the new row when an item is added to Files

Every item added to the open folder from elsewhere (another tab, an
upload, a move into it) restyled and re-truncated every row in the
listing, so a burst of adds was quadratic in the folder's size: 100 adds
into a 300-item folder took ~800ms of main-thread work. The added row now
takes the listing's column layout on its own (~130ms for the same burst).

* fix(dashboard): open the Sort and View menus at their buttons from the keyboard

Enter or Space on either button fires a click with no coordinates, so the
menu opened at the top-left corner of the screen, far from the button.
A keyboard press now opens it below the button; a pointer click still
opens it at the pointer.

* fix(dashboard): open a breadcrumb's long-press menu as the touch sheet

A breadcrumb's long-press opened the desktop context menu at the event's
pointer position, which a long-press doesn't have, so on tablets the menu
landed wherever an unset position put it. Breadcrumbs now open the same
touch sheet, anchored to the crumb, as the sidebar folders and rows do.

* fix(dashboard): don't drag-move items the user can't move

Delete, Cut and their buttons already leave out shared roots and items in
a read-only share, but dropping them on a folder, a sidebar entry or a
breadcrumb still sent the moves, and each came back as a "Forbidden"
alert. Drag moves now leave those rows out too; copying them still works.

* fix(dashboard): clear "1 item selected" when a double-click opens a file

Opening a file by double-click deselects its row, but the footer kept
saying "1 item selected" with nothing selected.

* fix(dashboard): only the Trash gets the Trash label in breadcrumbs

Any folder named "Trash" was shown with the translated Trash label in the
breadcrumbs, so in German ~/Documents/Trash read as "Papierkorb". The
label now goes on the user's Trash folder only.

* fix(dashboard): name a trashed folder by its old name in the breadcrumbs

A trashed item's name on disk is its uid, so opening a trashed folder
showed a crumb like "ffaff8cb-dcaf-44f7-…" while its row in the Trash
read "Old project". The crumb now uses the name the row shows.

* fix(dashboard): show why Delete Permanently failed and keep the item

delete_item fades the item out before the request; when the request
failed it left the item hidden, as if deleted, and passed the alert the
undefined `responseText` of a puter.js error, which threw. The item now
comes back and the alert shows the error's message.

* fix(dashboard): translate the New menu's Web Link and templates strings

"Web Link", its URL prompt, validation and failure messages, the "User
templates" submenu, and the account prompt before publishing a worker
were hardcoded English in the Files New and item menus.

* fix(gui): stop leaking a document click handler per context menu

Every context menu bound its item-click handler on document, delegated
to its own id, and never unbound it. Each menu opened therefore left a
handler that ran on every later click in the page and kept the menu's
items alive, rows and fs entries included. The handler is now bound on
the menu itself and goes with it. It still returns false, so item clicks
keep not reaching document-level click handlers, as before.

* fix(gui): run a context menu item once on a double-click

The menu stays clickable during its 200ms fade-out, so the second click
of a double-click ran the item again: double-clicking Share… or
Properties in the dashboard opened two stacked dialogs. A menu that has
started closing now ignores item clicks.

* fix(dashboard): drag a select-mode selection from its own rows

Select mode returned from pointerdown before recording whether the
pressed row was already selected, so a drag started from that stale
value: dragging selected rows didn't move them, and a stale "selected"
could let a drag from an unselected row clear the selection. The value is
recorded first now.

* fix(dashboard): leave Files select mode when the window widens past 480px

Select mode's Select and Done buttons only show up to 480px, so turning
it on in a narrow window and widening it left every click toggling rows,
with no way out short of narrowing the window again.
2026-10-23 15:24:02 -07:00
Neal Shah 03e483e372 PUT-2252 (#4311) 2026-10-23 17:33:59 -04:00
Nariman Jelveh 9c90c7afde fix(dashboard): third adversarial review — bugs, UX, a11y, perf (#4310)
* chore(dashboard): add the standard file header where it was missing

* fix(dashboard): rename editor keeps entity-like names; rows follow remote renames

The name editor decoded its already-decoded value a second time, so opening
it on a file such as "R&amp;D.txt" and clicking away renamed it to "R&D.txt".

A rename or update from another tab only patched a row's attributes, while
its open/copy/share handlers kept using the entry they were rendered with;
the row now keeps that entry current and refits its name.

* fix(dashboard): Files selection, preview and Trash/Shared edge cases

- A marquee drag without modifiers now replaces a selection made by click,
  Ctrl+A or the arrow keys instead of adding to it.
- With the image preview open, clicking another image switches to it, and
  moving onto a non-image closes it instead of keeping the old image up.
- Cut (Ctrl+X, multi-select menu) skips trashed items, which leave the Trash
  through Restore; Ctrl+V refuses the Trash and the Shared view like the
  Paste menu item does.
- Drops on the Shared sidebar entry or breadcrumb are refused (the drag
  snaps back) instead of sending writes to a path that isn't a folder.
- The Trash selection bar shows Delete permanently again after a selection
  elsewhere had hidden it.

* fix(dashboard): keep an open rename across refreshes; re-sort renamed rows

A same-folder refresh (an upload or paste finishing, a sort change) rebuilt
every row and silently dropped a rename in progress; the editor now comes
back on the same item with the text and caret the user left.

A renamed row whose new name sorts last stayed where its old name was.

* fix(dashboard): Files columns follow the room the listing has

- Resizing a column past the listing's width clamps it instead of resetting
  every column to the defaults; a window too narrow for the saved widths
  lays out at the defaults but keeps the saved widths for when it widens.
- Columns and grid names re-fit when the window or sidebar resizes the
  listing, not only on the next navigation, and grid names hidden behind
  another tab are fitted when the Files tab is shown.
- Names are measured in the font they render in (the compact grid's is
  smaller), and a column drag relayouts at most once a frame.

* fix(dashboard): Files keyboard and touch edge cases

- The marquee selects file rows only, not the Shared/Trash place rows Home
  shows on narrow screens.
- Row shortcuts stand aside while the touch action sheet is open.
- Breadcrumb long-press opens its menu on iPadOS too.
- A folder rendered before the saved view preferences load uses the
  defaults instead of throwing and blocking navigation.

* fix(dashboard): share and properties modals — focus, Escape, link state

Share modal:
- Escape pressed in a window opened over the modal (a verification or
  upgrade gate) no longer closes the modal underneath.
- Turning the link on or off for a selection too large to list now shows
  the result instead of leaving the control on its old state.
- Opening the link up starts at view access; it no longer reuses a hidden
  mode left over from earlier, which could hand out edit access.
- Focus returns to the select the user changed.

Properties modal:
- Takes focus when it opens, keeps Tab inside, gives focus back on close,
  and leaves Escape to a window opened over it.
- Shows a loading state, and opening Versions on a folder before its
  details load no longer leaves an empty body.
- Disassociate is a real button, ignores repeat clicks while pending,
  reports a failure, and keeps the website badge while other sites remain.
- Tabs carry tab/tabpanel roles; transitions respect reduced motion.

* fix(dashboard): account dialogs, avatar saves and usage figures

- A form dialog ignores Escape and backdrop clicks while its request runs,
  so the outcome isn't lost behind a closed dialog; the close button still
  works.
- A new API token, shown only once, survives a stray click outside the card.
- Copy token works on origins without navigator.clipboard (plain-http
  self-hosts) instead of throwing, falling back to a selected field.
- Saving one profile picture and then another before the first finished
  can no longer leave the first one's picture or failure on screen.
- The storage host bar is clamped to the room left beside Puter's share and
  hides its label when there is no host figure.
- Sub-cent dollar costs read "<$0.01" rather than "$0.00", and the usage
  meter reads 99% until the allowance is actually spent.

* fix(dashboard): Apps folder drag, search, keys and loading

- A drag inside an open folder that is cancelled (Escape, blur) or dropped
  back in place puts the tiles back in their saved order; the cached panel
  markup used to skip the rebuild, and the next drag then saved the
  cancelled move too.
- Search no longer matches substrings of generated app ids, so "app" or a
  single hex letter stops returning every website shortcut.
- Arrow keys and Enter stay with the notifications panel, an account dialog
  or the touch action sheet while one is open over the grid.
- The grid shows after 1.5s even if an icon host never answers.
- Refreshing a grid already on screen keeps the page and skips the fade.
- Rename from a folder's menu focuses the name on touch devices too, and
  the folder dialog's label follows renames and counts the apps shown.
- i18n inserts %% values literally, so a name containing $& or $' no longer
  comes out mangled.

* fix(dashboard): shell navigation, a11y and notification sync

- Missed notifications replayed on load toast as one burst (a few, then a
  summary) instead of one toast each.
- A notification read in another tab while this tab's first listing is in
  flight is no longer shown as unread when that listing lands.
- Focus stays in the notifications panel when Mark all read or Retry hides
  the button that had it.
- The active sidebar tab carries aria-current, and the account button is
  named by the username even while the collapsed sidebar hides it.
- Landing with ?ref= replaces the history entry, so one Back leaves.
- Ctrl/Cmd/Shift-click on a sidebar tab opens it in a new browser tab.
- Recently used keeps the newest list when refreshes overlap, and app names
  are URL-encoded in the links it opens.

* fix(dashboard): focus rings, dark-mode contrast and reduced motion

- App tiles show a focus ring, not only the hover enlargement; Home card
  headers get an inset ring the card's rounded clip can't cut off; the
  Delete account button gets the settings cards' ring.
- Pager arrows are mouse-only (the dots page by keyboard), so invisible
  arrows are no longer Tab stops, and an arrow with no page leaves the
  accessibility tree.
- Dark mode: the Files toolbar's action icons match the nav arrows and stay
  readable on the hover chip, hovered breadcrumbs keep their contrast, and
  the shared-item dot's ring follows the row background.
- The row '⋯' button is visible at rest on touch screens.
- A long username or email wraps inside the profile card.
- The touch action sheet, the Files selection bar and the app feedback
  modal stop scaling/sliding under reduced motion.

* fix(gui): desktop rows store remote renames raw; column resize stays in bounds

- The desktop's item.renamed/item.updated handlers wrote HTML-escaped names
  into data-name, the title and the name editor (and titled windows by an
  undefined uid). They store the raw name now, as rename_file does, so the
  editor shows the real name now that it no longer decodes a second time.
- Resizing a Files column while saved widths are too wide for the listing
  starts from the widths on screen, and a width is held at its column's
  minimum, so a drag or auto-fit can't save a negative width.
- Double-clicking a row closes the image preview, which would otherwise
  float above the app window it opens.
2026-10-23 12:32:33 -07:00
Rasad RegmiandDaniel Salazar acd3df8c1b fix(puter-js): replay the request after a successful token_auth_failed reauth (#3954)
* fix(puter-js): replay the request after a token_auth_failed reauth

A 401 carrying the legacy `token_auth_failed` code opened the sign-in
prompt and then dropped the re-authenticated token on the floor:
`resolveReauth()` fell through to `return null` instead of returning
`{ action: 'replay' }`, so the original call was never re-issued and
rejected with `Unauthorized` even though the new session was valid.

Return the replay verdict the `reauth_required` branch already returns.
Both replay paths stay one-shot (`ctx.done.add('reauth')`,
`spec._replayed`), and the auth gate rejects before the route handler
runs, so a replay has no side effects.

Closes #3953

* fix(puter-js): sign in once when a replayed XHR is refused again

handle_resp ran the reauth flow on the replay's own 401, so a
token_auth_failed that survived the replay reset the fresh token and
opened a second sign-in before rejecting. Treat a replay's 401 as final,
as the 403 verification-gate branch already does.

---------

Co-authored-by: Daniel Salazar <[email protected]>
2026-10-23 00:07:56 -07:00
Daniel Salazar 4729d733bb feat(aws): DynamoDB, Polly and Textract can use the default credential chain (#4307)
`useCredentialChain: true` in `dynamo.aws`, `providers['aws-polly']` or
`providers['aws-textract']` takes credentials from the default AWS chain
instead of static keys, as `s3.s3Config` already does. One provider is shared
and refreshes temporary credentials itself.
2026-10-22 21:31:23 -07:00
Nariman Jelveh 26d843778e feat(dashboard): account dialogs as dashboard modals instead of windows (#4305)
* feat(dashboard): account dialogs as dashboard modals instead of windows

Edit profile, change username, change password, change email, create API
token, and delete account in the Account tab (and change password in the
Security tab) now open as dashboard overlay cards, like the share,
properties, and confirm dialogs, instead of desktop UIWindows. They are
centered cards on desktop and tablet and bottom sheets on phones.

- dashboardOverlay.js: the overlay shell UIDashboardDialog already had
  (reveal, Esc/backdrop/close dismissal, focus trap, focus return), pulled
  out so the new form dialog shares it. It also pins the overlay to the
  visual viewport so iOS's keyboard can't cover a sheet's fields, and
  leaves Esc to a modal opened on top of it.
- UIDashboardFormDialog.js: labelled fields, password show/hide, an
  aria-live status line that marks and focuses the offending field, a busy
  submit button, and a done state.
- postUserProtected.js: the cookie-retry / provider re-sign-in / reauth
  flow the four account windows each carried, in one place.
- Deleting an account is one dialog now (warning plus confirmation) rather
  than two stacked windows.
- Touch: 44px controls, 16px inputs (no iOS focus zoom), no autofocus
  that pops the keyboard mid-animation, form buttons side by side on
  phones, safe-area padding dropped while the keyboard is up.
- The replaced windows had no other callers and are removed with their
  tests, which are ported to the new modules.

* fix(dashboard): a double-click that opens a dialog no longer dismisses it

The second press of the double-click lands on the freshly mounted backdrop
and closed the dialog straight away. Backdrop presses with a click count
above one no longer count toward dismissal.

* fix(dashboard): a failed form submit keeps focus in the dialog

Locking the fields disabled the focused control, which drops focus to the
page, so after an error Enter no longer retried and screen readers lost
their place. The control that had focus gets it back once the fields
unlock, unless the handler moved focus elsewhere in the dialog.

* fix(dashboard): a form error keeps the field it names in view

The status line scrolled the dialog body to the bottom, which on a short
screen (a phone with its keyboard up) left the field being focused and
marked invalid scrolled off the top. The body now scrolls back up just
far enough to show that field.

* fix(gui): a blocked re-sign-in popup fails instead of waiting forever

openRevalidatePopup polled a popup that was never opened, so when the
browser blocked it the account dialog stayed busy indefinitely. It now
rejects straight away and the dialog says the sign-in didn't finish.

* fix(dashboard): password show/hide reads as one action, not a pressed toggle

The button flipped its label between Show and Hide password and also set
aria-pressed, so assistive tech announced "Hide password, pressed" while
the password was showing. It keeps the action label and drops aria-pressed.

* fix(dashboard): screen readers hear a form dialog's success message

The done state arrives as a new status region that already holds its text,
which screen readers don't announce, so after a change succeeded they only
heard "Done, button". The Done button that takes focus is now described by
the message.

* fix(dashboard): requesting an email change no longer sets the cached email

The server only stages the new address until the emailed link is
followed, but the dialog wrote it into window.user.email straight away.
That cached a wrong address for the session and kept refresh_user_data from
updating the Account tab once the change was confirmed, since the two
already matched.

* fix(dashboard): form dialog labels drop the global label shadow

style.css's bare `label` rule gave the field labels a white text-shadow,
which ghosted them on the dark theme, and an extra 3px under each label.

* fix(dashboard): dialog inputs keep a 1px border when focused

style.css's global input :focus rule still set a 2px border width, so a
dialog field's text shifted by a pixel each time it took focus. The focus
ring is the box-shadow; the border stays 1px.

* fix(dashboard): phone dialog inputs stay 16px instead of jumping on focus

On phones style.css's .device-phone input rule outranked the dialog's
16px size at rest, so fields drew at 17px and shrank to 16px the moment
they took focus.

* fix(dashboard): temporary accounts can delete themselves again

The server reports oidc_only for every passwordless account, temporary
ones included, so the shared user-protected helper sent a temporary
account into the linked-provider sign-in, which has no URL for it, and the
deletion never went out. Temporary accounts post directly, as the route
admits them without credentials.
2026-10-22 17:13:50 -07:00
Daniel Salazar 80a9cd85ee fix(ai): chat requests run to their upstream usage report after the caller disconnects (#4302)
- Providers send upstream calls with a driver-owned `upstreamSignal`
  instead of the caller's `abortSignal`. A disconnect detaches the chat
  stream: output stops, the provider keeps reading until the upstream
  reports usage, and that usage is metered.
- A detached stream gets SDK_TIMEOUT_MS to finish. After that the driver
  aborts the upstream, and the stream is metered like any other stream
  that ends without usage.
- The disconnect check also runs right before dispatch.
- The OpenAI stream handlers report usage that arrived before an abort.
- Docs: stopping a stream stops the output, and a request that reached
  the model is billed in full. Detached streams get 10 more minutes.
2026-10-22 16:08:05 -07:00
Nariman Jelveh 63370fcfa5 fix(dashboard): mobile and tablet review — layout, touch, safe areas (#4303)
* fix(dashboard): give narrow Files listings the two-row layout

The two-row rows (name, then size and date) were keyed to a phone-width
viewport, so between 481px and ~930px, where the sidebar and folder column
take their share, the fixed size and modified columns squeezed names to a
few characters. The layout now switches on the listing's own width with a
container query, which phones still meet and wide listings never do.

* fix(dashboard): wrap the Files toolbar onto two rows in narrow listings

On tablets the nav buttons, crumbs and actions sat on one row wider than
the listing, so the sort, view, new-folder and upload buttons ran off the
right edge. Narrow listings now use the phone's two-row toolbar (crumbs
above, buttons below); only the menu-button clearance stays phone-only.

* fix(dashboard): keep a deep folder path on one row with the current folder in view

A long path wrapped the crumbs over several lines on top of the column
headers and pushed the toolbar actions off-screen; where the crumbs already
scrolled (phones), the row opened at the root, hiding the current folder.
The crumbs now scroll within their row and are scrolled to their end on
render and when the tab is shown.

* fix(dashboard): end clipped file names in the list with an ellipsis

With the name column auto-sized, which is always the case on phones and
tablets, a long name was cut off mid-letter with no sign it continued.

* fix(dashboard): keep the Files selection bar under the drawer and sheets

The bar's visible state set z-index 99999999999999999 (clamped to the
maximum), so in phone select mode it drew over the open navigation drawer,
covering the account button, and over the share sheet it opens. Its base
z-index already clears the rows and the footer.

* fix(dashboard): let the last Files rows scroll clear of the phone selection bar

In phone select mode the list's bottom padding only cleared the footer, so
the last row stayed behind the floating action bar even fully scrolled.

* fix(dashboard): keep the phone Files footer and selection bar above the home indicator

The installed app runs edge to edge, so the footer's item count sat under
the iPhone home indicator. The footer now grows by the bottom safe-area
inset, and the list padding and floating action bar move up with it, as
the Apps tab and the dashboard's bottom sheets already do.

* fix(dashboard): clear the home indicator in the item properties sheet

On phones the sheet sits flush with the bottom edge, and unlike the share
sheet it mirrors, its body didn't pad for the bottom safe-area inset, so
the last details scrolled under the iPhone home indicator.

* fix(dashboard): keep the app feedback sheet's buttons above the home indicator

As a phone bottom sheet it sits flush with the bottom edge, so Cancel and
Send ended under the iPhone home indicator.

* fix(dashboard): size the touch context menu as a card on tablets

The touch menu took 90% of the viewport, which on an iPad meant a ~920px
strip with its labels far from the row they belong to. It now stops at
400px, which every phone stays under. Its height is also measured after
the width is set, so the clamp to the viewport uses the real height.

* fix(dashboard): let the Apps modals scroll in a short viewport

The Add-an-app and uninstall cards had no height cap and their overlay
centers without scrolling, so on a landscape phone with the keyboard up
(the viewport resizes for it) the request form's title and buttons sat
off-screen with no way to reach them.

* fix(dashboard): align Account and Security with the other tabs on phones

Below 768px their wrapper added a 16px gutter inside the content area's
own, so both tabs sat 32px in while Home, Apps and Usage (and the menu
button above them) sit at 16px. The extra padding stays for Apps, whose
content area has none.

* fix(dashboard): keep Home's recent apps inside their card on tablets

Each recent app was a fixed 200px inside 1fr columns, whose minimum is
that width, so in the half-width card on a landscape iPad the second
column ran past the card's edge and its name was cut off with the
ellipsis out of view. The rows now shrink with their column.

* fix(dashboard): keep every Files folder reachable in a short viewport

Trash was fixed to the bottom of the viewport, so in a landscape phone or
a short window it sat on top of Public and Videos, while Shared ran below
the column with no way to scroll to it. The folder column now scrolls,
and Trash sits at its foot in the flow: in the same spot when there is
room, after Shared when there isn't.

* fix(dashboard): don't leave Account and Security buttons highlighted after a tap

Their hover background wasn't limited to hover-capable pointers like the
rest of the dashboard's hover styles, so on touch devices a tapped button
(Change Password, Manage Sessions, ...) stayed shaded after its dialog
closed.

* fix(dashboard): give the Files row menu button a full-size touch target

On phones the row's '⋯' is 40x23, right beside a row whose tap opens the
item, so a slightly-off tap opened the file or folder instead of its
menu. In the two-row layout it now has a 44px square hit area on touch
screens.

* fix(dashboard): enlarge the Files back, forward and up touch targets

They were 28px squares on touch screens. On coarse pointers they now take
36px with a matching negative margin, so the toolbar layout is unchanged.

* fix(dashboard): size Home's recent apps card to its apps on phones

Its 310px minimum height matches the square welcome card beside it on
wider screens. In the single-column layout the welcome card drops its
square shape but the minimum stayed, leaving a block of empty card under
a short list of recent apps.

* fix(dashboard): stop clipping app names' descenders on phones and small tablets

Below 768px the tile height budgeted 52px icon + 10px margin + 12px for a
label line that is 15.6px tall, so the bottoms of g, p and y were cut off
in every app name. The tile now has the full line (78px) and the row gap
gives back the 4px, so tiles sit exactly where they did.

* fix(dashboard): show Usage amounts in full on phones

Every Usage table cell ellipsized, numbers included, so on a phone a
token count like 12,345,678 read as "12,345...". Below 480px the units
and cost columns now keep their full width and the resource name is the
one that truncates. Wider layouts are unchanged.

* fix(dashboard): stack Home's recent apps when their card is narrow

In the two-column Home on a portrait tablet (iPad Air and Pro 11 at
820-834px, or 769px windows) the Apps card is about 200px wide, and two
columns of recent apps cut every name to a letter or two. The list now
switches to one column on the card's own width; 1024px and wider keep two.

* fix(dashboard): fit two grid tiles a row in narrow Files listings

Grid view's 170px column minimum left one 330px tile per row on 360px
phones (and in a tablet's narrow listing), so barely two files fit on
screen. Narrow listings now use two equal columns, and the tile's icon
box shrinks with the tile instead of keeping its fixed 150px.

* fix(dashboard): align the Shared and Trash rows' menu button on phones

Item rows get position: relative from jQuery UI draggable, but the Shared
and Trash rows above Home's folders aren't draggable, so their '⋯' was
positioned against the whole list and sat 3px off from every other row's.
2026-10-22 15:39:47 -07:00
Daniel Salazar 631b264c1c test(ai): every AI driver holds and meters at the AI cost factor (#4304)
Image, video, TTS, speech-to-text and voice-changer driver tests run one
request at factor 1 and again with every `ai.cost.factor.*` hook set to
2, and check that both the credit hold and the recorded cost double.
Chat and OCR already had this coverage. A driver that hands a provider
the raw metering service instead of its factored view now fails its
test.

`withAiCostFactorHook` in integrationTestUtil keeps the hook in place
for a callback, since providers record their usage unawaited.
2026-10-22 15:31:15 -07:00
Filip KujundžićandClaude Opus 5.5 809c303117 fix(ai): treat a gateway 4xx with no upstream error body as a route failure (#4301)
Infron (a new-api gateway) answers "bad response status code N" with the
upstream's status when the upstream replied without a JSON error, e.g. a
model the upstream no longer serves. That 4xx was classified as the
caller's bad request, so the request skipped fallback, the dead route
stayed in rotation, and the caller got upstream_bad_request. It now counts
as an upstream failure: fallback runs, the route is marked unhealthy, and
an exhausted chain reports upstream_provider_unavailable. A gateway error
that carries the vendor's own message still stays request-level.

Co-authored-by: Claude Opus 5.5 <[email protected]>
2026-10-22 15:14:28 -07:00
VonypetandCursor afad53d3f4 feat(gui): add Filipino and restore the language menu (#4263)
The desktop built the language list and never placed it on the user menu, so a saved locale could not be chosen. Register Filipino and show that list.

Co-authored-by: Cursor <[email protected]>
2026-10-22 15:13:43 -07:00
Daniel Salazar 8b8dd19eae fix(teams): refuse to boot when teams_enabled has nothing registering Teams (#4300)
* fix(teams): refuse to boot when teams_enabled has nothing registering Teams

Core's seat checks (team 2FA, seat password reset, email and username
changes, self-delete, the signup placeholder claim) read the team store and
skip when it is absent. With `teams_enabled` on and no extension registering
the store and service, the server now fails at boot instead of serving
without those checks.

- whoami no longer string-matches the extension proxy's "missing property"
  error: with the flag on, the store is always there.
- Tests that turned the flag on without Teams drop it; the puter.js harness
  sets it only under the `teams` capability.
- Drop a stale services/index.ts comment.

* refactor(gui): drop the Teams console styles, which ship with the console now
2026-10-22 15:11:36 -07:00
Juan Fernando Castro 1fdde02f6a feat(metering): free and no-allowance plan traits, provisional resolver answers (#4284)
* feat(metering): let registered plans declare free and no-allowance traits

A policy passed to registerPolicy can now set `free: true`, so
isFreeSubscription and everything that reads it count the plan as free.
It can also set `noAllowance: true`: the plan grants no monthly
allowance, so only purchased credit can be spent, and the cached
credit check refuses an actor that has none. A non-positive allowance
without the trait still means the plan is not metered.

getActorSubscriptionStatus returns the plan along with whether it was
paid for and whether the answer is provisional (a lookup behind it
failed, or the actor lacks a field resolvers key on), so callers can
tell a fallback from a resolved plan. getActorSubscription is unchanged.

* feat(metering): provisional resolver answers, and tighter no-allowance accounting

A subscription resolver can answer { id, provisional: true }. The answer
wins like any id but is cached only for the fallback window.

On a no-allowance plan, the month record now records allowanceUsed = 0,
so spend that purchased credit paid for is not counted again against an
allowance the actor gets later in the month. An actor that never had
budget raises no credit-state event. setMonthAllowance refuses plans
whose allowance an override never applies to.

* fix(metering): keep resolved plans clean when a default fails, and announce moves onto no budget

A failed or provisional default resolver now marks the answer provisional
only when the default is what applied; an explicit plan that won is
cached for the full window. A provisional answer with no id still marks
the result provisional.

A plan or credit change now re-arms an actor's credit alert instead of
forgetting it, so an actor moving onto a plan with no budget hears
'exhausted' once. An actor never seen with budget still hears nothing.

* chore(metering): let a provisional answer carry no id, and fix two resolver docs

* chore: one-line comments
2026-10-22 15:47:01 -04:00
Juan Fernando Castro 1627650abc refactor: make teams an optional feature (#4248)
* refactor: let core run without a teams registration

Teams becomes optional: `services.team` and `stores.team` may be absent, and
core stops assuming either is there.

Seventeen call sites across share, signup, account deletion and the homepage
now degrade instead of throwing: a team recipient that cannot be resolved is
"no such recipient" rather than a fault, a group share folds in nothing, and
account deletion stops asking what a seat was charged for. This generalises
the pattern `gates.ts` and `SocketService` already used.

Teams' own files take a private handle to their store and service, so the
feature's code stays free of optionality that only core needs.

No behaviour changes where Teams is registered.

* refactor: put a contract between core and teams

`core/teams.ts` holds the shapes core reads and the calls it makes, and the
registries declare the pair as provided rather than built in. The service,
store and controller are registered by whatever runs the feature.

Typing them properly turned up eight AuthController call sites the previous
commit could not see: the controller reads its stores from the built-in
registry, so an absent `team` fell to an index signature and typed as
`unknown` instead of erroring. They now go through the same optional path as
the rest.

The feature's own tests move with it. The cases in core suites that build a
team to exercise a core rule still need relocating, so those suites are red
here.

* test: stub core's seat rules, rehome the rest with the feature

A core suite can no longer build a team, so the rules that refuse a seat get
`testFixtures/seatStub.ts` instead: it answers the contract, which is all
those rules read, and puts the registry back when the test ends.

Seventeen cases move that way. Four could not: group shares and a seat's 2FA
enrolment over HTTP need the real feature and the `/teams` route, so they go
with it, as does the whole of `GroupGrants.test.ts`.

Two whoami cases asserted a seat's team through `extension.import`, which
resolves the registry rather than the stores object. They now assert the
absent case, and the behaviour they covered is asserted against
`buildUserDetails`, which takes its stores as an argument.

Suite: 12252 passed, 38 skipped.

* refactor: take the teams console out of the built-in tabs

`TabTeams`, `teamsConsole` and `teamActionIcons` are no longer built in. The
dashboard already invites a tab through its `dashboard-will-open` event, so
whatever provides Teams contributes the console the same way.

Its 113 strings go with it and register into the shipped English dictionary
at load, so `i18n('teams_*')` resolves as it did. Only `en.js` ever carried
them, so no translation is lost.

Three modules stay, because things other than the console use them:
`teamBadge` (the dashboard shell), `orgSeat` (Home, Account, Security) and
`shareTeams` (the share picker). They already render nothing when the
account has no team. `teams_account_of` stays with `teamBadge` for the same
reason, and the `share_*` and `plan_team_*` strings stay with their callers.

The GUI builds clean with no console at all, and builds it in when the tab
is contributed.

* docs: seats past the owner's current limit get no allowance
2026-10-22 15:32:39 -04:00
Daniel Salazar 4968a69d45 fix(s3): keep credential-chain credentials an hour from expiry when signing (#4299)
Presigned URLs die with the credentials that signed them, and the SDK only refreshes temporary credentials five minutes before expiry, so a URL signed late in a credential's life could expire long before its requested lifetime (up to an hour). The chain provider now reports expiry an hour early and force-refreshes credentials that are already inside that window.
2026-10-22 00:47:48 -07:00
Juan Fernando Castro aa47dcd3e5 feat(workers): internal teardown that takes the script down before the row, and destroy reports Cloudflare refusals (PUT-2219) (#4297) 2026-10-21 18:50:54 -07:00
Daniel Salazar c8424f6775 fix(fetch): raise HTTP/2 receive windows for plain fetch (#4296)
Node 26's bundled undici negotiates HTTP/2 by default with a 256 KB
per-stream receive window, which capped large downloads at about
window / RTT (8.6 MB/s vs 46 MB/s over HTTP/1.1 at 23 ms). The guard swaps
in an Agent of the same class with 4 MB stream / 16 MB connection windows.
2026-10-21 13:54:31 -07:00
Daniel Salazar b3c85c86e9 feat(puter.js): open the upgrade page with what was refused (#4294)
The web upgrade dialog's button opens the dashboard with the refusal
reason, the app the call ran as, and the refused method as query params,
so the dashboard can open its upgrade flow with that context.
2026-10-21 13:09:02 -07:00
Daniel Salazar d22d419c4d chore: move to Node 26 (#4295)
* chore: move to Node 26

Docker image and CI run on Node 26 (bookworm-slim, same Debian base as before).

Node 26's bundled undici reads the global dispatcher from the
`undici.globalDispatcher.2` slot, so the guard's 10-minute timeout wrap on
`.1` no longer reached plain fetch() and provider calls fell back to the
5-minute headers timeout. The guard now wraps every present slot and tracks
wrapped dispatchers in a set, since compose() can return a Proxy that writes
a marker property through to the original dispatcher.

* test(puter-js): disable Node's web storage in test workers

Node 26 defines its own localStorage global, undefined without a storage
file, and vitest's jsdom environment doesn't replace globals that already
exist, so jsdom tests saw localStorage as undefined.
2026-10-21 13:04:23 -07:00
Daniel Salazar 0760530d0c feat(gui): edit display name and bio from the Account tab (#4293) 2026-10-21 12:57:15 -07:00
Juan Fernando CastroandDaniel Salazar e18a5c2b54 fix(apps): refuse claimed subdomains as app index_url hosts (PUT-2110) (#4267)
index_url may not point at a system host: the origin, the api host, or a
subdomain an extension has claimed. App create and update now apply the
same refusal to `<claimed>.<domain>` (any case, any port) that they apply
to the origin and api hosts.

The host-to-claim match is a new exported `subdomainLabelOf` in
hostRedirects, also used by the claim dispatch middleware, so both
decide the same way.

Co-authored-by: Daniel Salazar <[email protected]>
2026-10-21 13:19:31 -04:00
Kailasnath TandDaniel Salazar 75385501a7 fix(fs): support offset and byte_count in read operations (#4266)
* fix(fs): support offset and byte_count in read operations

Allow partial content reads via offset and byte_count parameters in puter.fs.read and backend FS controllers.

- In puter.js read operation, check options.offset and options.byte_count against null/undefined so offset: 0 is preserved in query params.
- In handle_resp, accept any 2xx status code so 206 Partial Content responses resolve successfully.
- In backend FS controllers (LegacyFSController and FSController), derive the HTTP byte range from query offset/byte_count parameters when an explicit Range header is not present.
- Add unit and integration tests across controllers and SDK test suites.

* fix(fs): strict range params, 416 past EOF, drop format churn

- resolveReadRange accepts only decimal digit strings and safe integers
- readContent maps S3 InvalidRange to 416 instead of a 500
- revert unrelated union-type reformatting, restore truncated test comment

---------

Co-authored-by: Daniel Salazar <[email protected]>
2026-10-20 21:20:51 -07:00
Daniel Salazar 5b0205f2bf fix(hosting): apps publish with write again (#4291)
Reverts the hosting part of #4290. manage stays a user-to-user grant;
an app with write on its user's directory can publish and keep serving it.
2026-10-20 17:36:36 -07:00
Daniel Salazar 3bce3b056e misc: hardening (#4290) 2026-10-20 17:12:13 -07:00
dependabot[bot] f70240784f chore(deps): bump follow-redirects from 1.16.0 to 1.16.1 (#4287)
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.16.0 to 1.16.1.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.16.0...v1.16.1)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-version: 1.16.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-20 16:53:12 -07:00
dependabot[bot] 356e7b2b94 chore(deps): bump follow-redirects in /src/puter-js (#4288)
Bumps [follow-redirects](https://github.com/follow-redirects/follow-redirects) from 1.16.0 to 1.16.1.
- [Release notes](https://github.com/follow-redirects/follow-redirects/releases)
- [Commits](https://github.com/follow-redirects/follow-redirects/compare/v1.16.0...v1.16.1)

---
updated-dependencies:
- dependency-name: follow-redirects
  dependency-version: 1.16.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-20 16:29:27 -07:00
jelveh f02b4a6b73 feat: tell apps when a launched item is a folder; Open with AI above Open With
A folder opened with an app (Open With, Open with AI) reached puter.js as
a file: the GUI never forwarded the signature's fsentry_is_dir, so the
launched FSItem always had isDirectory false. The GUI now appends
puter.item.is_dir, and puter.js reads it in onLaunchedWithItems and the
onItemsOpened launch replay. Both now share one URL-to-FSItem builder.

"Open with AI" moves directly under Open, above Open With, in the
desktop and dashboard file menus.
2026-10-20 16:16:20 -07:00
dependabot[bot] 0768978111 chore(deps-dev): bump vitest from 4.1.11 to 4.1.12 (#4275)
Bumps [vitest](https://github.com/vitest-dev/vitest/tree/HEAD/packages/vitest) from 4.1.11 to 4.1.12.
- [Release notes](https://github.com/vitest-dev/vitest/releases)
- [Changelog](https://github.com/vitest-dev/vitest/blob/main/docs/releases.md)
- [Commits](https://github.com/vitest-dev/vitest/commits/v4.1.12/packages/vitest)

---
updated-dependencies:
- dependency-name: vitest
  dependency-version: 4.1.12
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-20 15:47:43 -07:00
Nariman Jelveh 66093e78f3 feat(gui): "Open with AI" in the dashboard Files menu, opening like Open With (#4289)
"Open with AI" now launches the `ai` app with the item the same way
Open With launches any app: launch_app with file_path/file_uid, so the
app gets a signed launch item via puter.ui.onLaunchedWithItems. It
replaces the custom `ai:openFsEntries` postMessage, which went to an
existing AI window.

- The helper moved from UIItem into helpers/openWithAI.js, shared by the
  desktop item menu and the dashboard Files menu (generate_file_context_menu).
- A shortcut opens its target.
- Dropped from multi-select menus, since a launch opens a single item.
- Renamed open_in_ai -> open_with_ai ("Open with AI") and removed the
  unused ai_app_unavailable string.
2026-10-20 15:33:12 -07:00
dependabot[bot] ed531e3992 chore(deps): bump undici from 7.30.0 to 7.30.1 (#4280)
Bumps [undici](https://github.com/nodejs/undici) from 7.30.0 to 7.30.1.
- [Release notes](https://github.com/nodejs/undici/releases)
- [Commits](https://github.com/nodejs/undici/compare/v7.30.0...v7.30.1)

---
updated-dependencies:
- dependency-name: undici
  dependency-version: 7.30.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-20 15:27:17 -07:00
dependabot[bot] 53e27f3d41 chore(deps): bump mysql2 from 3.24.5 to 3.24.6 (#4279)
Bumps [mysql2](https://github.com/sidorares/node-mysql2) from 3.24.5 to 3.24.6.
- [Release notes](https://github.com/sidorares/node-mysql2/releases)
- [Changelog](https://github.com/sidorares/node-mysql2/blob/master/Changelog.md)
- [Commits](https://github.com/sidorares/node-mysql2/compare/v3.24.5...v3.24.6)

---
updated-dependencies:
- dependency-name: mysql2
  dependency-version: 3.24.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-20 15:25:23 -07:00
dependabot[bot] deac121675 chore(deps-dev): bump eslint-plugin-prettier from 5.5.6 to 5.5.7 (#4278)
Bumps [eslint-plugin-prettier](https://github.com/prettier/eslint-plugin-prettier) from 5.5.6 to 5.5.7.
- [Release notes](https://github.com/prettier/eslint-plugin-prettier/releases)
- [Changelog](https://github.com/prettier/eslint-plugin-prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/eslint-plugin-prettier/compare/v5.5.6...v5.5.7)

---
updated-dependencies:
- dependency-name: eslint-plugin-prettier
  dependency-version: 5.5.7
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-20 15:22:09 -07:00
Nariman Jelveh 53e6e3179b refactor(gui): remove the AI side panel and its desktop button (#4286)
The desktop no longer launches the `ai` app at boot. This removes the
`is_panel` window mode, which existed only for that launch, and the
toolbar/taskbar/window/popover offsets tied to it. It also removes the
desktop sparkles button that showed the panel, and the
`silent_on_failure` launch option, whose only caller was the boot launch.
"Open in AI" in the item context menu still launches the app as a
normal window.
2026-10-20 15:21:21 -07:00
dependabot[bot] 73624bf1d0 chore(deps): bump smtp-server from 3.19.21 to 3.19.23 (#4276)
Bumps [smtp-server](https://github.com/nodemailer/smtp-server) from 3.19.21 to 3.19.23.
- [Release notes](https://github.com/nodemailer/smtp-server/releases)
- [Changelog](https://github.com/nodemailer/smtp-server/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodemailer/smtp-server/compare/v3.19.21...v3.19.23)

---
updated-dependencies:
- dependency-name: smtp-server
  dependency-version: 3.19.23
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-20 15:19:00 -07:00
Nariman Jelveh 1a9e43f5e5 fix(dashboard): adversarial review — bugs, a11y, perf, i18n (#4285)
* fix(dashboard): size the Notifications sidebar row like its neighbors

The bell button's font: inherit overrode the sidebar item's font-size
(16px instead of 14px, 15px in the drawer), and its icon wrapper stayed
18px while the drawer grows nav icons to 20px.

* test(dashboard): scope the directory-note assertion to the directory row

The 2FA row reuses .teams-directory-note, so the unscoped selector read
both notes and the test failed.

* fix(dashboard): only treat a picked file as a new avatar while the avatar picker is open

The Account tab listened for every file_opened on the dashboard window,
so choosing an image for a web link's Change Icon in the Files tab opened
the profile-picture crop modal, and saving it replaced the avatar.

* fix(dashboard): let dialogs over the Files tab keep their keyboard

The Files keydown handler ran under an open confirm, so pressing Enter on
the focused Delete button of the permanent-delete prompt opened the
trashed item instead (stacking a second alert), and arrows or Delete
acted on the rows behind the dialog.

* fix(dashboard): keep Apps grid keys away from an open context menu

Choosing a tile's menu item with Enter (e.g. Open in new tab) also
launched the focused app in place, and arrows moved tile focus while
they walked the menu.

* fix(dashboard): Escape on a menu or modal inside an app folder keeps the folder open

The menu's and the uninstall modal's Escape handlers removed them before
the folder's listener ran, so it saw nothing above it and closed the
folder with the same keystroke.

* fix(dashboard): a tap on the drawer or sheet scrim only dismisses it

Outside-press handlers closed the mobile drawer and the notifications
sheet on touchstart/pointerdown, which took away the scrim's hit area
before the click, so the tap also landed on the app tile or file row
underneath.

* fix(dashboard): let the sidebar nav scroll on short viewports

The nav clipped its overflow, so in a landscape phone's drawer (or a
short desktop window) Usage, Account, and Security fell below the fold
with no way to reach them.

* fix(dashboard): keep the mobile drawer full width when the desktop sidebar is collapsed

The remembered collapse outranked the drawer's width rule, so below the
drawer breakpoint it opened as an unlabeled 50px icon strip, and the
toggle that would undo it is hidden there.

* perf(dashboard): stop refetching account data each time focus leaves an app

Home's window focus listener fired whenever focus came back from an app's
iframe, so every click from an app to the dashboard re-pulled whoami,
subscription, storage, and usage (5 requests), on any tab. It now only
refreshes after the page itself lost focus to another window.

* fix(dashboard): show a seat's team name unescaped in the plan card

i18n escapes its arguments for HTML, but the note is set with .text(), so
a team called "Bob's & Co" read "Managed by Bob&apos;s &amp; Co".

* fix(dashboard): stop double-escaping app names in uninstall alerts

UIAlert escapes its message, so an app titled "Tom & Jerry" read
"Tom &amp; Jerry" in the uninstall-failed and sessions-not-ended alerts.

* fix(dashboard): roll back an uninstall whose revoke failed

puter.perms.revokeApp resolves with error set instead of rejecting, so a
failed revoke still removed the tile, marked the app removed for later
sessions, and ended its sign-in, while the app kept its permissions and
the rollback and error alert never ran.

* fix(dashboard): read a team account's suspension when its name matches an Object member

The per-account state map was a plain object, so a username such as
constructor or toString was already "in" it, its audit entries were
skipped, and a suspended account showed as active with no Delete.

* fix(dashboard): update the profile card and sidebar name after a username change

Neither carried the .username class that the rename refreshes, so both
kept showing the old name until reload.

* fix(dashboard): ask how to restore a trashed item whose name is taken

Restore moved without overwrite or dedupe, so when the original location
already had an item of that name the move 409'd and nothing happened:
an uncaught error from the item menu, a console line from the others.
All three restore entry points now share one path that offers the
Replace / Keep Both prompt the paste flow uses, alerts on other errors,
and updates the footer and empty notice once the rows are gone.

* fix(dashboard): refresh a renamed row's name, icon, and position in the Files tab

The Files tab passed rename_file a callback it never accepted, so a
renamed row kept the desktop's 20-character truncation even in a wide
column, kept its old type icon, and stayed out of sort order. rename_file
now takes an optional onDone (carried through undo), which the Files tab
uses to fit the name to its column, refresh the icon and type, and move
the row into place. A cancelled rename no longer truncates the name
either.

* fix(dashboard): drop Publish and Properties from the Shared view's menu

The Shared view is a query, not a folder, so both acted on the virtual
shared path: Properties showed an unknown error and Publish opened a
dialog for a path that doesn't exist.

* fix(dashboard): close the Files image preview on Escape and when leaving the folder

Escape cleared the selection but left the Space preview open with
nothing selected, the preview stayed over a different folder after
navigating, and a preview whose URL arrived after it was dismissed or
replaced still opened.

* fix(dashboard): don't move Files history for a navigation that's dropped

renderDirectory ignores a navigation while another folder is loading,
but every caller had already pushed or stepped history, so after a click
during a load the listing and history disagreed and the next Back or
Forward seemed to do nothing. History now only moves when the render
will run; back/forward steps share one helper.

* fix(dashboard): reopening the current folder adds no Files history entry

Clicking the folder already on screen (e.g. Home in the sidebar) pushed
a duplicate entry, so the next Back reloaded the same folder.

* fix(dashboard): show installed apps when the recommended list fails to load

get-launch-apps sat in the same Promise.all as the installed apps and
its body was parsed without a status check, so a network error or a
proxy's HTML error page replaced the whole grid with "Failed to load
apps". A failed list now falls back to the last one that loaded.

* fix(dashboard): fit the app uninstall dialog on narrow phones

The card is mounted outside the dashboard's border-box rule, so its
320px min-width plus padding came to 370px and was clipped on 360px
screens; a long unbroken app title also overflowed it. Desktop sizing is
unchanged.

* fix(dashboard): keep a new team account's one-time password on screen until dismissed

The credential box lived only in the DOM inside the panel that every
refresh repaints, so revisiting the Teams tab or any later action wiped
a password that can't be retrieved again. It is now kept in state, for
the team it belongs to, and drawn with the panel.

* fix(dashboard): reload the Teams tab after a change made while it was loading

refresh() handed back any load already in flight, so an action or team
switch made during a revisit's background load awaited data read before
the change: a toggled setting snapped back, and a team switch could
paint one team's roster next to another's record. A change during a load
now queues one more load, the stale one skips its paint, and revisiting
the tab doesn't start a second load while init's is still running.

* fix(dashboard): send one re-enable request for a double click in Teams

Re-enable has no confirm dialog to absorb a second click, and nothing
held the action while it ran, so a double click enabled the account
twice (two audit entries). A suspend or re-enable already in flight for
an account now ignores further clicks.

* fix(dashboard): revert the avatar and say so when saving a new photo fails

The crop's Save showed the new picture everywhere and revealed Remove
without waiting for the upload, and update_profile swallows failures,
so a failed save looked saved until reload. The save is now awaited: a
failure puts the previous picture back with an error hint, and Remove
waits for the upload so it can't race it.

* fix(dashboard): hide the Usage tab's plan button for team seats

A seat's plan belongs to its team, so Home already hides its upgrade
link; the Usage tab still offered Upgrade, which only reaches a refusal.

* fix(dashboard): don't drop Home's forced usage refresh during a running load

loadUsageData handed any caller the load already in flight before looking
at force, so a subscription change (or a return from the billing portal)
during a routine load was answered with data read before the change, and
the plan card stayed stale until the next refresh. A forced call now runs
once more after the running load.

* fix(dashboard): activate Apps when the URL names an unknown tab

An unknown hash rendered Apps as the active tab but skipped its
activation, so the grid kept the generic content padding and Apps'
onActivate never ran.

* fix(dashboard): make the sidebar user menu reachable from the keyboard

The user button was a div with no tabindex, so Log Out, account
switching, and Contact Us were out of reach without a pointer. It is
now a button (rendering unchanged), with aria-haspopup.

* fix(dashboard): name the sidebar's collapse and drawer buttons for assistive tech

The collapse toggle held only two unlabeled images, so screen readers
announced an unnamed button; it now carries a translated label and
aria-expanded that follow its state. The drawer's open/close labels were
hardcoded English and now go through i18n.

* fix(dashboard): make Home's recent apps reachable from the keyboard

Each recent app was a click-only div, so keyboard users couldn't open
one. They are now buttons with a focus ring; the icon is decorative
since the title names the button.

* fix(dashboard): name the Security tab's 2FA switch for assistive tech

The checkbox sat in a text-less label, so screen readers announced an
unnamed checkbox. It now carries the translated Two Factor
Authentication label, as the Teams switches do.

* fix(dashboard): sort the Usage table from the keyboard

The sortable headers were click-only th cells. Each now holds a button,
carries aria-sort, and keeps focus across the re-render a sort causes.
The Resource/Units/Cost headers go through i18n.

* fix(dashboard): don't rebuild the Files listing under an open menu

Returning to the browser tab re-rendered the folder even with a context
menu or action sheet open, so its actions then targeted detached rows:
Rename did nothing, Restore faded a row that was already gone. The
refresh now waits while a menu is up, as it does for a rename.

* fix(dashboard): point the Files sidebar's Documents at the current home

The entry read window.documents_path, which globals.js sets once from
the cached user and nothing updates; login and username changes keep
window.docs_path current instead. After a username change, Documents
pointed at the old home and showed "This folder couldn't be opened",
even after reloading.

* fix(dashboard): let only the newest share listing render in the share dialog

Each action ends in a refresh, and refreshes from overlapping actions (or
the opening load) could finish out of order, so an older listing landed
last and undid what the user just did, e.g. the link control snapped
back to restricted after turning the link on. Older refreshes now drop
their result.

* perf(dashboard): load a team's record, roster, and plan in parallel

loadSelected awaited the audit log (every page of it), then the members,
then the plan, on every visit to the Teams tab. They don't depend on
each other, so they are now fetched together.

* fix(dashboard): keep keyboard focus on the Teams control that was used

Every Teams action repaints the whole panel, which dropped focus to
<body>: a keyboard user toggling a switch or suspending an account was
sent back to the top of the page. The repaint now refocuses the same
control, or the same account's new action when the old one is gone.

* fix(dashboard): give the app uninstall prompt dialog semantics and focus

The prompt had no dialog role or label and never took focus, so keyboard
and screen-reader users stayed on the inert grid behind it. It is now a
labelled modal dialog that focuses Cancel on open and returns focus to
the app's tile when dismissed.

* fix(dashboard): keep the add-an-app tile when there are no apps

With no apps at all, the empty state replaced the whole grid, add tile
included, so a new user on an instance with no recommended apps had
nothing to act on. The empty line now sits above a grid that keeps the
tile, as a search with no matches already does.

* fix(dashboard): don't rebuild the Apps grid under an active drag

An uninstall finishing (or a failed one rolling back) re-rendered the
grid even while another tile was being dragged, replacing the dragged
tile with a fresh copy: the copy showed under the ghost and the drop
was lost. Renders asked for mid-drag now wait for the one the drop does;
the folder eject, which rebuilds the grid as part of the drag, still can.

* fix(dashboard): return focus to the drawer toggle when the mobile drawer closes

Closing the drawer (Escape, the close button, a tab pick) left focus on
controls that had just become hidden, so it fell to <body>; closing the
notifications sheet opened from the drawer tried to refocus the bell
inside the closed drawer. Both now land on the drawer toggle.

* fix(dashboard): truncate Files names between graphemes, with a binary search

The middle truncation sliced UTF-16 code units, so at many widths a long
name with emoji showed a half surrogate (rendered as a replacement
character) or a broken joined emoji. It also measured one more character
per step, a measureText call per character of every long name on each
render and column resize. The cut now falls between graphemes and is
found by binary search; output for other names is unchanged.

* fix(dashboard): translate the Home tab's hardcoded English

The tab label, the recent-apps empty state, card subtitles, the plan
badge and trial wording, the upgrade/manage links, and the usage cards'
"Used" / "of" lines were English literals. They now go through
i18n, with placeholders where values are interpolated.

* fix(dashboard): translate the sidebar's remaining English

The Apps, Files, and Usage tab labels, the user button's fallback name,
and the user menu's Developers item were English literals; they now use
i18n keys like the other tabs.

* fix(dashboard): translate the Usage tab's hardcoded English

The subtitle, the "used of" meter lines (now the existing used_of
key), Usage Details, the plan button, Show more/less, and the details
error were English literals.

* fix(dashboard): translate the Account and Security tabs' hardcoded English

Both subtitles, the delete-account description, the sessions hint, the
username fallback, and the avatar picker's window title were English
literals.

* fix(dashboard): translate the Apps tab's hardcoded English

The uninstall prompt and its failure alert, empty and no-match notices,
pager and folder-dot labels, the search placeholder, the edit toggle,
and the tile menu's Open in New Tab / Quit / Uninstall were English
literals.

* fix(dashboard): translate the Files tab's hardcoded English

The folder sidebar, footer counts (now the existing item-count keys),
sort and view menus, upload status and errors, the empty and
can't-open notices, the reserved-name alerts, the move error, and the
loading text were English literals.

* fix(dashboard): translate the action sheet's Back row and match delete in any language

The touch action sheet styled an item red when its label contained the
English word "delete", so Delete lost its danger styling in every other
language; it now compares against the translated Delete labels. Its
submenu Back row and the properties panel's Directory/disassociate
labels were English literals.

* fix(dashboard): make the Files sidebar, crumbs, and nav arrows keyboard-operable

The folder sidebar, the breadcrumbs, and the back/forward/up arrows were
click-only li/span/img elements, out of the tab order. They now take
focus, carry button/link roles (the arrows with labels and
aria-disabled), answer Enter and Space, and show a focus ring. The rows'
Enter/Space shortcuts also no longer swallow those keys from a focused
control: with rows selected, Enter on Upload opened the selection.

* perf(dashboard): read grid name widths before writing any in the Files tab

fitItemNames read each tile's name width right after writing the
previous name, forcing a layout per row. Reading them all first, then
writing, cuts switching 400 files into grid view from about 300ms to
170ms; the rendered names are identical.
2026-10-20 13:44:42 -07:00
242a654166 feat(ai): add Kling for video, image and speech generation (#4274)
* feat(ai): add Kling for video, image and speech generation

One `kling` API key now registers a provider on three drivers:

- txt2vid: Kling 3.0 Turbo, 3.0, 3.0 Omni, O1, 2.6 and 2.5 Turbo,
  with first/last frames, reference images on the omni models, native
  audio, and editing a source video on 3.0 Omni and O1. Billed per
  second at the resolution, audio and video-input rate; an edit is held
  at the longest source and billed for the clip Kling returns.
- txt2img: Kling Image 3.0, 3.0 Omni, O1 and 2.1, billed per image.
- txt2speech: Kling TTS with its English and Chinese voices, billed per
  request.

Kling's business error codes map onto the driver error contract, so an
exhausted resource pack reports as credit exhaustion and a submit-time
content block as `moderation_flagged`. "Risk control" failures now
count as content-filter refusals.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(ai): treat Kling's unknown-model codes as a Puter-side failure

Kling answers 1202/1203 when the method or model isn't one it serves.
Puter picks both, so these meant catalog drift but surfaced as a 400 that
blamed the caller and raised no alarm. They now return 502 upstream_failed
with a warning alarm.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(ai): tighten Kling input checks and error mapping

- 3.0 Turbo takes at most 2,500 prompt characters with a first frame,
  the image-to-video endpoint's limit.
- O1 refuses reference images together with a first and last frame.
- 3.0 Omni accepts a source of up to 15.5 seconds, and an edit is held
  at 16 seconds to cover it.
- A source video Kling can't decode fails as 400 upstream_bad_request
  instead of a Puter-side 502.
- A 1201 about the model counts as catalog drift, like 1202/1203.
- Auth failures no longer pass Kling's console wording to the caller.

Adds integration tests for the image, video and TTS providers, gated
on PUTER_TEST_AI_KLING_API_KEY.

* feat(ai): wait up to 14 minutes for a video job

Video jobs, Kling edits in particular, can take over 10 minutes. The
poll window and the Gemini Omni deadline move to 14 minutes, under the
SDK's 15-minute idle timeout, with holds and signed source links
lengthened to match. The keep-alive default moves to 920 seconds so it
stays above a 15-minute proxy idle timeout.

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Daniel Salazar <[email protected]>
2026-10-20 12:32:19 -07:00
Daniel Salazar 41e4b582d3 fix(subdomains): emit subdomain.delete for sites deleted with an app (#4273)
Sites and workers tied to an app go with it by FK cascade, on app delete and
on deleting the app owner's account. Only caches were dropped for them, so
the subdomain.delete listeners (CDN purge, worker script teardown, worker
sessions, site cleanup) never ran.
2026-10-20 12:31:40 -07:00
Reynaldi Chernando 2a49689fad fix contradicting jsdocs and docs (#4282) 2026-10-20 18:39:49 +07:00
Juan Fernando CastroandDaniel Salazar b54b76715a feat(fs): finish a home rename that stopped partway (#4255)
* feat(fs): finish a home rename that stopped partway

FSEntryStore.rebaseUserSubtree runs renameUserHome's owner-scoped
cascade on its own (same subtree matching, same rebased-cache
invalidation) and also drops the home row's cached copies at either
path. countUserSubtree counts the owner's rows under a prefix with that
same matching, from the primary. renameUserHome now uses the shared
cascade.

* fix(telemetry): resolve the tracer per span so a test's provider is the one used

withSpan held a ProxyTracer built at module load, which caches the first
provider it delegates to. Test files sharing a worker then sent spans to a
provider an earlier file had already shut down.

* fix(fs): drop cached copies of only the rows a home rebase moves

The owner-scoped rewrite read its invalidation set from everything under the new prefix, which for a finishing rebase is the whole live home; it now reads the rows it is about to move.

---------

Co-authored-by: Daniel Salazar <[email protected]>
2026-10-20 00:59:40 -07:00
Juan Fernando Castro d978538c9d feat: let a subdomain claim keep the main pipeline (#4268)
`extension.claimSubdomain(name, { mainPipeline: true })` serves
`<name>.<domain>` the way core's own subdomains are served: through CORS,
body parsing, auth and every route, `subdomain: '*'` ones included. Routes
declared for the name are ordinary routes, with no option restrictions.
Gated claims (`{ authorize, checkRoute? }`) behave as before.

onlyoffice's subdomain now comes from its extension's claim, so core no
longer lists it among the subdomains it serves itself. Without that claim
`onlyoffice.<domain>` gets the unclaimed-subdomain 404 like any other name.
2026-10-20 00:43:54 -07:00
e83e27f306 feat(ai): edit a source video with txt2vid (Gemini Omni, FLUX 3) (#4272)
* feat(ai): edit a source video with txt2vid on Gemini Omni

txt2vid takes an `inputVideo` option (sent as `input_video`): a URL, data
URI or Puter file path to an MP4, QuickTime or WebM video of up to 32 MiB.
Gemini Omni receives it inline next to the prompt, which describes the
edit or extension. Google enforces the 10-second limit.

Models declare `videoInput` in the catalog, and the driver rejects a
source video for any other model instead of letting the provider drop it.
The credit hold adds 16,000 input tokens for a source video, since
countTokens reports far fewer tokens than the reply bills.

loadFileInput takes an optional `timeoutMs` for web URLs, and cancels
the body of a failed response.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* feat(ai): edit a source video with FLUX 3 on Together AI

FLUX 3 takes `input_video` as Together's `media.source_video`. A web URL
goes through for Together to fetch; a data URI or Puter file is read with
the caller's access, capped at 32 MiB, sniffed as a video and sent inline.
Editing is estimated and billed at the 1080p rate of $0.54/s, since FLUX 3
jobs report no cost. Image inputs can't be combined with a source video.

Seedance 2.0 ignores `source_video`, treats `reference_videos` as a loose
reference rather than an edit, and Seedance 2.5 fails on reference videos,
so neither declares `videoInput`.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* docs(ai): name FLUX 3 as a video editor in the AI overview

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(ai): match Hailuo 02 to Together's price and document estimate billing

Together quotes $0.56 for a Hailuo 02 clip; the catalog had $0.49.

Together's video jobs no longer report what they cost, so every Together
clip is billed at the catalog's highest published rate. The docs said the
charge was the amount Together reports; they now describe the estimate.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix: enforce video edit durations and propagate source fetch cancellation

* fix(ai): pass media inputs as Puter files and stream source videos

Source videos were read whole into the API process, base64-encoded and
sent twice to Gemini (countTokens and the interaction), which held about
128 MiB per in-flight edit for up to ten minutes.

- Video inputs are a URL, a Puter file path or a data URI of at most
  1 MiB. Together gets a Puter file as a 15-minute presigned S3 URL and
  fetches it itself; Gemini streams it into the request body with an
  exact Content-Length, so it's never held as one string.
- Gemini counts only the prompt and image for a source video, and both
  providers read the source only after the credit hold.
- txt2img and txt2vid image inputs accept Puter file paths, read within
  the caller's access right before the provider call. Existing URL,
  data URI and base64 inputs are unchanged.
- puter.js writes a Blob, File or data URI over 1 MiB to a temporary
  file under .tmp, sends its path, and deletes it once the call settles.
- FLUX 3 edits bill at the 720p editing rate, matching their 1280x704
  output.

* fix(ai): stage inputs after website sign-in, keep images working without a write

- puter.js chose the temporary file path before signing a website visitor
  in, so it fell outside the app's data directory and the upload was
  refused. It now signs in first, then picks the path.
- An image that can't be written (no access, storage full) is sent inline
  as before instead of failing the call. Videos still fail, since a
  large inline video isn't accepted.
- Telling raw JPEG base64 from a path decodes only the first 24
  characters instead of the whole input.

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: 404oops <[email protected]>
Co-authored-by: Daniel Salazar <[email protected]>
2026-10-19 23:33:03 -07:00
Udit AgarwalandDaniel Salazar 54f542ace4 fix(gui): refresh Start menu apps without blocking cached rendering (#4244)
* fix(gui): refresh Start menu apps without blocking cached rendering

* docs: remove desktop Start menu limits from SDK reference

* fix(gui): drop the 1 MiB cap on the launch-apps refresh

Recent entries carry each app's raw icon column, which can still hold a data URL of up to 5 MB, so a large icon tripped the cap on every request, including the one at startup, and left the Start menu with no apps. Replace the cap's test with one for the unlaunchable-app filter.

---------

Co-authored-by: Daniel Salazar <[email protected]>
2026-10-19 16:13:55 -07:00
496 changed files with 46908 additions and 21813 deletions

No files matched your search

@@ -22,7 +22,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v7
with:
node-version: '24'
node-version: '26'
cache: 'npm'
- name: Install dependencies
+3 -3
View File
@@ -57,7 +57,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v7
with:
node-version: '24'
node-version: '26'
cache: 'npm'
- name: Install dependencies
@@ -88,7 +88,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v7
with:
node-version: '24'
node-version: '26'
cache: 'npm'
- name: Install dependencies
@@ -154,7 +154,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v7
with:
node-version: '24'
node-version: '26'
cache: 'npm'
- name: Install dependencies
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v7
with:
node-version: '24'
node-version: '26'
cache: 'npm'
- name: Install dependencies
+3 -3
View File
@@ -57,7 +57,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v7
with:
node-version: '24'
node-version: '26'
cache: 'npm'
- name: Install dependencies
@@ -78,7 +78,7 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v7
with:
node-version: '24'
node-version: '26'
cache: 'npm'
- name: Install dependencies
@@ -135,7 +135,7 @@ jobs:
if: needs.changes.outputs.puterjs == 'true'
uses: actions/setup-node@v7
with:
node-version: '24'
node-version: '26'
cache: 'npm'
- name: Install dependencies
+3 -1
View File
@@ -184,10 +184,12 @@ Recipes are tested, copy-pasteable patterns for a task, one layer above the per-
- **Description.** Written for someone who has never heard of Puter. Start with "Learn how to", put the action first, and say what the reader's app gains: "Learn how to add file sharing to your app with Puter.js, so users can share files in their Puter account with other people." No API names, no mechanism, no list of example cases, and no "not just …" contrast.
- **Intro.** Explain the situation and why you'd do this, tied to something the reader already knows ("`puter.kv` is your database in Puter.js"). No "Use this when" bullet list.
- **Structure.** Get to the first working call as early as possible, then add validation, rate limits, and sign-in after it. Explain a Puter concept the first time it appears, such as what `me.puter` is or that `puter.workers.exec()` opens the Puter sign-in window. Split recipes by what the reader needs, not by technique, and give a family of recipes a shared tag.
- **Reading order.** A recipe reads top to bottom. Each code block uses only functions and variables from blocks above it, so the reader never has to search further down the page to understand it. Start with a small version that works, and have each section add one case. When a section changes a function from earlier, show the whole new version of that function there. If the code is built up over several sections, end with a `## Full Code` block that has all of it.
- **Link to the reference for full option lists.** Mention only the options the recipe uses, then link to the function page for the rest ("All the sort options are listed on [`puter.fs.readdir()`](/FS/readdir/)."). A copied list of options, rules, or scopes goes stale when the reference changes.
- **Limits.** Mention a limit only when it's the reason for a pattern the recipe uses, such as one wildcard subscription instead of one per key, and link to [rate-limits-and-quotas.md](src/docs/src/rate-limits-and-quotas.md) or the function page for the number. Leave out limits that code following the recipe never reaches.
- **Code.** Platform-neutral: just the code, with top-level `await`. No `<script>` or `<html>` wrapper, no IIFE.
- **Tradeoffs.** Justify a choice with one scenario the reader can picture right away, in about two short paragraphs, or fold each point into the section where it matters. The description states the benefit only.
- **Notes.** Leave out `## Notes` by default. Use it only to point to an architecturally different approach. A limit goes in the section it applies to; other facts belong on the linked reference page.
- **Notes.** Leave out `## Notes` by default. Use it only to point to an architecturally different approach. A limit the recipe needs goes in the section it applies to, and other facts belong on the linked reference page.
---
+2 -2
View File
@@ -14,7 +14,7 @@
# config.default.json, so partial overrides work. Absent file = defaults.
# ---- Build stage ----
FROM node:24-slim AS build
FROM node:26-bookworm-slim AS build
WORKDIR /opt/puter
@@ -59,7 +59,7 @@ RUN set -e; \
wait $pjs_pid
# ---- Runtime stage (slim — no build tools) ----
FROM node:24-slim
FROM node:26-bookworm-slim
WORKDIR /opt/puter
+14 -3
View File
@@ -34,7 +34,7 @@
// Idle keep-alive timeout in ms. Must stay above the idle timeout of any
// proxy in front, or the proxy reuses connections this server has closed
// and its clients see 502s.
"keep_alive_timeout": 620000,
"keep_alive_timeout": 920000,
"protocol": "http",
"domain": "puter.localhost",
// Fully-qualified externally-visible URL. Computed from protocol/domain/
@@ -215,7 +215,9 @@
// "bootstrapTables": true,
"path": "",
// Credentials. NOTE: snake_case here, unlike `s3.s3Config` below.
// For dynamodb-local, any non-empty values work.
// For dynamodb-local, any non-empty values work. On AWS,
// `"useCredentialChain": true` uses the default credential chain
// instead of keys.
"aws": {
"access_key": "",
"secret_key": "",
@@ -313,7 +315,8 @@
// The tables ship either way and sit inert; this gates whether `/teams`
// is registered at all, so with it off the paths do not exist rather than
// existing and refusing. It is also the backout: turning it off removes
// the feature without touching data.
// the feature without touching data. Teams ships as an extension; on,
// the server refuses to boot unless one registers it.
"teams_enabled": false,
//
// Live teams one user may own. Soft-deleted ones do not count, so
@@ -430,6 +433,8 @@
"mistral": { "apiKey": "" },
"xai": { "apiKey": "" },
"moonshot": { "apiKey": "" },
// MiniMax. One key powers chat and video generation (H3, Hailuo);
// video calls go to the same host, beside the chat API's `/v1`.
"minimax": {
"apiKey": "",
"apiBaseUrl": "https://api.minimax.io/v1"
@@ -450,6 +455,10 @@
"apiKey": "",
"apiBaseUrl": "https://ark.ap-southeast.bytepluses.com/api/v3"
},
// Kling AI. One API key (not the legacy AccessKey/SecretKey pair)
// powers video generation, image generation and TTS; `apiBaseUrl`
// defaults to https://api-singapore.klingai.com.
"kling": { "apiKey": "" },
"zai": { "apiKey": "" },
"alibaba": { "apiKey": "" },
"hoonify": {
@@ -488,6 +497,8 @@
"defaultVoiceId": "",
"speechToSpeechModelId": ""
},
// Both AWS providers also take `"useCredentialChain": true` in place
// of keys, like `dynamo.aws`.
"aws-polly": {
"access_key": "",
"secret_key": "",
+2
View File
@@ -45,6 +45,8 @@ Both are supported ways to define an API. **Prefer a controller when you need fi
- **A specific verified factor:** `requireVerified` (email, and only under `strict_email_verification_required`), `requirePhoneVerified`, `requireCardVerified`. Opt-in, and the factor must actually have been verified, unlike the default-on gate that turns away accounts still carrying a pending verification the abuse harness asked for. `requireAnyVerified: ['phone', 'card']` is the OR of the last two: any listed factor verified at any point passes, as does a paid plan when `card` is listed (a paying account already has a card on file). Otherwise only the factors the deployment can verify are asked for (an SMS provider configured, a card gate an extension reports on); with none verifiable the gate is inert rather than locking the route on a self-hosted install. A denial carries the first verifiable factor's code plus `factors` (the verifiable ones, in the route's order), so a client can lead with one flow and offer the other. `verifiedFactorGate.enabled: false` in config stops every declared gate at once, e.g. during an SMS-provider outage.
- **Metered spend:** an endpoint that spends metered resources on the caller's behalf (moving file content, object-store requests, anything else the account is billed for) declares `requireCredits: true`, which turns an account with no budget left away with a 402 before the handler runs. Endpoints that only describe or delete things deliberately don't: an account that has run out still has to see what it has, clear it, and reach its billing pages. Drivers have no route options, so they call `assertActorHasCredits` themselves ([src/backend/services/metering/enforcement.ts](../src/backend/services/metering/enforcement.ts)) — see `KVStoreDriver`, which does it once for every method.
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.
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.
+4 -1
View File
@@ -51,7 +51,10 @@ two pages can't be decoded into how fast the table grows; `openCursor` still
reads a plain cursor. What a cursor wraps is an implementation detail per
store:
- DynamoDB-backed lists wrap `LastEvaluatedKey`.
- DynamoDB-backed lists wrap `LastEvaluatedKey`. A kv listing of another
namespace (cross-app or through a share handle) resumes after the last entry
it returned instead, since the scan can stop on a private one, and its cursor
is sealed to that listing and padded to one length.
- SQL-backed lists wrap a keyset position — `(sortValue, id)` of the last
row — and the query seeks past it (`WHERE (col, id) > (?, ?) ORDER BY col,
id`). Any SQL list gaining a cursor must have a deterministic `ORDER BY`
+13 -25
View File
@@ -1,13 +1,7 @@
import type { Request, Response } from 'express';
import { installSeatStub } from '@heyputer/backend/testFixtures/seatStub';
import { v4 as uuidv4 } from 'uuid';
import {
afterAll,
beforeAll,
describe,
expect,
it,
vi,
} from 'vitest';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { makeActor, type Actor } from '../src/backend/core/actor.ts';
import { runWithContext } from '../src/backend/core/context.ts';
import { configContainer } from '../src/backend/exports.ts';
@@ -49,7 +43,6 @@ beforeAll(async () => {
create_shortcut: true,
payment_bypass: true,
},
teams_enabled: true,
} as never);
});
@@ -83,21 +76,20 @@ describe('whoami extension — handleWhoami', () => {
};
const seatOf = async (teamName: string) => {
const owner = await seedUser();
const seat = await seedSeat();
const team = await server.stores.team.create({
ownerUserId: owner.id as number,
const team = {
uid: `wt-${Math.random().toString(36).slice(2, 9)}`,
name: teamName,
handle: `wt-${Math.random().toString(36).slice(2, 9)}`,
});
await server.stores.team.addMember(team.uid, seat.id as number, {
orgOwned: true,
};
installSeatStub(server as never, [seat.id as number], {
teamUid: team.uid,
teamName: team.name,
});
return { seat, team };
};
it('names the team for a seat', async () => {
const { seat, team } = await seatOf('Acme Corp');
it('names no team where teams is off', async () => {
const { seat } = await seatOf('Acme Corp');
const { res, captured } = makeRes();
await runWithContext(
@@ -105,10 +97,8 @@ describe('whoami extension — handleWhoami', () => {
() => handleWhoami(makeReq(), res),
);
expect((captured.body as { team?: unknown }).team).toEqual({
uid: team.uid,
name: 'Acme Corp',
});
// Off, so the seat is never looked up.
expect((captured.body as { team?: unknown }).team).toBeUndefined();
});
it('says nothing for an account that is not a seat', async () => {
@@ -526,9 +516,7 @@ describe('whoami extension — handleWhoami', () => {
}),
]) {
const { res, captured } = makeRes();
await runWithContext({ actor }, () =>
handleWhoami(makeReq(), res),
);
await runWithContext({ actor }, () => handleWhoami(makeReq(), res));
const body = captured.body as Record<string, unknown>;
expect(body.phone).toBeUndefined();
+92 -80
View File
@@ -1503,6 +1503,17 @@
"node": ">=10.0.0"
}
},
"node_modules/@dual-bundle/import-meta-resolve": {
"version": "4.2.1",
"resolved": "https://registry.npmjs.org/@dual-bundle/import-meta-resolve/-/import-meta-resolve-4.2.1.tgz",
"integrity": "sha512-id+7YRUgoUX6CgV0DtuhirQWodeeA7Lf4i2x71JS/vtA5pRb/hIGWlw+G6MeXvsM+MXrz0VAydTGElX1rAfgPg==",
"dev": true,
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/JounQin"
}
},
"node_modules/@emnapi/core": {
"version": "1.11.1",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz",
@@ -6526,14 +6537,14 @@
}
},
"node_modules/@vitest/coverage-v8": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.11.tgz",
"integrity": "sha512-8MVGEFnJIcdGjcbfKmeq8z0pZHH0JlVtoVZH9Q/qwUp6wyFnEJUBMrw9DCaj+ra3vShGmhavjalMIhPNxZAUcw==",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.12.tgz",
"integrity": "sha512-6S7/iVknKglLAxMFxQGNppBI0iqJS2NgM6FBxavitj7KaDq/vywOWGf6dGu7F2L1Qy0G6S3uTp7aGshHrzK8JA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@bcoe/v8-coverage": "^1.0.2",
"@vitest/utils": "4.1.11",
"@vitest/utils": "4.1.12",
"ast-v8-to-istanbul": "^1.0.0",
"istanbul-lib-coverage": "^3.2.2",
"istanbul-lib-report": "^3.0.1",
@@ -6547,8 +6558,8 @@
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@vitest/browser": "4.1.11",
"vitest": "4.1.11"
"@vitest/browser": "4.1.12",
"vitest": "4.1.12"
},
"peerDependenciesMeta": {
"@vitest/browser": {
@@ -6557,16 +6568,16 @@
}
},
"node_modules/@vitest/expect": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.11.tgz",
"integrity": "sha512-VX2x5vNJXET47KAFzwERI+KRMtTTCSWTfSMKsW7JsUsXV4psq++e3DvZpuTDOpHcxytiDs6p2nhVb2tVDiiUYw==",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.12.tgz",
"integrity": "sha512-vFlTt+YEu9WBpNjdGayNmlMVzrWPi1+sv853/7t2FU7hpO5gAFBKHzQmkCq5Xjpr+evegF2994LQT3hF3mRlqQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@standard-schema/spec": "^1.1.0",
"@types/chai": "^5.2.2",
"@vitest/spy": "4.1.11",
"@vitest/utils": "4.1.11",
"@vitest/spy": "4.1.12",
"@vitest/utils": "4.1.12",
"chai": "^6.2.2",
"tinyrainbow": "^3.1.0"
},
@@ -6575,13 +6586,13 @@
}
},
"node_modules/@vitest/mocker": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.11.tgz",
"integrity": "sha512-2XJVD55d1o5AZous5CCGKS74g/riOj9odEt2bQpCVZeblHyHdnMeFl4jl0XjU21stf4mbjUkew2eXQZt65g5CQ==",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.12.tgz",
"integrity": "sha512-fKShNpKZzupWZuY0z7egydvL0sqW4INEZBEExMBMAa6izlENEVHwoXgdaTCaBZ5fwlr4xf2o1wXC83n9RpO+NA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/spy": "4.1.11",
"@vitest/spy": "4.1.12",
"estree-walker": "^3.0.3",
"magic-string": "^0.30.21"
},
@@ -6602,9 +6613,9 @@
}
},
"node_modules/@vitest/pretty-format": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.11.tgz",
"integrity": "sha512-yiZzPbGTS9Sr/JpFl8zHrcIkAofNbFV6k21vIgQN/cY/oxZeXhJv5sc/MBJ5jFKWmWs+oJHw0UXLZjmf931+Vw==",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.12.tgz",
"integrity": "sha512-XW9O8+Mxx4VpCtkYdMJLZQQwsVryP9wk7cPKDxtXFfwSak6yRh5+2fep/Y0XF/ElBvJHq8bcYdi6aeuai0opZA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -6615,13 +6626,13 @@
}
},
"node_modules/@vitest/runner": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.11.tgz",
"integrity": "sha512-LztvUgdwMNJMIkj3hQnnxiC2Xy1zNxq928W/xhjCLaNCzqTZOudjwbQf6v9IntZGPw132i2Lq2rgTRZHD3JHNw==",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.12.tgz",
"integrity": "sha512-NKlVKCr5QnCe8jijdMnmkpHHU+WTqiLFR6G0pHji9uRjXK9xJl1k1nTw6F3pOOj2PDcsDt2GKQMI7wIsaT491w==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/utils": "4.1.11",
"@vitest/utils": "4.1.12",
"pathe": "^2.0.3"
},
"funding": {
@@ -6629,14 +6640,14 @@
}
},
"node_modules/@vitest/snapshot": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.11.tgz",
"integrity": "sha512-pN7ikn1ON7h8ee4gIAp4AzyK+zBtJPzVbqOgu5LCEh4VaJVbPQcgYQYJIMGQPXVeJJq1fnfazis7a5pFNPahog==",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.12.tgz",
"integrity": "sha512-ko1y1Qgb/slzQ9mr+b89Cuqud8WG1TKtsPD8r7ZUBdvVOGt85Af2HwCqHujrhwJq95hs/9RM7fcAPg57o1vlzw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.11",
"@vitest/utils": "4.1.11",
"@vitest/pretty-format": "4.1.12",
"@vitest/utils": "4.1.12",
"magic-string": "^0.30.21",
"pathe": "^2.0.3"
},
@@ -6645,9 +6656,9 @@
}
},
"node_modules/@vitest/spy": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.11.tgz",
"integrity": "sha512-apNa/prQy2qCeywhnixOHPRCgGNhvg7T4Dapfl1GahLp/R+uhBm5cPyFoNVyqsNd2h1nJxL6BqqdIjiABL60YA==",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.12.tgz",
"integrity": "sha512-RGq9x4JNGNTZHEJxvN0oSiJjeSCExm0n2gZKKJZHY9cX5AVA6M7VJILBT3ZJ/tJ/0J9uWT7fETV9/btoi//vSg==",
"dev": true,
"license": "MIT",
"funding": {
@@ -6655,13 +6666,13 @@
}
},
"node_modules/@vitest/utils": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.11.tgz",
"integrity": "sha512-zTCVGpyFsGWBhllOyKlTw/vnr6D9qxsfSDyfbyZmTyjHw5N/VuvzHpHoQjm2ZJzn4RJgx5w4r7V0er69CmLgPQ==",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.12.tgz",
"integrity": "sha512-Q3awXrwOfEV+qc3WmO5ONyE0yO7Q1gQ9/oyHxGLZ/pl7N3NO/ZztqQwKLuwMkV+CA1JYSIeO3gkHnRDIo4AVaA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/pretty-format": "4.1.11",
"@vitest/pretty-format": "4.1.12",
"convert-source-map": "^2.0.0",
"tinyrainbow": "^3.1.0"
},
@@ -7768,9 +7779,9 @@
"license": "CC-BY-4.0"
},
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz",
"integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==",
"dev": true,
"license": "MIT",
"engines": {
@@ -9125,14 +9136,14 @@
}
},
"node_modules/eslint-plugin-prettier": {
"version": "5.5.6",
"resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.6.tgz",
"integrity": "sha512-ifetmTcxWfz+4qRW3pH/ujdTq2jQIj59AxJMIN26K5avYgU8dxycUETQonWiW+wPrYXA0j3Try0l1CnwVQtDqQ==",
"version": "5.5.7",
"resolved": "https://registry.npmjs.org/eslint-plugin-prettier/-/eslint-plugin-prettier-5.5.7.tgz",
"integrity": "sha512-WjHyn3GrFd+ZR0rzsLiM4zJZ35PZMOE0SUIxzGmnWNG0126yNfJKJzObmmkYDwHJfGlkz3PSlzyG9ez/LJntwg==",
"dev": true,
"license": "MIT",
"dependencies": {
"prettier-linter-helpers": "^1.0.1",
"synckit": "^0.11.13"
"synckit": "^0.12.3"
},
"engines": {
"node": "^14.18.0 || >=16.0.0"
@@ -9881,9 +9892,9 @@
"license": "ISC"
},
"node_modules/follow-redirects": {
"version": "1.16.0",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz",
"integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==",
"version": "1.16.1",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.1.tgz",
"integrity": "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==",
"funding": [
{
"type": "individual",
@@ -13363,9 +13374,9 @@
"license": "MIT"
},
"node_modules/mysql2": {
"version": "3.24.5",
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.24.5.tgz",
"integrity": "sha512-X6Ujsr2QSkkLpkQGjxzpKRAPn9nu4axpR63ntBzquFVEvPOArgbUQ1sJjFKI7hnaYtiVZxa17Z7q18KSubW0IQ==",
"version": "3.24.6",
"resolved": "https://registry.npmjs.org/mysql2/-/mysql2-3.24.6.tgz",
"integrity": "sha512-enu73KhbAua2L3dIxjc6qg6ZJ7H3pt1BImZASWNWY5xSsmJ1ZuUPULTDGXgmJbI61vb+nJAG19IZclIq19UidQ==",
"license": "MIT",
"dependencies": {
"aws-ssl-profiles": "^1.1.2",
@@ -15578,13 +15589,13 @@
"license": "MIT"
},
"node_modules/smtp-server": {
"version": "3.19.21",
"resolved": "https://registry.npmjs.org/smtp-server/-/smtp-server-3.19.21.tgz",
"integrity": "sha512-d0wWijB5dK4gImQidwWMlcGsycqcDCJV0VdQbfz61rgkDqnzAx0Epvi0o5JN2nnr/YwriVC+XV0T8YgcoB0bng==",
"version": "3.19.23",
"resolved": "https://registry.npmjs.org/smtp-server/-/smtp-server-3.19.23.tgz",
"integrity": "sha512-t6AOBe7RR9Y/9Kox4e5SIm7wS2ZA8P0VuhVEZ5Yiox0WyevX+rMjATzIwEX3A+B6SGwnJ8QvoHtJAhbJYbjy3A==",
"license": "MIT-0",
"dependencies": {
"ipv6-normalize": "1.0.1",
"nodemailer": "10.1.0",
"nodemailer": "10.1.2",
"punycode.js": "2.3.1"
},
"engines": {
@@ -15592,9 +15603,9 @@
}
},
"node_modules/smtp-server/node_modules/nodemailer": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.1.0.tgz",
"integrity": "sha512-fXW8AulB25aOTuwfRYV6lqHyfz2VM4rr4SGXH03mE7x5znEUpzL5mu6anARUpZN7744Ziwkmr4AxSLPJsBQspg==",
"version": "10.1.2",
"resolved": "https://registry.npmjs.org/nodemailer/-/nodemailer-10.1.2.tgz",
"integrity": "sha512-rnQn9Rzh8RAwcp42MVsIvxbsv6YZ2a2/av+uyJ/LPU1+2QSlrRgFsWWZTKenyRUqkIl8urOI0c4+CEcmFdsvQw==",
"license": "MIT-0",
"engines": {
"node": ">=20.0.0"
@@ -16003,12 +16014,13 @@
"license": "MIT"
},
"node_modules/synckit": {
"version": "0.11.13",
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.11.13.tgz",
"integrity": "sha512-eNRKgb3z66Yp3D2CixVujOUvXLFUTij/zVnV8KRyvFdQwpz7I5DS8UfRkTeLzb64u+dkzDSdelE24izu+zSSUg==",
"version": "0.12.3",
"resolved": "https://registry.npmjs.org/synckit/-/synckit-0.12.3.tgz",
"integrity": "sha512-U+/AXT6LWsxnj2vz9HeUuPWBNgdWxjyIEjEPdbJARC9txmgPzo2VEdqnFo4hHaNyMPtycyVJrSaQYeQv/PoyyA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@dual-bundle/import-meta-resolve": "^4.2.1",
"@pkgr/core": "^0.3.6"
},
"engines": {
@@ -16607,9 +16619,9 @@
"license": "MIT"
},
"node_modules/undici": {
"version": "7.30.0",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.30.0.tgz",
"integrity": "sha512-dkrQXeHSaoamnItlYbmzG0wFYrM0ZwDxCIg0A7aKjTyyhh9svRzCNFEzV+Vm05/yehjCzjDZ31KXfGEjYSztDQ==",
"version": "7.30.1",
"resolved": "https://registry.npmjs.org/undici/-/undici-7.30.1.tgz",
"integrity": "sha512-03KrMbjIFDWQR5RNPrSw3bd1r12JpyFQl9/yxjUbJtqkjmllqC5lFjZh6ujeuJCKr8EOQvRW872Oy/az+nLYxg==",
"license": "MIT",
"engines": {
"node": ">=20.18.1"
@@ -16853,19 +16865,19 @@
}
},
"node_modules/vitest": {
"version": "4.1.11",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.11.tgz",
"integrity": "sha512-fhACrNXUidIbGSBr5FlbuBkO7VWC1ZyLl0DO4CU2DrQoAPxX84Ysxs+HeGQpii5lZWV1Q4gBZTTu49mF+A6Edw==",
"version": "4.1.12",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.12.tgz",
"integrity": "sha512-5eWdz2yb3ajxWF3C8C4TjkXl7FGm3n2E/VlazALrQHzK7nYMSGFnCZto0bbPNl77AwkOZVLk0YnBAejUu0NiWA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@vitest/expect": "4.1.11",
"@vitest/mocker": "4.1.11",
"@vitest/pretty-format": "4.1.11",
"@vitest/runner": "4.1.11",
"@vitest/snapshot": "4.1.11",
"@vitest/spy": "4.1.11",
"@vitest/utils": "4.1.11",
"@vitest/expect": "4.1.12",
"@vitest/mocker": "4.1.12",
"@vitest/pretty-format": "4.1.12",
"@vitest/runner": "4.1.12",
"@vitest/snapshot": "4.1.12",
"@vitest/spy": "4.1.12",
"@vitest/utils": "4.1.12",
"es-module-lexer": "^2.0.0",
"expect-type": "^1.3.0",
"magic-string": "^0.30.21",
@@ -16893,12 +16905,12 @@
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "4.1.11",
"@vitest/browser-preview": "4.1.11",
"@vitest/browser-webdriverio": "4.1.11",
"@vitest/coverage-istanbul": "4.1.11",
"@vitest/coverage-v8": "4.1.11",
"@vitest/ui": "4.1.11",
"@vitest/browser-playwright": "4.1.12",
"@vitest/browser-preview": "4.1.12",
"@vitest/browser-webdriverio": "^4.1.11",
"@vitest/coverage-istanbul": "4.1.12",
"@vitest/coverage-v8": "4.1.12",
"@vitest/ui": "4.1.12",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
@@ -17704,7 +17716,7 @@
"lorem-ipsum": "^2.0.8",
"mime-types": "^2.1.35",
"murmurhash": "^2.0.1",
"mysql2": "^3.24.5",
"mysql2": "^3.24.6",
"nodemailer": "^9.1.1",
"openai": "^6.34.0",
"otpauth": "^9.5.2",
@@ -17712,12 +17724,12 @@
"postal-mime": "^3.0.1",
"replicate": "^1.0.0",
"sharp": "^0.35.5",
"smtp-server": "^3.19.21",
"smtp-server": "^3.19.23",
"socket.io": "^4.8.4",
"svg-captcha": "^1.4.0",
"together-ai": "^0.33.0",
"ua-parser-js": "^1.0.41",
"undici": "^7.29.1",
"undici": "^7.30.1",
"uuid": "^14.0.3",
"validator": "^13.15.35"
},
@@ -19087,7 +19099,7 @@
},
"src/puter-js": {
"name": "@heyputer/puter.js",
"version": "2.6.4",
"version": "2.6.5",
"license": "Apache-2.0",
"dependencies": {
"@heyputer/kv.js": "^0.2.1",
+135
View File
@@ -0,0 +1,135 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { v4 as uuidv4 } from 'uuid';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import type { PuterServer } from './server.ts';
import { createTestUser, setupTestServer } from './testUtil.ts';
/**
* Teams is optional, so core has to work when neither the service nor the store
* is registered, which is how this test server boots.
*/
describe('core without a teams registration', () => {
let server: PuterServer;
const makeUser = async (): Promise<{ id: number; username: string }> => {
const username = `nt_${Math.random().toString(36).slice(2, 10)}`;
const created = (await server.stores.user.create({
username,
uuid: uuidv4(),
password: null,
email: `${username}@test.local`,
})) as unknown as { id: number };
return { id: created.id, username };
};
beforeAll(async () => {
server = await setupTestServer({
// Without this the allowlist check returns before asking Teams.
teams_allowed_email_domains: ['test.local'],
} as never);
});
afterAll(async () => {
await server?.shutdown();
});
it('has no teams in the registry', () => {
expect(server.services.team).toBeUndefined();
expect(server.stores.team).toBeUndefined();
});
it('refuses to boot when the config asks for teams', async () => {
await expect(
setupTestServer({ teams_enabled: true } as never),
).rejects.toThrow(/teams_enabled/);
});
it('deletes an account without asking what it was charged for', async () => {
const user = await makeUser();
await expect(
server.services.userAccount.cascadeDelete(user.id),
).resolves.not.toThrow();
expect(await server.stores.user.getById(user.id)).toBeFalsy();
});
it('answers the address-claim question signup asks', async () => {
const user = await makeUser();
const row = (await server.stores.user.getById(user.id))!;
// Nothing is a seat, so the holder is returned as-is.
await expect(
server.services.signup.signupEmailHolder(row.email as string, {
releaseSeat: true,
}),
).resolves.toMatchObject({ id: user.id });
});
it('lists what is shared with an account, folding in no team shares', async () => {
const user = await makeUser();
await expect(
server.services.share.listSharedWithMe({
user: { id: user.id },
} as never),
).resolves.toMatchObject({ items: [] });
});
it('refuses a share aimed at a team, rather than failing', async () => {
// A real home directory, so the refusal is the recipient's and not
// the entry's.
const username = `nt_${Math.random().toString(36).slice(2, 10)}`;
const creds = await createTestUser(server, {
username,
password: 'Nt-Pw-12345!',
});
const row = (await server.stores.user.getByUsername(username))!;
const user = { id: row.id, username };
const actor = { user: row } as never;
expect(creds).toBeTruthy();
// The recipient cannot exist, so this is a 404 and not a crash.
await expect(
server.services.share.share(actor, {
recipient: { teamHandle: 'nobody' },
mode: 'read',
path: `/${user.username}`,
} as never),
).rejects.toMatchObject({ statusCode: 404 });
});
it('serves the homepage shell with the teams tab off', async () => {
const user = await makeUser();
const row = (await server.stores.user.getById(user.id))!;
let html = '';
const res = { send: (out: string) => (html = out) };
await server.services.homepage.send(
{
req: { headers: {}, query: {}, get: () => undefined },
res,
actor: { user: row },
} as never,
{ title: 'Puter' } as never,
{} as never,
);
const params = JSON.parse(
/gui\((\{.*?\})\);/s.exec(html)![1].replaceAll('\\u003c', '<'),
);
expect(params.teams_ui).toBe(false);
});
});
@@ -27,7 +27,7 @@ import { DatabaseClientFactory } from './index.js';
import { SqliteDatabaseClient } from './SqliteDatabaseClient.js';
/** Highest schema version the migration table can reach. */
const CURRENT_SCHEMA_VERSION = 90;
const CURRENT_SCHEMA_VERSION = 91;
/**
* These suites migrate real files on disk. Idle they finish in well under a
@@ -129,6 +129,7 @@ const AVAILABLE_MIGRATIONS: [number, string[]][] = [
[87, ['0092_share-entry-owner.sql']],
[88, ['0093_fsentries-parent-uid-index.sql']],
[89, ['0094_deleted-apps.sql']],
[90, ['0095_kv-share-handle-levels.sql']],
];
export class SqliteDatabaseClient extends AbstractDatabaseClient {
@@ -0,0 +1,50 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- Share levels on `kv_share_handles`. See
-- sqlite/0095_kv-share-handle-levels.sql for the rationale. No per-file
-- applied-state tracking, so every step is guarded and a replay does nothing.
CALL _puter_add_col('kv_share_handles', 'revoked_by', '`revoked_by` varchar(8) DEFAULT NULL');
CALL _puter_add_col('kv_share_handles', 'label', '`label` varchar(512) DEFAULT NULL');
DROP PROCEDURE IF EXISTS _puter_add_kv_share_handle_indexes;
DELIMITER //
CREATE PROCEDURE _puter_add_kv_share_handle_indexes()
BEGIN
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'kv_share_handles'
AND INDEX_NAME = 'idx_kv_share_handles_grantee'
) THEN
ALTER TABLE `kv_share_handles` ADD INDEX `idx_kv_share_handles_grantee` (`grantee_user_id`, `id`);
END IF;
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'kv_share_handles'
AND INDEX_NAME = 'idx_kv_share_handles_app'
) THEN
ALTER TABLE `kv_share_handles` ADD INDEX `idx_kv_share_handles_app` (`app_uid`);
END IF;
END//
DELIMITER ;
CALL _puter_add_kv_share_handle_indexes();
DROP PROCEDURE IF EXISTS _puter_add_kv_share_handle_indexes;
@@ -0,0 +1,29 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- Share levels on kv_share_handles. See
-- sqlite/0095_kv-share-handle-levels.sql for the rationale. Idempotent via
-- IF NOT EXISTS; there is no per-file applied-state tracking.
ALTER TABLE kv_share_handles ADD COLUMN IF NOT EXISTS revoked_by VARCHAR(8) DEFAULT NULL;
ALTER TABLE kv_share_handles ADD COLUMN IF NOT EXISTS label VARCHAR(512) DEFAULT NULL;
CREATE INDEX IF NOT EXISTS idx_kv_share_handles_grantee
ON kv_share_handles (grantee_user_id, id);
CREATE INDEX IF NOT EXISTS idx_kv_share_handles_app
ON kv_share_handles (app_uid);
@@ -0,0 +1,35 @@
-- Copyright (C) 2024-present Puter Technologies Inc.
--
-- This file is part of Puter.
--
-- Puter is free software: you can redistribute it and/or modify
-- it under the terms of the GNU Affero General Public License as published
-- by the Free Software Foundation, either version 3 of the License, or
-- (at your option) any later version.
--
-- This program is distributed in the hope that it will be useful,
-- but WITHOUT ANY WARRANTY; without even the implied warranty of
-- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-- GNU Affero General Public License for more details.
--
-- You should have received a copy of the GNU Affero General Public License
-- along with this program. If not, see <https://www.gnu.org/licenses/>.
-- What share levels need from `kv_share_handles`. The level itself is not a
-- column: it is the root of the stored `permission`.
--
-- - `revoked_by` : `owner`, `holder` or `system` (the namespace app was
-- deleted). Null while live, and on rows retired before
-- this existed.
-- - `label` : optional owner-set text the holder is shown.
-- - the grantee index serves the holder's listing and their unshare, and
-- the app index retiring a deleted app's handles.
ALTER TABLE `kv_share_handles` ADD COLUMN `revoked_by` TEXT DEFAULT NULL;
ALTER TABLE `kv_share_handles` ADD COLUMN `label` TEXT DEFAULT NULL;
CREATE INDEX IF NOT EXISTS `idx_kv_share_handles_grantee`
ON `kv_share_handles` (`grantee_user_id`, `id`);
CREATE INDEX IF NOT EXISTS `idx_kv_share_handles_app`
ON `kv_share_handles` (`app_uid`);
@@ -188,6 +188,47 @@ describe('DDBClient — item operations', () => {
).toBeUndefined();
});
it('puts only while its condition holds', async () => {
const absent = {
expression: 'attribute_not_exists(#pk)',
names: { '#pk': 'pk' },
};
await client.put(
TABLE,
{ pk: 'u7', sk: 'once', v: 1 },
{
condition: absent,
},
);
await expect(
client.put(
TABLE,
{ pk: 'u7', sk: 'once', v: 2 },
{
condition: absent,
},
),
).rejects.toMatchObject({ name: 'ConditionalCheckFailedException' });
expect(
(await client.get(TABLE, { pk: 'u7', sk: 'once' })).Item?.v,
).toBe(1);
await client.put(
TABLE,
{ pk: 'u7', sk: 'once', v: 3 },
{
condition: {
expression: '#v = :held',
names: { '#v': 'v' },
values: { ':held': 1 },
},
},
);
expect(
(await client.get(TABLE, { pk: 'u7', sk: 'once' })).Item?.v,
).toBe(3);
});
it('fetches keys from several tables in one batch', async () => {
await client.put(TABLE, { pk: 'b1', sk: 'a', v: 1 });
await client.put(TABLE, { pk: 'b1', sk: 'b', v: 2 });
@@ -501,6 +542,19 @@ describe('DDBClient — credentialled configuration', () => {
).toThrow('requires both `access_key` and `secret_key`');
});
it('accepts the credential chain in place of keys', async () => {
const client = new DDBClient({
port: 0,
extensions: [],
dynamo: {
aws: { useCredentialChain: true, region: 'us-west-2' },
endpoint: 'http://127.0.0.1:1',
},
} as unknown as IConfig);
await expect(client.recreateClient()).resolves.toBeUndefined();
});
it('skips table creation on a managed deployment', async () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
const client = new DDBClient({
+39 -8
View File
@@ -43,6 +43,10 @@ import { once } from 'node:events';
import { Agent as httpsAgent } from 'node:https';
import { HttpError } from '../../core/http';
import type { IConfig, IDynamoConfig } from '../../types';
import {
awsCredentialsFrom,
type AwsCredentials,
} from '../../util/awsCredentials.js';
import { BoundedTtlMap } from '../../util/boundedTtlMap.js';
import { runWithConcurrencyLimitSettled } from '../../util/concurrency.js';
import { Span } from '../../util/span.js';
@@ -248,7 +252,19 @@ export class DDBClient extends PuterClient {
}
@Span('ddb.put', (table: string) => ({ 'db.table': table }))
async put<T extends Record<string, unknown>>(table: string, item: T) {
async put<T extends Record<string, unknown>>(
table: string,
item: T,
opts?: {
/** Refused (`ConditionalCheckFailedException`) unless it holds. */
condition?: {
expression: string;
names?: Record<string, string>;
values?: Record<string, unknown>;
};
},
) {
const condition = opts?.condition;
const client = await this.#getDocumentClient();
return sendRepairingValues(
item,
@@ -258,6 +274,23 @@ export class DDBClient extends PuterClient {
TableName: table,
Item: itemToWrite,
ReturnConsumedCapacity: 'TOTAL',
...(condition
? {
ConditionExpression: condition.expression,
...(condition.names
? {
ExpressionAttributeNames:
condition.names,
}
: {}),
...(condition.values
? {
ExpressionAttributeValues:
condition.values,
}
: {}),
}
: {}),
}),
),
() => `a put to ${table}`,
@@ -688,17 +721,15 @@ export class DDBClient extends PuterClient {
}
#bindAwsClient() {
const accessKeyId = this.#ddbConfig.aws?.access_key;
const secretAccessKey = this.#ddbConfig.aws?.secret_key;
if (!accessKeyId || !secretAccessKey) {
const credentials = awsCredentialsFrom(this.#ddbConfig.aws);
if (!credentials) {
throw new Error(
'DynamoDB aws config requires both `access_key` and `secret_key`',
'DynamoDB aws config requires both `access_key` and `secret_key`, or `useCredentialChain`',
);
}
this.#bindClient({
credentials: { accessKeyId, secretAccessKey },
credentials,
region: this.#ddbConfig.aws?.region || 'us-west-2',
endpoint: this.#ddbConfig.endpoint,
});
@@ -720,7 +751,7 @@ export class DDBClient extends PuterClient {
region,
endpoint,
}: {
credentials: { accessKeyId: string; secretAccessKey: string };
credentials: AwsCredentials;
region: string;
endpoint?: string;
}) {
+27 -1
View File
@@ -397,6 +397,8 @@ export type EventMap = {
user_id: number;
user_uuid?: string;
stripe_customer_id?: string | null;
/** Apps the account owned, which cascaded away with it. */
appUids?: string[];
};
// ---- Team billing ---- the trigger, never the charge ----
@@ -490,13 +492,33 @@ export type EventMap = {
noShareKeys?: string[];
/** Events handler runs behind the write; see `Actor.handlerDepth`. */
handlerDepth?: number;
/**
* The user who made the change. Differs from `userId` when a share
* holder wrote into the owner's namespace.
*/
actingUserId?: number;
/** Made by a worker session or an events handler; see `viaWorkerOf`. */
viaWorker?: true;
};
/**
* A whole namespace was emptied. Namespace-level on purpose: `flush`'s own
* key enumeration is truncated for a large namespace, so the keys it saw
* are not the keys it removed.
*/
'kv.flushed': { namespace: string; userId: number };
'kv.flushed': {
namespace: string;
userId: number;
actingUserId?: number;
viaWorker?: true;
};
/**
* Share handles stopped being addresses, whoever retired them. `userId` is
* one user who could have subscribed through them, their owner or their
* holder. Listeners end what was made through them even where another grant
* still covers the region. Local, like `permission.revoked`: one region
* settling covers every other.
*/
'kv.share.retired': { handles: string[]; userId: number };
// ---- Notifications ----
/**
@@ -679,6 +701,10 @@ export type EventMap = {
requestHash?: string;
mime: string;
};
// Asked for a request whose host is outside every configured domain. A
// listener that recognises the host sets `result.subdomain` to the site
// it serves; an unclaimed host is answered with a 404.
'site.resolveHost': { host: string; result: { subdomain: string | null } };
// Asked before a hosted file is streamed. A listener that owns the site
// sets `result.allowed = false` to withhold the entry; the visitor then
// sees the same 404 as for a missing file.
+62 -10
View File
@@ -3,18 +3,19 @@
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
* Puter is free software: you can redistribute it and/or modify it under the
* terms of the GNU Affero General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option) any
* later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
* along with this program. If not, see
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
*/
import { GetObjectCommand } from '@aws-sdk/client-s3';
@@ -24,7 +25,7 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import type { IConfig } from '../../types';
import { S3Client } from './S3Client';
import { S3Client, withPresignHeadroom } from './S3Client';
const LOCAL_BUCKET = 'puter-local';
@@ -487,3 +488,54 @@ describe('S3Client — short reads during multipart', () => {
expect(parts).toEqual([10, 10]);
});
});
describe('withPresignHeadroom', () => {
const HOUR = 60 * 60 * 1000;
const creds = (msLeft?: number, id = 'a') => ({
accessKeyId: id,
secretAccessKey: 's',
...(msLeft === undefined
? {}
: { expiration: new Date(Date.now() + msLeft) }),
});
it('reports expiry an hour early so the SDK refreshes while an hour remains', async () => {
const chain = vi.fn(async () => creds(5 * HOUR));
const identity = await withPresignHeadroom(chain)();
expect(chain).toHaveBeenCalledTimes(1);
const left = identity.expiration!.getTime() - Date.now();
expect(left).toBeGreaterThan(4 * HOUR - 1000);
expect(left).toBeLessThanOrEqual(4 * HOUR);
});
it('force-refreshes credentials that are inside the presign window', async () => {
const chain = vi.fn(async (options?: { forceRefresh?: boolean }) =>
options?.forceRefresh
? creds(6 * HOUR, 'fresh')
: creds(30 * 60 * 1000, 'stale'),
);
const identity = await withPresignHeadroom(chain)();
expect(chain).toHaveBeenLastCalledWith(
expect.objectContaining({ forceRefresh: true }),
);
expect(identity.accessKeyId).toBe('fresh');
});
it('leaves long-lived credentials alone', async () => {
const chain = vi.fn(async () => creds(undefined));
const identity = await withPresignHeadroom(chain)();
expect(chain).toHaveBeenCalledTimes(1);
expect(identity.expiration).toBeUndefined();
});
it('never reports an expiry inside the SDK refresh margin', async () => {
const chain = vi.fn(async () => creds(20 * 60 * 1000));
const identity = await withPresignHeadroom(chain)();
const left = identity.expiration!.getTime() - Date.now();
expect(left).toBeGreaterThan(5 * 60 * 1000);
});
});
+40 -1
View File
@@ -43,6 +43,43 @@ const DEFAULT_MULTIPART_PART_SIZE_BYTES = 5 * 1024 * 1024;
const FAUXQS_SAFE_PUT_OBJECT_LIMIT_BYTES = 10 * 1024 * 1024;
const LEGACY_STORAGE_BUCKET = 'puter-local';
// A presigned URL stops working when the credentials that signed it expire.
// Presigns last up to an hour, but the SDK only refreshes temporary
// credentials five minutes before expiry.
const PRESIGN_MAX_LIFETIME_MS = 60 * 60 * 1000;
const SDK_REFRESH_MARGIN_MS = 5 * 60 * 1000;
type CredentialChain = ReturnType<typeof fromNodeProviderChain>;
/**
* Keeps temporary credentials at least an hour from expiry when they sign. The
* SDK is handed each identity with its expiration moved an hour early, so its
* own refresh comes back here while an hour remains, and a credential already
* inside that window is force-refreshed from the chain.
*/
export function withPresignHeadroom(chain: CredentialChain): CredentialChain {
return async (options) => {
let identity = await chain(options);
const remaining = (i: typeof identity) =>
i.expiration ? i.expiration.getTime() - Date.now() : Infinity;
if (
remaining(identity) <
PRESIGN_MAX_LIFETIME_MS + SDK_REFRESH_MARGIN_MS
) {
identity = await chain({ ...options, forceRefresh: true });
}
if (!identity.expiration) return identity;
// If even fresh credentials are short-lived, report an expiry just past
// the SDK's margin rather than one in the past, which would make it
// re-resolve on every request.
const reported = Math.max(
identity.expiration.getTime() - PRESIGN_MAX_LIFETIME_MS,
Date.now() + SDK_REFRESH_MARGIN_MS + 60 * 1000,
);
return { ...identity, expiration: new Date(reported) };
};
}
type S3CommandSender = Pick<AwsS3Client, 'send'>;
export class S3Client extends PuterClient {
@@ -83,7 +120,9 @@ export class S3Client extends PuterClient {
if (useCredentialChain) {
this.useProviderChain = true;
this.awsConfig = { credentials: fromNodeProviderChain() };
this.awsConfig = {
credentials: withPresignHeadroom(fromNodeProviderChain()),
};
this.partSize = 64 * 1024 * 1024;
this.maxSingleUploadSize = 128 * 1024 * 1024;
} else {
@@ -28,7 +28,6 @@ import {
setupPuterTestEnv,
type PuterTestEnv,
} from '../../testUtil.js';
import type { IConfig } from '../../types';
/**
* HTTP-level coverage for `/auth/revoke-own-access-token` — the route that
@@ -424,7 +423,7 @@ describe('2FA enrollment over HTTP', () => {
let env: PuterTestEnv;
beforeAll(async () => {
env = await setupPuterTestEnv({ teams_enabled: true } as IConfig);
env = await setupPuterTestEnv();
}, 120_000);
afterAll(async () => {
@@ -561,56 +560,6 @@ describe('2FA enrollment over HTTP', () => {
expect((await otpState(account.userId)).enabled).toBe(true);
});
it('lets a seat its team holds for 2FA enroll through the same gate', async () => {
const owner = await makeAccount();
// The rule can only be turned on by an owner who has 2FA.
await env.server.stores.user.update(owner.userId, { otp_enabled: 1 });
await env.server.stores.user.invalidateById(owner.userId);
const team = await env.server.services.team.createTeam(owner.userId, {
name: 'Acme',
});
const seatName = `seat_${uniq()}`;
const { userId, temporaryPassword } =
await env.server.services.team.provisionAccount(
team.uid,
owner.userId,
{ username: seatName },
);
await env.server.services.team.updateTeam(team.uid, owner.userId, {
require2fa: true,
});
const { token } = await env.server.services.auth.createSessionToken(
(await env.server.stores.user.getById(userId))!,
);
// First sign-in replaces the temporary password.
const changed = await protectedPost(
'/user-protected/change-password',
token,
{ password: temporaryPassword, new_pass: 'seat-own-password' },
);
expect(changed.status).toBe(200);
const held = await fetch(new URL('/teams', env.apiOrigin), {
headers: { authorization: `Bearer ${token}` },
});
expect(held.status).toBe(403);
expect(await held.text()).toContain('two_factor_required');
const res = await setup(token, { password: 'seat-own-password' });
expect(res.status).toBe(200);
const { secret } = (await res.json()) as { secret: string };
const enabled = await configure(token, 'enable', {
code: liveTotp(seatName, secret),
});
expect(enabled.status).toBe(200);
expect((await otpState(userId)).enabled).toBe(true);
const released = await fetch(new URL('/teams', env.apiOrigin), {
headers: { authorization: `Bearer ${token}` },
});
expect(released.status).toBe(200);
});
});
/**
@@ -28,6 +28,7 @@
* play along. Aligns with AGENTS.md: "Prefer test server over mocking deps."
*/
import { installSeatStub } from '../../testFixtures/seatStub.js';
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import { TOTP } from 'otpauth';
@@ -48,7 +49,7 @@ import {
} from '../../core/http/types.js';
import { PuterServer } from '../../server.js';
import { createSecret as otpCreateSecret } from '../../services/auth/OTPUtil.js';
import { kvSharePermission } from '../../services/events/kvShares.js';
import { kvSharePermission } from '../../services/kvShare/kvShares.js';
import { FULL_API_ACCESS } from '../../services/permission/consts.js';
import { setupTestServer } from '../../testUtil.js';
import { resetCardVerificationStatusCache } from '../../util/cardFallback.js';
@@ -2150,7 +2151,7 @@ describe('AuthController grant flows', () => {
},
{ ownerUserId: issuer.id },
);
const permission = `service:tg-app:ii:read`;
const permission = `events:background`;
// The controller's grant call delegates through PermissionService
// (which uses ALS Context.set), so wrap in runWithContext.
const res = makeRes();
@@ -2191,7 +2192,7 @@ describe('AuthController grant flows', () => {
},
{ ownerUserId: issuer.id },
);
const permission = `service:tg-origin-app:ii:read`;
const permission = `events:background`;
const res = makeRes();
await inCtx(issuerActor, () =>
controller.handleGrantUserApp(
@@ -2228,7 +2229,7 @@ describe('AuthController grant flows', () => {
},
{ ownerUserId: issuer.id },
);
const permission = `service:tr-origin-app:ii:read`;
const permission = `events:background`;
await inCtx(issuerActor, () =>
controller.handleGrantUserApp(
makeReq(
@@ -2293,7 +2294,7 @@ describe('AuthController grant flows', () => {
await inCtx(issuerActor, () =>
controller.handleRevokeUserApp(
makeReq(
{ app_uid: app.uid, permission: 'service:x:ii:read' },
{ app_uid: app.uid, permission: 'events:background' },
{ actor: issuerActor },
),
makeRes(),
@@ -2317,12 +2318,12 @@ describe('AuthController grant flows', () => {
it('grant-user-app: 400 on non-string or oversized origin/app_uid/permission', async () => {
const cases = [
{ origin: { host: 'evil' }, permission: 'service:x:ii:read' },
{ origin: 'https://a.test', permission: ['service:x:ii:read'] },
{ app_uid: 12345, permission: 'service:x:ii:read' },
{ origin: { host: 'evil' }, permission: 'events:background' },
{ origin: 'https://a.test', permission: ['events:background'] },
{ app_uid: 12345, permission: 'events:background' },
{
origin: `https://${'a'.repeat(5000)}.test`,
permission: 'service:x:ii:read',
permission: 'events:background',
},
];
for (const body of cases) {
@@ -2353,7 +2354,7 @@ describe('AuthController grant flows', () => {
{ ownerUserId: target.id },
);
const permission = 'service:squat:ii:read';
const permission = 'events:background';
for (const handler of [
'handleGrantUserApp',
'handleRevokeUserApp',
@@ -2397,7 +2398,7 @@ describe('AuthController grant flows', () => {
{ ownerUserId: target.id },
);
const permission = 'service:squat-beside:ii:read';
const permission = 'events:background';
for (const handler of [
'handleGrantUserApp',
'handleRevokeUserApp',
@@ -2450,7 +2451,7 @@ describe('AuthController grant flows', () => {
{ ownerUserId: issuer.id },
);
const permission = 'service:tp-origin:ii:read';
const permission = 'events:background';
await inCtx(issuerActor, () =>
controller.handleGrantUserApp(
makeReq(
@@ -2486,7 +2487,7 @@ describe('AuthController grant flows', () => {
},
{ ownerUserId: issuer.id },
);
const permission = 'service:tm-app:ii:read';
const permission = 'events:background';
for (const body of [
{ app_uid: app.uid, permission, meta: 'nope' },
{ app_uid: app.uid, permission, meta: [1, 2] },
@@ -2527,7 +2528,7 @@ describe('AuthController grant flows', () => {
},
{ ownerUserId: issuer.id },
);
const permission = 'service:tb-app:ii:read';
const permission = 'events:background';
const oversized = { blob: 'z'.repeat(5000) };
for (const body of [
{ app_uid: app.uid, permission, extra: oversized },
@@ -2604,26 +2605,278 @@ describe('AuthController grant flows', () => {
expect(res.body).toEqual({});
});
it('grant-user-app: 400 on a `permission` wider than the column it lands in', async () => {
// 300 chars is under the 4096 input cap but over `varchar(255)`, so it
// used to reach the INSERT and fault on MySQL/Postgres. The check runs
// after the rewrite, so this has to be a permission no rewriter
// shortens — and it has to reject before the app is resolved, since
// this uid names no app.
await expect(
it('grant-user-app: grants read and write share delegations on a region, never on the namespace', async () => {
const appName = `tkvl-${uuidv4()}`;
const app = await server.stores.app.create(
{
name: appName,
title: 'TestKvShareLevelsApp',
index_url: `https://${appName}.example.test/index.html`,
},
{ ownerUserId: issuer.id },
);
for (const root of ['kv-share-read', 'kv-share-write']) {
const namespace = `manage:${root}:${issuer.uuid}:${app.uid}`;
await expect(
inCtx(issuerActor, () =>
controller.handleGrantUserApp(
makeReq(
{ app_uid: app.uid, permission: namespace },
{ actor: issuerActor },
),
makeRes(),
),
),
).rejects.toMatchObject({ legacyCode: 'invalid_kv_share_prefix' });
const res = makeRes();
await inCtx(issuerActor, () =>
controller.handleGrantUserApp(
makeReq(
{ app_uid: app.uid, permission: `${namespace}:team` },
{ actor: issuerActor },
),
res,
),
);
expect(res.body).toEqual({});
}
const rows = await server.clients.db.read(
'SELECT `permission` FROM `user_to_app_permissions` WHERE `user_id` = ? AND `app_id` = ? ORDER BY `permission`',
[issuer.id, app.id],
);
expect(rows.map((r: { permission: string }) => r.permission)).toEqual([
`manage:kv-share-read:${issuer.uuid}:${app.uid}:team`,
`manage:kv-share-write:${issuer.uuid}:${app.uid}:team`,
]);
});
it('grant-user-app: refuses a share delegation whose region a new share could not name, writing nothing', async () => {
const appName = `tkvc-${uuidv4()}`;
const app = await server.stores.app.create(
{
name: appName,
title: 'TestKvShareCharsApp',
index_url: `https://${appName}.example.test/index.html`,
},
{ ownerUserId: issuer.id },
);
for (const region of ['Team', 'team:Abc', 'caf\u00E9', 'a b'])
await expect(
inCtx(issuerActor, () =>
controller.handleGrantUserApp(
makeReq(
{
app_uid: app.uid,
permission: `manage:kv-share-read:${issuer.uuid}:${app.uid}:${region}`,
},
{ actor: issuerActor },
),
makeRes(),
),
),
).rejects.toMatchObject({ legacyCode: 'invalid_kv_share_prefix' });
const rows = await server.clients.db.read(
'SELECT `permission` FROM `user_to_app_permissions` WHERE `user_id` = ? AND `app_id` = ?',
[issuer.id, app.id],
);
expect(rows).toEqual([]);
});
it('grant-user-app: refuses a permission outside the app-grantable families, writing nothing', async () => {
const appName = `tng-${uuidv4()}`;
const app = await server.stores.app.create(
{
name: appName,
title: 'TestNotGrantableApp',
index_url: `https://${appName}.example.test/index.html`,
},
{ ownerUserId: issuer.id },
);
const other = await server.stores.app.create(
{
name: `tng-other-${uuidv4()}`,
title: 'TestNotGrantableOther',
index_url: `https://tng-other-${uuidv4()}.example.test/index.html`,
},
{ ownerUserId: issuer.id },
);
const refused = [
['driver:puter-image-generation:generate'],
[`user:${target.uuid}:email:read`],
// One bad entry refuses the batch.
['events:background', 'random-string'],
// A share delegation names the app's own namespace only.
[`manage:kv-share:${issuer.uuid}:${other.uid}:rooms`],
];
for (const permissions of refused) {
await expect(
inCtx(issuerActor, () =>
controller.handleGrantUserApp(
makeReq(
{ app_uid: app.uid, permissions },
{ actor: issuerActor },
),
makeRes(),
),
),
).rejects.toMatchObject({
statusCode: 400,
legacyCode: 'permission_not_grantable',
});
}
const rows = await server.clients.db.read(
'SELECT 1 FROM `user_to_app_permissions` WHERE `user_id` = ? AND `app_id` = ?',
[issuer.id, app.id],
);
expect(rows).toEqual([]);
});
it('grant-user-app: refuses a non-canonical spelling of a grantable permission', async () => {
const appName = `tnc-${uuidv4()}`;
const app = await server.stores.app.create(
{
name: appName,
title: 'TestNonCanonicalApp',
index_url: `https://${appName}.example.test/index.html`,
},
{ ownerUserId: issuer.id },
);
for (const permission of [
`f\\s:/${issuer.username}/Documents:read`,
`app-root-di\\r:${app.uid}:read`,
`manag\\e:kv-share:${issuer.uuid}:${app.uid}:rooms`,
]) {
await expect(
inCtx(issuerActor, () =>
controller.handleGrantUserApp(
makeReq(
{ app_uid: app.uid, permission },
{ actor: issuerActor },
),
makeRes(),
),
),
).rejects.toMatchObject({ legacyCode: 'permission_not_grantable' });
}
});
it('grant-user-app: refuses a share delegation over a prefix no share could be made of, as a share would be', async () => {
const appName = `tkvp-${uuidv4().slice(0, 8)}`;
const app = await server.stores.app.create(
{
name: appName,
title: 'TestKvSharePrefixApp',
index_url: `https://${appName}.example.test/index.html`,
},
{ ownerUserId: issuer.id },
);
for (const root of ['kv-share', 'kv-share-read', 'kv-share-write']) {
// A share grant one character wider than the column.
const head = `${root}:${issuer.uuid}:${app.uid}:`;
const region = 'a'.repeat(256 - head.length);
await expect(
inCtx(issuerActor, () =>
controller.handleGrantUserApp(
makeReq(
{
app_uid: app.uid,
permission: `manage:${head}${region}`,
},
{ actor: issuerActor },
),
makeRes(),
),
),
).rejects.toMatchObject({ legacyCode: 'invalid_kv_share_prefix' });
}
});
it('grant-user-app: a share delegation must name the app the request names, by uid, name or origin', async () => {
const appName = `tkvn-${uuidv4()}`;
const origin = `https://${appName}.example.test`;
const app = await server.stores.app.create(
{
name: appName,
title: 'TestKvShareNamedApp',
index_url: `${origin}/index.html`,
},
{ ownerUserId: issuer.id },
);
const other = await server.stores.app.create(
{
name: `tkvn-other-${uuidv4()}`,
title: 'TestKvShareNamedOther',
index_url: `https://tkvn-other-${uuidv4()}.example.test/index.html`,
},
{ ownerUserId: issuer.id },
);
const grant = (requester: Record<string, string>, namespace: string) =>
inCtx(issuerActor, () =>
controller.handleGrantUserApp(
makeReq(
{
app_uid: `app-${uuidv4()}`,
permission: `service:${'a'.repeat(300)}:ii:read`,
...requester,
permission: `manage:kv-share:${issuer.uuid}:${namespace}:rooms`,
},
{ actor: issuerActor },
),
makeRes(),
),
);
for (const requester of [{ app_uid: app.name }, { origin }]) {
await grant(requester, app.uid);
await expect(grant(requester, other.uid)).rejects.toMatchObject({
legacyCode: 'permission_not_grantable',
});
}
// The app's name is not its namespace.
await expect(
grant({ app_uid: app.uid }, app.name),
).rejects.toMatchObject({ legacyCode: 'permission_not_grantable' });
const rows = await server.clients.db.read(
'SELECT `permission` FROM `user_to_app_permissions` WHERE `user_id` = ? AND `app_id` = ?',
[issuer.id, app.id],
);
expect(rows.map((r: { permission: string }) => r.permission)).toEqual([
`manage:kv-share:${issuer.uuid}:${app.uid}:rooms`,
]);
});
it('grant-user-app: 400 on a `permission` wider than the column it lands in', async () => {
// Under the 4096 input cap but over `varchar(255)`, so it would reach
// the INSERT and fault on MySQL/Postgres. The check runs after the
// rewrite, so this has to be a grantable permission no rewriter
// shortens.
const appName = `tw-${uuidv4()}`;
const app = await server.stores.app.create(
{
name: appName,
title: 'TestWidthApp',
index_url: `https://${appName}.example.test/index.html`,
},
{ ownerUserId: issuer.id },
);
// The shares it delegates fit, at 255 characters; the consent's own
// `manage:` arm takes it past the column.
const head = `kv-share:${issuer.uuid}:${app.uid}:`;
const permission = `manage:${head}${'a'.repeat(255 - head.length)}`;
await expect(
inCtx(issuerActor, () =>
controller.handleGrantUserApp(
makeReq(
{ app_uid: app.uid, permission },
{ actor: issuerActor },
),
makeRes(),
),
),
).rejects.toMatchObject({ statusCode: 400 });
).rejects.toMatchObject({
statusCode: 400,
legacyCode: 'bad_request',
message: 'Invalid `permission`',
});
});
it('grant-user-app: an oversized permission that a rewriter shortens is accepted', async () => {
@@ -2635,6 +2888,8 @@ describe('AuthController grant flows', () => {
// on "please try again" for any deeply nested file. Exercised here
// through a rewriter of our own, since the real fs rewriter resolves
// the path through the fsentry store and this suite has no entries.
// It rewrites a share delegation only because that is a grantable
// shape no production rewriter touches.
const appName = `tlp-${uuidv4()}`;
const app = await server.stores.app.create(
{
@@ -2645,13 +2900,15 @@ describe('AuthController grant flows', () => {
{ ownerUserId: issuer.id },
);
const prefix = `tlprw-${uuidv4()}`;
const longPermission = `${prefix}:${'a'.repeat(300)}:read`;
const shortPermission = `${prefix}:${uuidv4()}:read`;
// Over the column only by its `manage:` arm, so the shares it
// delegates still fit.
const head = `kv-share:${issuer.uuid}:${app.uid}:`;
const longPermission = `manage:${head}${'a'.repeat(255 - head.length)}`;
const shortPermission = `manage:${head}${uuidv4()}`;
expect(longPermission.length).toBeGreaterThan(255);
expect(shortPermission.length).toBeLessThanOrEqual(255);
server.services.permission.registerRewriter({
id: `test-shorten-${prefix}`,
id: `test-shorten-${app.uid}`,
matches: (permission: string) => permission === longPermission,
rewrite: async () => shortPermission,
});
@@ -2711,7 +2968,8 @@ describe('AuthController grant flows', () => {
{ ownerUserId: issuer.id },
);
const prefix = `conc-${uuidv4()}`;
// A grantable shape no production rewriter touches.
const prefix = `manage:kv-share:${issuer.uuid}:${app.uid}:conc-${uuidv4()}`;
let release: () => void;
let entered: () => void;
const held = new Promise<void>((r) => {
@@ -2739,7 +2997,7 @@ describe('AuthController grant flows', () => {
makeReq(
{
app_uid: app.uid,
permission: `${prefix}:${app.uid}:write`,
permission: `${prefix}:write`,
},
{ actor: issuerActor },
),
@@ -2780,8 +3038,8 @@ describe('AuthController grant flows', () => {
{ ownerUserId: issuer.id },
);
const prefix = `tardrw-${uuidv4()}`;
const pseudoPermission = `${prefix}:${app.uid}:write`;
const prefix = `manage:kv-share:${issuer.uuid}:${app.uid}:tardrw-${uuidv4()}`;
const pseudoPermission = `${prefix}:write`;
const resolvedPermission = `fs:${uuidv4()}:write`;
const NOTHING = 'nothing-in-particular';
server.services.permission.registerRewriter({
@@ -3022,7 +3280,7 @@ describe('AuthController.handleGrantUserApp `create` flag', () => {
// phase A's pre-flight passes, phase C's re-check then fails, so the
// leaf (and its auto-created parent) already exist by the time the
// request unwinds.
const prefix = `rbparent-${uuidv4()}`;
const prefix = `manage:kv-share:${user.uuid}:${appUid}:rbparent-${uuidv4()}`;
let calls = 0;
server.services.permission.registerRewriter({
id: prefix,
@@ -3188,13 +3446,13 @@ describe('AuthController.handleGrantUserApp `create` flag', () => {
).rejects.toMatchObject({ statusCode: 400, legacyCode: 'bad_request' });
});
it('does not apply `create` to a `manage:` grant — 404 unchanged, nothing created', async () => {
it('refuses a `manage:` grant outright, creating nothing', async () => {
const p = path(`manage-missing-${rand()}`);
await expect(
grant({ permission: `manage:fs:${p}:write` }),
).rejects.toMatchObject({
statusCode: 404,
legacyCode: 'subject_does_not_exist',
statusCode: 400,
legacyCode: 'permission_not_grantable',
});
expect(await server.stores.fsEntry.getEntryByPath(p)).toBeFalsy();
});
@@ -3284,7 +3542,7 @@ describe('AuthController.handleGrantUserApp `create` flag', () => {
// (so nothing is rejected before anything is created) but has grown
// too wide by the time phase C re-runs it — modelling a grant that
// only fails once the fs path from the same batch already exists.
const prefix = `rbtest-${uuidv4()}`;
const prefix = `manage:kv-share:${user.uuid}:${appUid}:rbtest-${uuidv4()}`;
let calls = 0;
server.services.permission.registerRewriter({
id: `test-rollback-${prefix}`,
@@ -3325,6 +3583,37 @@ describe('AuthController.handleGrantUserApp `create` flag', () => {
);
});
it('check-permissions answers an app asking about itself through ACL, so a folder grant covers what is inside', async () => {
const dir = path(`acl-self-${rand()}`);
const child = `${dir}/inner`;
await server.services.fs.mkdir(user.id, {
path: child,
createMissingParents: true,
});
expect(await checkAsApp(`fs:${child}:read`)).toBe(false);
await grant({ permission: `fs:${dir}:write`, create: false });
expect(await checkAsApp(`fs:${child}:read`)).toBe(true);
expect(await checkAsApp(`fs:${child}:write`)).toBe(true);
// Another user's home stays out of reach.
expect(await checkAsApp(`fs:/${target.username}/Documents:read`)).toBe(
false,
);
});
it('check-permissions answers an app asking about itself through ACL for a uid too', async () => {
const dir = path(`acl-self-uid-${rand()}`);
const child = await server.services.fs.mkdir(user.id, {
path: `${dir}/inner`,
createMissingParents: true,
});
expect(await checkAsApp(`fs:${child.uuid}:read`)).toBe(false);
await grant({ permission: `fs:${dir}:read`, create: false });
expect(await checkAsApp(`fs:${child.uuid}:read`)).toBe(true);
expect(await checkAsApp(`fs:${child.uuid}:write`)).toBe(false);
});
it('check-permissions on a missing fs path answers false rather than 404ing, without poisoning a mixed batch', async () => {
const missingPath = path(`.checkmissing-${rand()}`);
// A held fs grant: the owning user holds it implicitly (is-owner), so
@@ -4151,9 +4440,9 @@ describe('AuthController.handleCreateAccessToken + handleRevokeAccessToken', ()
['an oversized permission string', ['x'.repeat(4097)]],
[
'an oversized `extra`',
[['service:x:ii:read', { a: 'y'.repeat(5000) }]],
[['events:background', { a: 'y'.repeat(5000) }]],
],
['a non-object `extra`', [['service:x:ii:read', 'nope']]],
['a non-object `extra`', [['events:background', 'nope']]],
['an empty permission string', ['']],
])('rejects %s with 400', async (_label, permissions) => {
await expect(
@@ -6314,29 +6603,25 @@ describe('AuthController password recovery', () => {
it('send-pass-recovery-email: refuses a team seat, and writes no token', async () => {
// The seat's address is admin-supplied and unverified; recovery there
// would be a takeover channel. Its recovery is the admin's reset.
const { user: owner } = await makeUserAndActor();
const { user: seat } = await makeUserAndActor();
const team = await server.stores.team.create({
ownerUserId: owner.id,
name: 'Acme',
});
await server.stores.user.update(seat.id, { password: null });
await server.stores.team.addMember(team.uid, seat.id, {
orgOwned: true,
});
const res = makeRes();
await controller.handleSendPassRecoveryEmail(
makeReq({ username: seat.username }),
res,
);
expect((res.body as { message: string }).message).toMatch(
/If that account exists/i,
);
const after = await server.stores.user.getById(seat.id, {
force: true,
});
expect(after!.pass_recovery_token).toBeFalsy();
const restore = installSeatStub(server as never, [seat.id]);
try {
const res = makeRes();
await controller.handleSendPassRecoveryEmail(
makeReq({ username: seat.username }),
res,
);
expect((res.body as { message: string }).message).toMatch(
/If that account exists/i,
);
const after = await server.stores.user.getById(seat.id, {
force: true,
});
expect(after!.pass_recovery_token).toBeFalsy();
} finally {
restore();
}
});
// `system` is seeded by migration; `admin` only by the self-hosted boot.
@@ -6725,16 +7010,9 @@ describe('AuthController user-protected mutations (validation paths)', () => {
});
it('change-email: 403 for an account its team provisioned', async () => {
const { user: owner } = await makeUserAndActor();
const { user: seat, actor } = await makeUserAndActor();
const team = await server.stores.team.create({
ownerUserId: owner.id,
name: 'Acme',
});
await server.stores.user.update(seat.id, { password: null });
await server.stores.team.addMember(team.uid, seat.id, {
orgOwned: true,
});
installSeatStub(server as never, [seat.id]);
await expect(
controller.handleChangeEmail(
@@ -6750,17 +7028,9 @@ describe('AuthController user-protected mutations (validation paths)', () => {
it('change-username: 403 for an account its team provisioned', async () => {
// The console lists members by username and the audit log records them
// by it; a self-service rename would desync both.
const { user: owner } = await makeUserAndActor();
const { user: seat, actor } = await makeUserAndActor();
const team = await server.stores.team.create({
ownerUserId: owner.id,
name: 'Acme',
});
// addMember refuses to adopt an account that has one.
await server.stores.user.update(seat.id, { password: null });
await server.stores.team.addMember(team.uid, seat.id, {
orgOwned: true,
});
installSeatStub(server as never, [seat.id]);
await expect(
controller.handleChangeUsername(
@@ -7383,6 +7653,88 @@ describe('AuthController.handleCheckPermissions + handleListPermissions', () =>
]);
});
it('check-permissions: answers false for share grants, which name what a holder is never told, but not for their manage arm', async () => {
const { user: owner, actor: ownerActor } = await makeUserAndActor();
const { user: holder, actor: holderActor } = await makeUserAndActor();
const region = `notes-${uuidv4().slice(0, 8)}`;
const grant = `kv-share-read:${owner.uuid}:os-global:${region}`;
await inCtx(ownerActor, () =>
server.services.permission.grantUserUserPermission(
ownerActor,
holder.username,
grant,
),
);
expect(await server.services.permission.check(holderActor, grant)).toBe(
true,
);
const asked = [
grant,
`kv-share:${owner.uuid}:os-global:${region}:inner`,
`kv-share-write:${owner.uuid}:os-global:${region}`,
];
const res = makeRes();
await inCtx(holderActor, () =>
controller.handleCheckPermissions(
makeReq({ permissions: asked }, { actor: holderActor }),
res,
),
);
expect(res.body).toEqual({
permissions: Object.fromEntries(asked.map((p) => [p, false])),
});
// What `puter.perms.check('kvShare', …)` asks.
const manage = `manage:kv-share-read:${owner.uuid}:os-global:${region}`;
const ownerRes = makeRes();
await inCtx(ownerActor, () =>
controller.handleCheckPermissions(
makeReq({ permissions: [manage] }, { actor: ownerActor }),
ownerRes,
),
);
expect(ownerRes.body).toEqual({ permissions: { [manage]: true } });
});
it('check-permissions: answers false for share grants asked by an app actor too', async () => {
const { user: owner, actor: ownerActor } = await makeUserAndActor();
const { user: holder, actor: holderActor } = await makeUserAndActor();
const app = await server.stores.app.create(
{
name: `cpk-${uuidv4()}`,
title: 'TestCheckPermsKvApp',
index_url: 'https://check-perms-kv.example.test/index.html',
},
{ ownerUserId: holder.id },
);
const grant = `kv-share-read:${owner.uuid}:${app.uid}:notes-${uuidv4().slice(0, 8)}`;
await inCtx(ownerActor, () =>
server.services.permission.grantUserUserPermission(
ownerActor,
holder.username,
grant,
),
);
const appActor = makeActor({
user: holderActor.user,
app: { id: app.id, uid: app.uid },
});
// The holder's app in the share's namespace holds it.
expect(await server.services.permission.check(appActor, grant)).toBe(
true,
);
const res = makeRes();
await inCtx(appActor, () =>
controller.handleCheckPermissions(
makeReq({ permissions: [grant] }, { actor: appActor }),
res,
),
);
expect(res.body).toEqual({ permissions: { [grant]: false } });
});
// What lets a permission request settle without a prompt. The second string
// is a consent scope nothing implies, so the first's `true` is the grant.
it('check-permissions: an app-under-user actor sees a grant made to that app', async () => {
@@ -7597,7 +7949,7 @@ describe('AuthController.handleCheckPermissions + handleListPermissions', () =>
},
{ ownerUserId: user.id },
);
const permission = 'service:tl-app:ii:read';
const permission = 'events:background';
await inCtx(actor, () =>
controller.handleGrantUserApp(
makeReq({ app_uid: app.uid, permission, extra: {} }, { actor }),
@@ -8154,15 +8506,10 @@ describe('AuthController 2FA flows', () => {
it('lets a team seat enable 2FA without a confirmed email', async () => {
// A seat has no address by design (PUT-1792) while its team can
// require 2FA, so this check would bar it forever.
const owner = await makeUserAndActor();
const team = await server.services.team.createTeam(owner.user.id, {
name: 'Acme',
});
const seatName = `s_${uniq()}`;
await server.services.team.provisionAccount(team.uid, owner.user.id, {
username: seatName,
});
const seat = await server.stores.user.getByUsername(seatName);
const { user: made } = await makeUserAndActor({ email_confirmed: 0 });
await server.stores.user.update(made.id, { password: null });
const seat = await server.stores.user.getById(made.id, { force: true });
installSeatStub(server as never, [seat!.id]);
const actor = {
user: {
id: seat!.id,
@@ -8183,15 +8530,10 @@ describe('AuthController 2FA flows', () => {
});
it('refuses to disable 2FA while the team requires it', async () => {
const owner = await makeUserAndActor({ otp_enabled: 1 });
const team = await server.services.team.createTeam(owner.user.id, {
name: 'Acme',
});
const seatName = `s_${uniq()}`;
await server.services.team.provisionAccount(team.uid, owner.user.id, {
username: seatName,
});
const seat = await server.stores.user.getByUsername(seatName);
const { user: made } = await makeUserAndActor();
await server.stores.user.update(made.id, { password: null });
const seat = await server.stores.user.getById(made.id, { force: true });
installSeatStub(server as never, [seat!.id], { require2fa: true });
const actor = {
user: {
id: seat!.id,
@@ -8202,9 +8544,6 @@ describe('AuthController 2FA flows', () => {
} as Actor;
const { secret } = await setup2fa(actor);
await enable2fa(actor, { code: liveTotp(seat!.username, secret) });
await server.services.team.updateTeam(team.uid, owner.user.id, {
require2fa: true,
});
await expect(
controller.handleDisable2fa(makeReq({}, { actor }), makeRes()),
@@ -8495,16 +8834,9 @@ describe('AuthController.handleDeleteOwnUser', () => {
it('refuses, and keeps the row, for an account its team provisioned', async () => {
// The team is billed for the seat and closing it is theirs to do, from
// the console that keeps the audit trail.
const { user: owner } = await makeUserAndActor();
const { user: seat, actor } = await makeUserAndActor();
const team = await server.stores.team.create({
ownerUserId: owner.id,
name: 'Acme',
});
await server.stores.user.update(seat.id, { password: null });
await server.stores.team.addMember(team.uid, seat.id, {
orgOwned: true,
});
installSeatStub(server as never, [seat.id]);
await expect(
controller.handleDeleteOwnUser(makeReq({}, { actor }), makeRes()),
@@ -10253,7 +10585,7 @@ describe('AuthController — app-data grants', () => {
await expect(
post('handleGrantUserApp', {
app_uid: grantee.uid,
permission: 'app-data:app-nope/:kv:read',
permission: `app-data:app-${uuidv4()}:kv:read`,
}),
).rejects.toMatchObject({ statusCode: 404 });
});
@@ -10320,7 +10652,7 @@ describe('AuthController — app-data grants', () => {
it('leaves unrelated permissions untouched by the new validation', async () => {
const grantee = await makeAppRow();
const permission = 'service:unrelated:ii:read';
const permission = 'events:background';
await post('handleGrantUserApp', {
app_uid: grantee.uid,
permission,
@@ -10334,17 +10666,9 @@ describe('AuthController — app-data grants', () => {
describe('AuthController — a seat address nobody confirmed', () => {
/** A seat whose address its team typed in; unconfirmed. */
const makeSeat = async () => {
const { user: owner } = await makeUserAndActor();
const { user: seat } = await makeUserAndActor();
const team = await server.stores.team.create({
ownerUserId: owner.id,
name: 'Acme',
});
// A seat is created password-less and given a temporary one after.
await server.stores.user.update(seat.id, { password: null });
await server.stores.team.addMember(team.uid, seat.id, {
orgOwned: true,
});
installSeatStub(server as never, [seat.id]);
await server.stores.user.update(seat.id, {
password: await bcrypt.hash('temporary-password', 8),
});
+101 -28
View File
@@ -33,7 +33,7 @@ import {
hasVerifiedPhone,
} from '../../core/http/middleware/gates.js';
import type { Actor } from '../../core/actor.js';
import { isPlainUserActor, makeActor } from '../../core/actor.js';
import { isAppActor, isPlainUserActor, makeActor } from '../../core/actor.js';
import {
checkRateLimit,
consumeRouteRateLimit,
@@ -88,13 +88,16 @@ import {
import { promoteToVerifiedGroup } from '../../util/userProvisioning.js';
import {
assertBoundedManageGrant,
assertNewShareSegments,
assertPrefixFitsAccess,
isKvSharePermission,
} from '../../services/events/kvShares.js';
} from '../../services/kvShare/kvShares.js';
import {
APP_DATA_PERMISSION_PREFIX,
appDataSharingAllowed,
parseAppDataPermission,
} from '../../services/permission/appDataScopes.js';
import { parseAppGrantablePermission } from '../../services/permission/appGrantable.js';
import {
assertCreatablePath,
isOwnHomePath,
@@ -111,6 +114,7 @@ import { normalizeAbsolutePath } from '../../services/fs/resolveNode.js';
import type { FSEntry } from '../../stores/fs/FSEntry.js';
import { PuterController } from '../types.js';
import { startWebSession } from './webSession.js';
import { completeOidcLoginReturn } from './oidcLoginReturn.js';
const FINGERPRINT_MAX_LENGTH = 128;
// One consent prompt covers a handful of scopes at most. The cap keeps a
@@ -595,6 +599,7 @@ export class AuthController extends PuterController {
user_uid: string;
purpose: string;
auth_id?: string;
oidcContinuation?: unknown;
}>('otp', token);
} catch {
throw new HttpError(400, 'Invalid token.', {
@@ -628,7 +633,7 @@ export class AuthController extends PuterController {
await this.#enforceAuthIdMatch(req, user, decoded.auth_id ?? null);
await this.#completeLogin(req, res, user);
await this.#completeLogin(req, res, user, decoded.oidcContinuation);
}
// -- Login: recovery code ----------------------------------------
@@ -664,6 +669,7 @@ export class AuthController extends PuterController {
user_uid: string;
purpose: string;
auth_id?: string;
oidcContinuation?: unknown;
}>('otp', token);
} catch {
throw new HttpError(400, 'Invalid token.', {
@@ -688,6 +694,11 @@ export class AuthController extends PuterController {
}
await this.#assertSecondFactorAttemptsRemain(user.uuid);
if (typeof code !== 'string') {
throw new HttpError(400, 'code must be a string.', {
legacyCode: 'bad_request',
});
}
const hashed = hashRecoveryCode(code);
const codes = ((user.otp_recovery_codes as string) || '')
.split(',')
@@ -700,15 +711,15 @@ export class AuthController extends PuterController {
}
await this.#consumeOtpLoginToken(token);
// Consume the recovery code
codes.splice(idx, 1);
await this.stores.user.update(user.id, {
otp_recovery_codes: codes.join(','),
});
if (!(await this.stores.user.consumeOtpRecoveryCode(user.id, hashed))) {
await this.#recordSecondFactorFailure(user.uuid);
res.json({ proceed: false });
return;
}
await this.#enforceAuthIdMatch(req, user, decoded.auth_id ?? null);
await this.#completeLogin(req, res, user);
await this.#completeLogin(req, res, user, decoded.oidcContinuation);
}
// -- Signup ------------------------------------------------------
@@ -1932,7 +1943,7 @@ export class AuthController extends PuterController {
// A seat's address is admin-supplied and never verified, so whoever
// holds that inbox could take the seat over. Its recovery channel is
// the team admin's password reset, not this one.
if (await this.stores.team.getOrgSeat(user.id)) {
if (await this.stores.team?.getOrgSeat(user.id)) {
res.json({ message: genericMessage });
return;
}
@@ -2166,8 +2177,8 @@ export class AuthController extends PuterController {
// Best effort: the password is already committed, and an audit write
// must not skip the eviction below.
await this.services.team
.recordPasswordSelfChange(user.id as number)
.catch((err: unknown) => {
?.recordPasswordSelfChange(user.id as number)
?.catch((err: unknown) => {
console.warn('[team] password self-change audit failed:', err);
});
@@ -2222,8 +2233,8 @@ export class AuthController extends PuterController {
temp_password_expires_at: null,
});
await this.services.team
.recordPasswordSelfChange(user.id)
.catch((err: unknown) => {
?.recordPasswordSelfChange(user.id)
?.catch((err: unknown) => {
console.warn('[team] password self-change audit failed:', err);
});
@@ -2257,7 +2268,7 @@ export class AuthController extends PuterController {
// A provisioned account's name belongs to the team that made it: the
// console lists its members by username and the audit log records them
// by username, so a self-service rename would desync both.
if (await this.stores.team.getOrgSeat(req.actor!.user.id!)) {
if (await this.stores.team?.getOrgSeat(req.actor!.user.id!)) {
throw new HttpError(
403,
'Your team set this username. Ask a team admin to change it.',
@@ -2391,7 +2402,7 @@ export class AuthController extends PuterController {
async handleChangeEmail(req: Request, res: Response): Promise<void> {
// The address is where admin-issued credentials and team notices go;
// same reasoning as the username and deletion guards above.
if (await this.stores.team.getOrgSeat(req.actor!.user.id!)) {
if (await this.stores.team?.getOrgSeat(req.actor!.user.id!)) {
throw new HttpError(
403,
'Your team set this address. Ask a team admin to change it.',
@@ -2989,6 +3000,45 @@ export class AuthController extends PuterController {
return typeof permission === 'string' && permission ? [permission] : [];
}
/**
* Refuses any entry outside the families an app can be granted. Anything
* else is a row the backend never checks for an app, or one wider than the
* consent prompt can describe.
*/
async #assertAppGrantable(
actor: Actor,
appIdentifier: string,
list: string[],
): Promise<void> {
const refuse = (entry: string) =>
new HttpError(
400,
`Permission cannot be granted to an app: ${entry}`,
{ legacyCode: 'permission_not_grantable' },
);
let appUid: string | undefined;
for (const entry of list) {
// A delegation over a whole key-value namespace has no bounded
// description; refused with its own code.
assertBoundedManageGrant(entry);
const parsed = parseAppGrantablePermission(entry, actor.user!.uuid);
if (!parsed) throw refuse(entry);
if (parsed.family !== 'kvShare') continue;
// Held to what a new share's prefix may be, so a consent never
// covers shares that could not be made.
assertNewShareSegments(parsed.segments);
assertPrefixFitsAccess(
parsed.ownerUuid,
parsed.appUid,
parsed.segments.join(':'),
parsed.level,
);
// An app shares regions of its own namespace only.
appUid ??= (await this.stores.app.resolveApp(appIdentifier))?.uid;
if (parsed.appUid !== appUid) throw refuse(entry);
}
}
/**
* Gate a cross-app data grant: the target must exist, must not have opted
* out of sharing, and must be named. Also creates the target's AppData
@@ -3167,6 +3217,7 @@ export class AuthController extends PuterController {
}
// 400 on a bad value, before anything else runs. Absent means create.
const create = parseCreateFlag(req.body?.create);
await this.#assertAppGrantable(req.actor!, app_uid, list);
// Validate every entry before writing or creating anything, so a bad
// one in the list cannot leave a partially-granted set behind: the
@@ -3179,10 +3230,6 @@ export class AuthController extends PuterController {
kind: FsCreateKind;
}> = [];
for (const entry of list) {
// A delegation over a whole key-value namespace has no bounded
// description, so it is refused where it is asked for rather than
// prompted for and then refused at use.
assertBoundedManageGrant(entry);
await this.#prepareAppDataGrant(req.actor!, entry);
const plan = await this.#planFsPathCreate(
req.actor!,
@@ -3389,11 +3436,28 @@ export class AuthController extends PuterController {
const unique = [...new Set(permissions)] as string[];
const result: Record<string, boolean> = {};
// `fs:` reaches down a tree, and only ACL walks that.
// A share grant names its owner and prefix, which its holder is never
// told, so asking about one would confirm a guess. `manage:` arms are
// the app's own consent and stay answerable.
const answerable: string[] = [];
for (const permission of unique) {
if (
typeof permission === 'string' &&
isKvSharePermission(permission)
)
result[permission] = false;
else answerable.push(permission);
}
// `fs:` reaches down a tree, and only ACL walks that. An app asking
// about itself gets the same answer, so a grant on a parent folder
// doesn't read as missing and prompt again.
const viaAcl = new Set(
app_uid ? unique.filter((p) => aclAnswerableFsPermission(p)) : [],
app_uid || isAppActor(actor)
? answerable.filter((p) => aclAnswerableFsPermission(p))
: [],
);
const viaScan = unique.filter((p) => !viaAcl.has(p));
const viaScan = answerable.filter((p) => !viaAcl.has(p));
let granted: Map<string, boolean>;
try {
@@ -4122,7 +4186,7 @@ export class AuthController extends PuterController {
if (action === 'enable') {
// A seat has no email by design, so this would bar it forever.
const seat = await this.stores.team.getOrgSeat(user.id);
const seat = await this.stores.team?.getOrgSeat(user.id);
if (!user.email_confirmed && !seat) {
throw new HttpError(
403,
@@ -4196,7 +4260,7 @@ export class AuthController extends PuterController {
legacyCode: 'not_found',
});
const seat = await this.stores.team.getOrgSeat(user.id);
const seat = await this.stores.team?.getOrgSeat(user.id);
if (seat && Number(seat.require_2fa) === 1) {
throw new HttpError(
409,
@@ -4449,7 +4513,7 @@ export class AuthController extends PuterController {
const userId = req.actor!.user.id!;
// The team owns the account and is billed for it; only they may close
// it, through the console that keeps the audit trail.
if (await this.stores.team.getOrgSeat(userId)) {
if (await this.stores.team?.getOrgSeat(userId)) {
throw new HttpError(
403,
'Your team owns this account. Ask a team admin to remove it.',
@@ -4459,7 +4523,7 @@ export class AuthController extends PuterController {
// The other direction: deleting an owner sets `owner_user_id` NULL and
// cascades their membership, leaving a team no one can administer —
// not even an admin, since every team route resolves by membership.
if (await this.stores.team.countOwned(userId)) {
if (await this.stores.team?.countOwned(userId)) {
throw new HttpError(
403,
'Delete the teams you own before closing this account.',
@@ -4468,7 +4532,7 @@ export class AuthController extends PuterController {
}
// A deleted team's seats are suspended, not gone, and only its owner
// can retire them.
if (await this.stores.team.countOwnedTeamsWithAccounts(userId)) {
if (await this.stores.team?.countOwnedTeamsWithAccounts(userId)) {
throw new HttpError(
403,
'Close the accounts your teams created before closing this one.',
@@ -4621,7 +4685,15 @@ export class AuthController extends PuterController {
req: Request,
res: Response,
user: UserRow,
oidcContinuation?: unknown,
): Promise<void> {
const redirectUrl =
oidcContinuation === undefined
? undefined
: completeOidcLoginReturn(res, user, oidcContinuation, {
config: this.config,
oidc: this.services.oidc,
});
const gui_token = await startWebSession(req, res, user, {
config: this.config,
auth: this.services.auth,
@@ -4647,6 +4719,7 @@ export class AuthController extends PuterController {
next_step: 'complete',
token: gui_token,
user: details,
...(redirectUrl ? { redirectUrl } : {}),
});
}
}
@@ -6,7 +6,7 @@ import {
setupPuterTestEnv,
type PuterTestEnv,
} from '../../testUtil.js';
import type { IConfig } from '../../types.js';
import { installSeatStub } from '../../testFixtures/seatStub.js';
describe('account change second-factor verification', () => {
let env: PuterTestEnv;
@@ -14,7 +14,7 @@ describe('account change second-factor verification', () => {
const links = new Map<string, string>();
beforeAll(async () => {
env = await setupPuterTestEnv({ teams_enabled: true } as IConfig);
env = await setupPuterTestEnv();
vi.spyOn(env.server.clients.email, 'send').mockImplementation(
async (to, template, values) => {
if (template === 'email_verification_link')
@@ -333,15 +333,8 @@ describe('account change second-factor verification', () => {
});
it('does not let a valid code override a team requirement for 2FA', async () => {
const owner = await account();
const member = await account(true, true);
const team = await env.server.services.team.createTeam(owner.id, {
name: 'Required 2FA',
});
await env.server.stores.team.addMember(team.uid, member.id, {
orgOwned: true,
});
await env.server.services.team.updateTeam(team.uid, owner.id, {
installSeatStub(env.server as never, [member.id], {
require2fa: true,
});
const proof = env.server.services.token.sign('oidc-state', {
@@ -0,0 +1,96 @@
import crypto from 'node:crypto';
import type { Response } from 'express';
import { HttpError } from '../../core/http/HttpError.js';
import type { OIDCService } from '../../services/auth/OIDCService.js';
import type { UserRow } from '../../stores/user/UserStore.js';
import type { IConfig } from '../../types.js';
import { sessionCookieFlags } from '../../util/cookieFlags.js';
export const OIDC_POPUP_RETURN_COOKIE_NAME = 'puter_oidc_popup_return';
export const OIDC_POPUP_RETURN_EXPIRY_SEC = 300;
export interface OidcLoginContinuation {
userUuid: string;
redirectUri: string;
embeddedInPopup: boolean;
openerOrigin: string | null;
messageId: string | null;
switchedToLogin: boolean;
}
interface OidcReturnDeps {
config: IConfig;
oidc: OIDCService;
}
/** Bind an attested popup return to the browser that completed sign-in. */
export function mintOidcPopupReturn(
res: Response,
payload: Record<string, unknown>,
deps: OidcReturnDeps,
): string {
const nonce = crypto.randomBytes(32).toString('base64url');
res.cookie(OIDC_POPUP_RETURN_COOKIE_NAME, nonce, {
...sessionCookieFlags(deps.config, { crossSite: false }),
httpOnly: true,
maxAge: OIDC_POPUP_RETURN_EXPIRY_SEC * 1000,
path: '/',
});
return deps.oidc.signPopupReturn({ action: 'sign-in', ...payload, nonce });
}
/** Call only after both the provider and the account's second factor succeed. */
export function completeOidcLoginReturn(
res: Response,
user: Pick<UserRow, 'uuid'>,
continuation: unknown,
deps: OidcReturnDeps,
): string {
if (!continuation || typeof continuation !== 'object') {
throw new HttpError(400, 'Invalid login continuation.', {
legacyCode: 'bad_request',
});
}
const data = continuation as OidcLoginContinuation;
if (
data.userUuid !== user.uuid ||
typeof data.redirectUri !== 'string' ||
typeof data.embeddedInPopup !== 'boolean' ||
typeof data.switchedToLogin !== 'boolean' ||
(data.openerOrigin !== null && typeof data.openerOrigin !== 'string') ||
(data.messageId !== null && typeof data.messageId !== 'string')
) {
throw new HttpError(400, 'Invalid login continuation.', {
legacyCode: 'bad_request',
});
}
const origin = (deps.config.origin ?? '').replace(/\/$/, '');
let target = data.redirectUri || origin || '/';
try {
if (origin && new URL(target).origin !== new URL(origin).origin)
target = origin;
} catch {
target = origin || '/';
}
const append = (key: string, value: string) => {
target += `${target.includes('?') ? '&' : '?'}${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
};
if (data.embeddedInPopup) {
append('oidc_login', 'true');
append(
'opener_state',
mintOidcPopupReturn(
res,
{
opener_origin: data.openerOrigin,
msg_id: data.messageId,
oidc_login: true,
user_uuid: user.uuid,
},
deps,
),
);
}
if (data.switchedToLogin) append('oidc_switched', 'login');
return target;
}
@@ -0,0 +1,139 @@
import { v4 as uuidv4 } from 'uuid';
import {
afterAll,
afterEach,
beforeAll,
describe,
expect,
it,
vi,
} from 'vitest';
import { hashRecoveryCode } from '../../services/auth/OTPUtil.js';
import {
createTestUser,
setupPuterTestEnv,
type PuterTestEnv,
} from '../../testUtil.js';
describe('recovery code consumption', () => {
let env: PuterTestEnv;
const password = 'recovery-code-password';
beforeAll(async () => {
env = await setupPuterTestEnv();
}, 120_000);
afterAll(async () => {
await env?.shutdown();
});
afterEach(() => {
vi.restoreAllMocks();
});
// Hold the same cached snapshot until every concurrent request has read it.
const overlapReads = (userUuid: string, count: number) => {
const redis = env.server.clients.redis;
const get = redis.get.bind(redis);
const ready = Promise.withResolvers<void>();
let reads = 0;
vi.spyOn(redis, 'get').mockImplementation(async (key: string) => {
const value = await get(key);
if (key.includes(userUuid) && reads < count) {
if (++reads === count) ready.resolve();
await ready.promise;
}
return value;
});
};
const post = async (path: string, body: Record<string, unknown>) => {
const response = await fetch(new URL(path, env.origin), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body),
});
return { response, body: await response.json() };
};
const account = async (codes: string[]) => {
const username = `recovery_${uuidv4().slice(0, 8)}`;
await createTestUser(env.server, { username, password });
const user = (await env.server.stores.user.getByUsername(username))!;
await env.server.stores.user.update(user.id, {
otp_enabled: true,
otp_secret: 'TESTSECRETBASE32',
otp_recovery_codes: codes.map(hashRecoveryCode).join(','),
});
const challenge = async () => {
const { response, body } = await post('/login', {
username,
password,
});
expect(response.status).toBe(202);
expect(body.next_step).toBe('otp');
return body.otp_jwt_token as string;
};
return { user, challenge };
};
it('allows only one concurrent redemption of the same code', async () => {
const { user, challenge } = await account(['RECOVER1', 'RECOVER2']);
const tokens = await Promise.all([challenge(), challenge()]);
overlapReads(user.uuid, tokens.length);
const results = await Promise.all(
tokens.map((token) =>
post('/login/recovery-code', { token, code: 'RECOVER1' }),
),
);
expect(
results.filter((result) => result.body.next_step === 'complete'),
).toHaveLength(1);
expect(
results.filter((result) => result.body.proceed === false),
).toHaveLength(1);
const after = await env.server.stores.user.getById(user.id, {
force: true,
});
expect(after!.otp_recovery_codes).toBe(hashRecoveryCode('RECOVER2'));
});
it('consumes different concurrent codes without restoring either one', async () => {
const codes = ['RECOVER1', 'RECOVER2', 'RECOVER3'];
const { user, challenge } = await account(codes);
const tokens = await Promise.all(codes.map(() => challenge()));
overlapReads(user.uuid, tokens.length);
const results = await Promise.all(
codes.map((code, index) =>
post('/login/recovery-code', { token: tokens[index], code }),
),
);
expect(
results.every((result) => result.body.next_step === 'complete'),
).toBe(true);
const after = await env.server.stores.user.getById(user.id, {
force: true,
});
expect(after!.otp_recovery_codes).toBe('');
const replay = await post('/login/recovery-code', {
token: tokens[0],
code: codes[0],
});
expect(replay.body).toMatchObject({ proceed: false });
});
it.each([{ code: 123456 }, { code: {} }, { code: [] }])(
'rejects a non-string recovery code $code without consuming a code',
async ({ code }) => {
const { user, challenge } = await account(['RECOVER1']);
const result = await post('/login/recovery-code', {
token: await challenge(),
code,
});
expect(result.response.status).toBe(400);
expect(
(await env.server.stores.user.getById(user.id, {
force: true,
}))!.otp_recovery_codes,
).toBe(hashRecoveryCode('RECOVER1'));
},
);
});
@@ -154,8 +154,9 @@ export class EventsController extends PuterController {
// -- Cross-user key-value handles --------------------------------
/**
* POST /events/kv-handles — hand another user a watchable region of this
* account's key-value data.
* POST /events/kv-handles — hand another user a region of this account's
* key-value data, `watch` unless the body asks for more. The older name for
* `POST /kv/shares`, keeping its own body and response shapes.
*
* An account session, or an app session holding a `manage:` delegation on
* the region — the service tells those apart and gates each rather than the
@@ -167,12 +168,32 @@ export class EventsController extends PuterController {
subdomain: 'api',
requireAuth: true,
allowAccessToken: true,
requireVerified: true,
})
async mintKvHandle(req: Request, res: Response): Promise<void> {
const actor = this.#requireActor(req);
res.json(
await this.services.events.mintKvHandle(actor, bodyRecord(req)),
const body = bodyRecord(req);
const share = await this.services.kvShare.mintKvHandle(
actor,
{
prefix: body.prefix,
recipient: {
username:
typeof body.granteeUsername === 'string'
? body.granteeUsername
: undefined,
uid:
typeof body.granteeUid === 'string'
? body.granteeUid
: undefined,
},
access: body.access,
appUid: body.appUid,
},
// Callers of this route mint again to get the same handle back.
{ defaultAccess: 'watch', keepLevelUnlessNamed: true },
);
res.json({ handle: share.handle, prefix: share.prefix });
}
/**
@@ -189,7 +210,7 @@ export class EventsController extends PuterController {
const actor = this.#requireActor(req);
const query = (req.query ?? {}) as Record<string, unknown>;
const page = await this.services.events.listKvHandles(actor, {
const page = await this.services.kvShare.listKvHandles(actor, {
limit: normalizeLimit(query.limit, {
cap: KV_HANDLE_LIST_LIMIT_CAP,
}),
@@ -198,7 +219,10 @@ export class EventsController extends PuterController {
});
res.json({
items: page.items,
items: page.items.map(({ holderUsername, ...row }) => ({
...row,
granteeUsername: holderUsername,
})),
...(page.cursor ? { cursor: page.cursor } : {}),
...(page.total !== undefined ? { total: page.total } : {}),
});
@@ -217,7 +241,10 @@ export class EventsController extends PuterController {
async revokeKvHandle(req: Request, res: Response): Promise<void> {
const actor = this.#requireActor(req);
res.json(
await this.services.events.revokeKvHandle(actor, req.params.handle),
await this.services.kvShare.revokeKvHandle(
actor,
req.params.handle,
),
);
}
+10
View File
@@ -163,6 +163,16 @@ export const EVENTS_ACK_LIMIT = userWindow('events:ack', 600);
*/
export const EVENTS_KV_HANDLE_LIMIT = userWindow('events:kvHandles', 60);
/**
* `kv.share.received` notices per hour, per owner and holder. A share past it
* is still made, without a notice.
*/
export const KV_SHARE_NOTICE_LIMIT = userWindow(
'kvShare:notices',
5,
60 * 60_000,
);
/**
* Live share handles one account may hold out at a time, across every app.
*
@@ -1948,6 +1948,27 @@ describe('FSController.readEntry (file streaming)', () => {
expect(captured.statusCode).toBe(206);
});
it('returns 206 with Range honored when offset and byte_count query params are supplied', async () => {
const { actor, userId } = await makeUser();
const username = actor.user!.username!;
const body = Buffer.from('abcdefghij');
const target = `/${username}/Documents/ranged-query.bin`;
await writeFile(userId, target, body, 'application/octet-stream');
const { res, captured } = makeStreamingRes();
await withActor(actor, () =>
controller.readEntry(
makeReq({
query: { path: target, offset: '2', byte_count: '4' },
actor,
}),
res,
),
);
expect(captured.statusCode).toBe(206);
expect(Buffer.concat(captured.bodyChunks).toString()).toBe('cdef');
});
it('throws 404 when the path does not exist', async () => {
const { actor } = await makeUser();
const username = actor.user!.username!;
+2 -4
View File
@@ -83,6 +83,7 @@ import {
loadLegacyAssociatedApps,
replacedByMove,
resolveOrNotFound,
resolveReadRange,
signEntryThumbnail,
} from './legacyFsHelpers.js';
import type {
@@ -1495,10 +1496,7 @@ export class FSController extends PuterController {
);
}
const range =
typeof req.headers.range === 'string'
? req.headers.range
: undefined;
const range = resolveReadRange(req.headers.range, query);
const download = await this.services.fs.readContent(entry, {
range,
});
@@ -3732,6 +3732,63 @@ describe('LegacyFSController.read file streaming', () => {
);
expect(captured.headers['Content-Type']).toMatch(/text\/html/);
});
it('honors offset and byte_count query params on /read', async () => {
const { actor, userId } = await makeUser();
const username = actor.user!.username!;
const body = Buffer.from('0123456789abcdef');
const target = `/${username}/Documents/range-test.txt`;
await server.services.fs.write(userId, {
fileMetadata: {
path: target,
size: body.byteLength,
contentType: 'text/plain',
},
fileContent: body,
});
const { res, captured } = makeStreamingRes();
await withActor(actor, () =>
controller.read(
makeReq({
query: { file: target, offset: '4', byte_count: '6' },
actor,
}),
res,
),
);
await new Promise<void>((resolve) => setImmediate(resolve));
expect(captured.statusCode).toBe(206);
expect(Buffer.concat(captured.bodyChunks).toString()).toBe('456789');
});
it('rejects invalid offset query param with 400', async () => {
const { actor, userId } = await makeUser();
const username = actor.user!.username!;
const body = Buffer.from('test');
const target = `/${username}/Documents/invalid-offset.txt`;
await server.services.fs.write(userId, {
fileMetadata: {
path: target,
size: body.byteLength,
contentType: 'text/plain',
},
fileContent: body,
});
const { res } = makeStreamingRes();
await expect(
withActor(actor, () =>
controller.read(
makeReq({
query: { file: target, offset: '-1' },
actor,
}),
res,
),
),
).rejects.toMatchObject({ statusCode: 400 });
});
});
// ── /file directory listing ─────────────────────────────────────────
@@ -17,6 +17,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import type { Team2faLookup } from '../../core/http/middleware/gates.js';
import Busboy from 'busboy';
import type { NextFunction, Request, RequestHandler, Response } from 'express';
import { contentType as contentTypeFromMime } from 'mime-types';
@@ -102,6 +103,7 @@ import {
type GuiItemEventName,
loadLegacyAssociatedApps,
replacedByMove,
resolveReadRange,
resolveV1Selector,
signEntry,
signingConfigFromAppConfig,
@@ -1287,10 +1289,7 @@ export class LegacyFSController extends PuterController {
});
}
const range =
typeof req.headers.range === 'string'
? req.headers.range
: undefined;
const range = resolveReadRange(req.headers.range, query);
const download = await this.services.fs.readContent(entry, {
range,
});
@@ -1348,7 +1347,7 @@ export class LegacyFSController extends PuterController {
// that guard every other authenticated FS route have to run here.
assertNotSuspended(actor!.user);
assertVerifiedAccount(actor!.user);
await assertTeam2fa(actor!.user, this.stores.team);
await assertTeam2fa(actor!.user, this.stores.team as Team2faLookup);
req.actor = assertResolvedActor(actor!);
Context.set('actor', actor);
@@ -1803,10 +1802,7 @@ export class LegacyFSController extends PuterController {
}
// File: stream bytes with Range support.
const range =
typeof req.headers.range === 'string'
? req.headers.range
: undefined;
const range = resolveReadRange(req.headers.range, query);
const download = await this.services.fs.readContent(entry, {
range,
});
@@ -26,6 +26,7 @@ import {
getBoolean,
getString,
loadLegacyAssociatedApps,
resolveReadRange,
signEntry,
signEntryThumbnail,
signingConfigFromAppConfig,
@@ -618,3 +619,89 @@ describe('toLegacyEntry is_empty against stored children', () => {
expect((await shape()).is_empty).toBe(false);
});
});
describe('resolveReadRange', () => {
it('returns the explicit headersRange when provided', () => {
expect(
resolveReadRange('bytes=10-20', { offset: 0, byte_count: 5 }),
).toBe('bytes=10-20');
});
it('returns undefined when neither headersRange nor range query params are present', () => {
expect(resolveReadRange(undefined, {})).toBeUndefined();
expect(resolveReadRange('', {})).toBeUndefined();
});
it('derives a byte range from offset and byte_count', () => {
expect(resolveReadRange(undefined, { offset: 0, byte_count: 5 })).toBe(
'bytes=0-4',
);
expect(
resolveReadRange(undefined, { offset: 10, byte_count: 20 }),
).toBe('bytes=10-29');
expect(
resolveReadRange(undefined, { offset: '4', byte_count: '6' }),
).toBe('bytes=4-9');
});
it('derives an open range when only offset is provided', () => {
expect(resolveReadRange(undefined, { offset: 10 })).toBe('bytes=10-');
expect(resolveReadRange(undefined, { offset: '0' })).toBe('bytes=0-');
});
it('derives a prefix range when only byte_count is provided', () => {
expect(resolveReadRange(undefined, { byte_count: 15 })).toBe(
'bytes=0-14',
);
expect(resolveReadRange(undefined, { byte_count: '1' })).toBe(
'bytes=0-0',
);
});
it('throws 400 on invalid or negative offset', () => {
expect(() =>
resolveReadRange(undefined, { offset: -1, byte_count: 10 }),
).toThrow('Invalid offset');
expect(() =>
resolveReadRange(undefined, { offset: 1.5, byte_count: 10 }),
).toThrow('Invalid offset');
expect(() =>
resolveReadRange(undefined, { offset: 'abc', byte_count: 10 }),
).toThrow('Invalid offset');
});
it('throws 400 on non-decimal or unsafe numeric strings', () => {
for (const offset of [
'1e3',
'0x10',
' 4',
'1e21',
'9007199254740993',
]) {
expect(() => resolveReadRange(undefined, { offset })).toThrow(
'Invalid offset',
);
}
expect(() =>
resolveReadRange(undefined, {
offset: String(Number.MAX_SAFE_INTEGER),
byte_count: '3',
}),
).toThrow('Invalid byte_count');
});
it('throws 400 on non-positive or invalid byte_count', () => {
expect(() =>
resolveReadRange(undefined, { offset: 0, byte_count: 0 }),
).toThrow('Invalid byte_count');
expect(() =>
resolveReadRange(undefined, { offset: 0, byte_count: -5 }),
).toThrow('Invalid byte_count');
expect(() =>
resolveReadRange(undefined, { offset: 0, byte_count: 2.5 }),
).toThrow('Invalid byte_count');
expect(() =>
resolveReadRange(undefined, { offset: 0, byte_count: 'xyz' }),
).toThrow('Invalid byte_count');
});
});
@@ -99,6 +99,57 @@ export function getBoolean(
return undefined;
}
// Non-negative safe integer from a number or a plain digit string; anything
// else (exponents, hex, whitespace) is rejected.
function parseByteParam(raw: unknown, name: string, min: number): number {
const value =
typeof raw === 'number'
? raw
: typeof raw === 'string' && /^\d+$/.test(raw)
? Number(raw)
: NaN;
if (!Number.isSafeInteger(value) || value < min) {
throw new HttpError(400, `Invalid ${name}`, {
legacyCode: 'bad_request',
});
}
return value;
}
/**
* Resolves an HTTP Range header string from explicit `headersRange` or fallback
* `offset` / `byte_count` query parameters.
*/
export function resolveReadRange(
headersRange: unknown,
query: Record<string, unknown>,
): string | undefined {
if (typeof headersRange === 'string' && headersRange.length > 0) {
return headersRange;
}
const isSet = (v: unknown) => v !== undefined && v !== null && v !== '';
const offset = isSet(query.offset)
? parseByteParam(query.offset, 'offset', 0)
: undefined;
const byteCount = isSet(query.byte_count)
? parseByteParam(query.byte_count, 'byte_count', 1)
: undefined;
if (byteCount !== undefined) {
const last = (offset ?? 0) + byteCount - 1;
if (!Number.isSafeInteger(last)) {
throw new HttpError(400, 'Invalid byte_count', {
legacyCode: 'bad_request',
});
}
return `bytes=${offset ?? 0}-${last}`;
}
if (offset !== undefined) {
return `bytes=${offset}-`;
}
return undefined;
}
// Accepts either `{ path }` or `{ uid }` or `{ id }` or `{ parent, name }`
// from a legacy body field. Returns a resolved entry (throwing 404 if not
// found or 400 if no usable ref is present).
+2 -2
View File
@@ -27,12 +27,12 @@ import { EventsController } from './events/EventsController.js';
import { FSController } from './fs/FSController.js';
import { HomepageController } from './homepage/HomepageController.js';
import { HostingController } from './hosting/HostingController.js';
import { KvShareController } from './kvShare/KvShareController.js';
import { LegacyFSController } from './fs/LegacyFSController.js';
import { NotificationController } from './notification/NotificationController.js';
import { OIDCController } from './oidc/OIDCController.js';
import { PuterAIController } from './puterai/PuterAIController.js';
import { ShareController } from './share/ShareController.js';
import { TeamController } from './team/TeamController.js';
import { StaticAssetsController } from './static/StaticAssetsController.js';
import { StaticPagesController } from './static/StaticPagesController.js';
import { SystemController } from './system/SystemController.js';
@@ -59,8 +59,8 @@ export const puterControllers = {
broadcast: BroadcastController,
notification: NotificationController,
events: EventsController,
kvShare: KvShareController,
share: ShareController,
team: TeamController,
webdav: WebDAVController,
oidc: OIDCController,
wisp: WispController,
@@ -0,0 +1,478 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { v4 as uuidv4 } from 'uuid';
import {
afterAll,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
} from 'vitest';
import {
createTestUser,
setupPuterTestEnv,
type PuterTestEnv,
type TestUserCredentials,
} from '../../testUtil.js';
import type { IConfig } from '../../types.js';
import { EVENTS_KV_HANDLE_LIMIT } from '../events/limits.js';
const BOOT_TIMEOUT_MS = 120_000;
const call = (
env: PuterTestEnv,
method: string,
path: string,
token?: string,
body?: unknown,
) =>
fetch(new URL(path, env.apiOrigin), {
method,
headers: {
'content-type': 'application/json',
...(token ? { authorization: `Bearer ${token}` } : {}),
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
const resetBudgets = async (env: PuterTestEnv) => {
for (const name of [env.users.user.username, env.users.other.username]) {
const user = await env.server.stores.user.getByUsername(name);
await env.server.clients.redis.del(
`rate:${EVENTS_KV_HANDLE_LIMIT.scope}:${user!.id}`,
);
}
};
describe('/kv/shares', () => {
let env: PuterTestEnv;
let ownerUuid: string;
const req = (
method: string,
path: string,
token?: string,
body?: unknown,
) => call(env, method, path, token, body);
beforeAll(async () => {
env = await setupPuterTestEnv({
events: { enabled: true, kvHandles: true },
kv: { shareHandles: true },
unlimitedMetering: true,
} as IConfig);
const owner = await env.server.stores.user.getByUsername(
env.users.user.username,
);
ownerUuid = owner!.uuid as string;
}, BOOT_TIMEOUT_MS);
afterAll(async () => {
await env?.shutdown();
});
beforeEach(async () => {
await resetBudgets(env);
});
const shareWithOther = async (body: Record<string, unknown> = {}) => {
const res = await req('POST', '/kv/shares', env.users.user.token, {
prefix: `http-${uuidv4().slice(0, 8)}:`,
recipient: env.users.other.username,
...body,
});
expect(res.status).toBe(200);
return (await res.json()) as Record<string, unknown>;
};
it('shares at read by default and answers in the owner`s shape', async () => {
const shared = await shareWithOther({ label: 'Groceries' });
expect(Object.keys(shared).sort()).toEqual(
[
'access',
'appUid',
'createdAt',
'handle',
'holder',
'label',
'prefix',
].sort(),
);
expect(shared).toMatchObject({
access: 'read',
appUid: null,
holder: env.users.other.username,
label: 'Groceries',
});
});
it('takes the recipient as an object too, and refuses an address', async () => {
await shareWithOther({
recipient: { username: env.users.other.username },
access: 'write',
});
const refused = await req('POST', '/kv/shares', env.users.user.token, {
prefix: 'address:',
recipient: '[email protected]',
});
expect(refused.status).toBe(400);
await expect(refused.json()).resolves.toMatchObject({
code: 'invalid_recipient',
});
});
it('lists what was shared out, and what was shared with the holder without the owner', async () => {
const shared = await shareWithOther({ access: 'write', label: 'Trip' });
const byMe = await req(
'GET',
'/kv/shares?limit=200',
env.users.user.token,
);
const byMePage = (await byMe.json()) as {
items: Array<Record<string, unknown>>;
};
expect(
byMePage.items.find((row) => row.handle === shared.handle),
).toEqual({
handle: shared.handle,
prefix: shared.prefix,
access: 'write',
appUid: null,
holder: env.users.other.username,
label: 'Trip',
createdAt: shared.createdAt,
revokedAt: null,
revokedBy: null,
});
const withMe = await req(
'GET',
'/kv/shares/with-me?limit=200',
env.users.other.token,
);
const withMeText = await withMe.text();
const withMePage = JSON.parse(withMeText) as {
items: Array<Record<string, unknown>>;
};
expect(
withMePage.items.find((row) => row.handle === shared.handle),
).toEqual({
handle: shared.handle,
access: 'write',
appUid: null,
label: 'Trip',
createdAt: shared.createdAt,
});
expect(withMeText).not.toContain(env.users.user.username);
expect(withMeText).not.toContain(ownerUuid);
expect(withMeText).not.toContain(String(shared.prefix));
});
it('lets the holder unshare, and shows the owner they did', async () => {
const shared = await shareWithOther();
const removed = await req(
'DELETE',
`/kv/shares/${shared.handle}`,
env.users.other.token,
);
expect(removed.status).toBe(200);
await expect(removed.json()).resolves.toMatchObject({
handle: shared.handle,
});
const byMe = (await (
await req('GET', '/kv/shares?limit=200', env.users.user.token)
).json()) as { items: Array<Record<string, unknown>> };
expect(
byMe.items.find((row) => row.handle === shared.handle),
).toMatchObject({ revokedBy: 'holder' });
});
it('keeps the owner and prefix out of the holder`s grant audit after they unshare', async () => {
const shared = await shareWithOther({ access: 'write' });
const segment = String(shared.prefix).slice(0, -1);
const other = await env.server.stores.user.getByUsername(
env.users.other.username,
);
// A row an earlier build filed under the holder.
await env.server.stores.permission.auditUserUserPerm({
holder_user_id: other!.id,
issuer_user_id: other!.id,
permission: `kv-share-write:${ownerUuid}:os-global:${segment}`,
action: 'revoke',
reason: 'kv share handle unshared',
});
const removed = await req(
'DELETE',
`/kv/shares/${shared.handle}`,
env.users.other.token,
);
expect(removed.status).toBe(200);
// The revoke is filed under the owner, who granted it.
const auditOf = async (token: string) => {
const res = await req('GET', '/share/audit?limit=200', token);
expect(res.status).toBe(200);
return await res.text();
};
await vi.waitFor(async () => {
const owners = JSON.parse(await auditOf(env.users.user.token)) as {
items: Array<Record<string, unknown>>;
};
expect(owners.items).toContainEqual(
expect.objectContaining({
action: 'revoke',
issuer: env.users.user.username,
holder: env.users.other.username,
permission: expect.stringContaining(segment),
}),
);
});
const holders = await auditOf(env.users.other.token);
expect(holders).not.toContain(ownerUuid);
expect(holders).not.toContain(segment);
});
it('lets the owner revoke', async () => {
const shared = await shareWithOther();
const removed = await req(
'DELETE',
`/kv/shares/${shared.handle}`,
env.users.user.token,
);
expect(removed.status).toBe(200);
});
it('answers anyone else, and an unknown handle, with the same 404', async () => {
const shared = await shareWithOther();
const stranger = await createTestUser(env.server, {
username: `kvs-${uuidv4().slice(0, 8)}`,
password: 'pw-test-1234',
});
const strangers = await req(
'DELETE',
`/kv/shares/${shared.handle}`,
stranger.token,
);
const unknownHandle = `kvh-${uuidv4()}`;
const unknown = await req(
'DELETE',
`/kv/shares/${unknownHandle}`,
env.users.user.token,
);
expect(strangers.status).toBe(404);
expect(unknown.status).toBe(404);
const a = (await strangers.json()) as Record<string, unknown>;
const b = (await unknown.json()) as Record<string, unknown>;
expect(a.code).toBe(b.code);
expect(a.message).toBe(`No such handle: ${shared.handle}`);
expect(b.message).toBe(`No such handle: ${unknownHandle}`);
});
it('turns an anonymous caller away', async () => {
const res = await req('GET', '/kv/shares');
expect(res.status).toBeGreaterThanOrEqual(400);
});
});
describe('/events/kv-handles beside /kv/shares', () => {
let env: PuterTestEnv;
beforeAll(async () => {
env = await setupPuterTestEnv({
events: { enabled: true, kvHandles: true },
kv: { shareHandles: true },
unlimitedMetering: true,
} as IConfig);
}, BOOT_TIMEOUT_MS);
afterAll(async () => {
await env?.shutdown();
});
it('keeps its own request and response shapes, at watch by default', async () => {
const minted = await call(
env,
'POST',
'/events/kv-handles',
env.users.user.token,
{
granteeUsername: env.users.other.username,
prefix: 'alias-shape:',
},
);
expect(minted.status).toBe(200);
const body = (await minted.json()) as Record<string, unknown>;
expect(body).toEqual({ handle: body.handle, prefix: 'alias-shape:' });
const listed = (await (
await call(
env,
'GET',
'/events/kv-handles?limit=200',
env.users.user.token,
)
).json()) as { items: Array<Record<string, unknown>> };
const row = listed.items.find((one) => one.handle === body.handle);
expect(row).toMatchObject({
prefix: 'alias-shape:',
appUid: 'os-global',
granteeUsername: env.users.other.username,
access: 'watch',
revokedBy: null,
});
expect(row).not.toHaveProperty('holder');
// The alias revoke stays the owner's: the holder is told it is absent.
const byHolder = await call(
env,
'DELETE',
`/events/kv-handles/${body.handle}`,
env.users.other.token,
);
expect(byHolder.status).toBe(404);
});
it('hands back a share it finds at the level it has, unless the body names one', async () => {
const shared = (await (
await call(env, 'POST', '/kv/shares', env.users.user.token, {
prefix: 'alias-keep:',
recipient: env.users.other.username,
access: 'write',
})
).json()) as Record<string, unknown>;
const accessOf = async () => {
const listed = (await (
await call(
env,
'GET',
'/kv/shares?limit=200',
env.users.user.token,
)
).json()) as { items: Array<Record<string, unknown>> };
return listed.items.find((one) => one.handle === shared.handle)
?.access;
};
const mintAlias = (extra: Record<string, unknown> = {}) =>
call(env, 'POST', '/events/kv-handles', env.users.user.token, {
granteeUsername: env.users.other.username,
prefix: 'alias-keep:',
...extra,
});
const again = await mintAlias();
expect(again.status).toBe(200);
await expect(again.json()).resolves.toEqual({
handle: shared.handle,
prefix: 'alias-keep:',
});
expect(await accessOf()).toBe('write');
expect((await mintAlias({ access: 'read' })).status).toBe(200);
expect(await accessOf()).toBe('read');
});
});
/**
* Sharing asks for a confirmed email and nothing else. Booted with an SMS
* provider configured, so a phone requirement would bite here if one were ever
* added to these routes.
*/
describe('verification on the mint routes', () => {
let env: PuterTestEnv;
beforeAll(async () => {
env = await setupPuterTestEnv({
events: { enabled: true, kvHandles: true },
kv: { shareHandles: true },
unlimitedMetering: true,
strict_email_verification_required: true,
prelude: { apiKey: 'test-key' },
} as IConfig);
}, BOOT_TIMEOUT_MS);
afterAll(async () => {
await env?.shutdown();
});
const makeUser = async (
patch: Record<string, unknown> = {},
): Promise<TestUserCredentials> => {
const username = `kvv${uuidv4().slice(0, 8)}`;
const creds = await createTestUser(env.server, {
username,
password: 'pw-test-1234',
});
if (Object.keys(patch).length > 0) {
const row = await env.server.stores.user.getByUsername(username);
await env.server.stores.user.update(row!.id, patch);
}
return creds;
};
const mintBoth = (owner: TestUserCredentials, prefix: string) =>
Promise.all([
call(env, 'POST', '/kv/shares', owner.token, {
prefix: `${prefix}a:`,
recipient: env.users.other.username,
}),
call(env, 'POST', '/events/kv-handles', owner.token, {
prefix: `${prefix}b:`,
granteeUsername: env.users.other.username,
}),
]);
it('refuses an account whose email is not confirmed, on both routes', async () => {
const owner = await makeUser();
for (const res of await mintBoth(owner, 'unconfirmed')) {
expect(res.status).toBe(403);
await expect(res.json()).resolves.toMatchObject({
code: 'account_is_not_verified',
});
}
});
it('lets a confirmed account share with no phone and no card', async () => {
const owner = await makeUser({
email: `kvv-${uuidv4().slice(0, 8)}@example.invalid`,
email_confirmed: 1,
});
for (const res of await mintBoth(owner, 'confirmed'))
expect(res.status).toBe(200);
});
it('never gates listing or unsharing', async () => {
const owner = await makeUser();
for (const path of ['/kv/shares', '/kv/shares/with-me'])
expect((await call(env, 'GET', path, owner.token)).status).toBe(
200,
);
const removed = await call(
env,
'DELETE',
`/kv/shares/kvh-${uuidv4()}`,
owner.token,
);
expect(removed.status).toBe(404);
});
});
@@ -0,0 +1,172 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import type { Request, Response } from 'express';
import type { Actor } from '../../core/actor.js';
import { Controller, Delete, Get, Post } from '../../core/http/decorators.js';
import { HttpError } from '../../core/http/HttpError.js';
import { bodyRecord } from '../../core/http/requestBody.js';
import { parseKvShareRecipient } from '../../services/kvShare/kvShares.js';
import type { KvShareHandleListOptions } from '../../stores/events/KvShareHandleStore.js';
import { KV_HANDLE_LIST_LIMIT_CAP } from '../../stores/events/KvShareHandleStore.js';
import { KV_GLOBAL_APP_KEY } from '../../stores/systemKv/SystemKVStore.js';
import { normalizeLimit } from '../../util/pagination.js';
import { EVENTS_LIST_LIMIT } from '../events/limits.js';
import { PuterController } from '../types.js';
/** The app-less namespace is no app, so the wire says `null` for it. */
const publicAppUid = (appUid: string): string | null =>
appUid === KV_GLOBAL_APP_KEY ? null : appUid;
const pageOptions = (req: Request): KvShareHandleListOptions => {
const query = (req.query ?? {}) as Record<string, unknown>;
return {
limit: normalizeLimit(query.limit, { cap: KV_HANDLE_LIST_LIMIT_CAP }),
cursor: typeof query.cursor === 'string' ? query.cursor : undefined,
includeTotal: query.includeTotal === 'true',
};
};
/**
* Sharing a region of one account's key-value data with another account.
*
* Gated like the events surface: `subdomain: 'api'` plus `allowAccessToken`,
* with app-ness decided from `effectiveApp` in the service, which is where the
* owner, holder, delegation and namespace rules live. Only sharing asks for a
* confirmed email; listing, revoking and unsharing only narrow or report.
*/
@Controller('/kv')
export class KvShareController extends PuterController {
/**
* POST /kv/shares — share a region with another account, `read` unless the
* body asks otherwise. Sharing the same region with the same account again
* changes its level or label in place.
*/
@Post('/shares', {
subdomain: 'api',
requireAuth: true,
allowAccessToken: true,
requireVerified: true,
})
async share(req: Request, res: Response): Promise<void> {
const actor = this.#requireActor(req);
const body = bodyRecord(req);
const share = await this.services.kvShare.mintKvHandle(
actor,
{
prefix: body.prefix,
recipient: parseKvShareRecipient(body.recipient),
access: body.access,
appUid: body.appUid,
label: body.label,
},
{ defaultAccess: 'read' },
);
res.json({
handle: share.handle,
prefix: share.prefix,
access: share.access,
appUid: publicAppUid(share.appUid),
holder: share.holderUsername,
label: share.label,
createdAt: share.createdAt,
});
}
/** GET /kv/shares — what this account has shared out, retired included. */
@Get('/shares', {
subdomain: 'api',
requireAuth: true,
allowAccessToken: true,
rateLimit: EVENTS_LIST_LIMIT,
})
async listSharedByMe(req: Request, res: Response): Promise<void> {
const actor = this.#requireActor(req);
const page = await this.services.kvShare.listKvHandles(
actor,
pageOptions(req),
);
res.json({
items: page.items.map((row) => ({
handle: row.handle,
prefix: row.prefix,
access: row.access,
appUid: publicAppUid(row.appUid),
holder: row.holderUsername,
label: row.label,
createdAt: row.createdAt,
revokedAt: row.revokedAt,
revokedBy: row.revokedBy,
})),
...(page.cursor ? { cursor: page.cursor } : {}),
...(page.total !== undefined ? { total: page.total } : {}),
});
}
/** GET /kv/shares/with-me — live shares this account holds. */
@Get('/shares/with-me', {
subdomain: 'api',
requireAuth: true,
allowAccessToken: true,
rateLimit: EVENTS_LIST_LIMIT,
})
async listSharedWithMe(req: Request, res: Response): Promise<void> {
const actor = this.#requireActor(req);
const page = await this.services.kvShare.listKvSharesWithMe(
actor,
pageOptions(req),
);
res.json({
items: page.items.map((row) => ({
handle: row.handle,
access: row.access,
appUid: publicAppUid(row.appUid),
label: row.label,
createdAt: row.createdAt,
})),
...(page.cursor ? { cursor: page.cursor } : {}),
...(page.total !== undefined ? { total: page.total } : {}),
});
}
/**
* DELETE /kv/shares/:handle — the owner revokes, the holder gives it up,
* and anyone else is told it is not there.
*/
@Delete('/shares/:handle', {
subdomain: 'api',
requireAuth: true,
allowAccessToken: true,
})
async unshare(req: Request, res: Response): Promise<void> {
const actor = this.#requireActor(req);
res.json(
await this.services.kvShare.removeKvShare(actor, req.params.handle),
);
}
#requireActor(req: Request): Actor {
const actor = req.actor;
if (!actor?.user)
throw new HttpError(401, 'Unauthorized', {
legacyCode: 'unauthorized',
});
return actor;
}
}
@@ -17,6 +17,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { installSeatStub } from '../../testFixtures/seatStub.js';
import type { Request, RequestHandler, Response } from 'express';
import {
afterAll,
@@ -233,20 +234,14 @@ const callRoute = async (
);
};
const oidc = (): OIDCService =>
server.services.oidc as unknown as OIDCService;
const oidc = (): OIDCService => server.services.oidc as unknown as OIDCService;
// ── GET /auth/oidc/providers ────────────────────────────────────────
describe('OIDCController GET /auth/oidc/providers', () => {
it('lists every provider whose config validates', async () => {
const { res, captured } = makeRes();
await callRoute(
'get',
'/auth/oidc/providers',
makeReq({}),
res,
);
await callRoute('get', '/auth/oidc/providers', makeReq({}), res);
// We configured a single `custom` provider; the live service
// walks the config and returns its id.
expect(captured.body).toEqual({ providers: ['custom'] });
@@ -417,9 +412,9 @@ describe('OIDCController GET /auth/oidc/:provider/start', () => {
}),
res,
);
const state = new URL(
captured.redirectUrl ?? '',
).searchParams.get('state');
const state = new URL(captured.redirectUrl ?? '').searchParams.get(
'state',
);
const decoded = oidc().verifyState(state!);
expect(String(decoded?.redirect_uri)).toBe(TEST_ORIGIN);
}
@@ -624,10 +619,7 @@ describe('OIDCController login callback', () => {
// The real OIDCService linked the new user — verify via the
// live store.
const linkedUser = await oidc().findUserByProviderSub(
'custom',
sub,
);
const linkedUser = await oidc().findUserByProviderSub('custom', sub);
expect(linkedUser).not.toBeNull();
expect(linkedUser?.email).toBe(email);
});
@@ -791,14 +783,12 @@ describe('OIDCController login callback', () => {
// Seed by running the linked-sub path first via createUserFromOIDC.
const sub = `sub-${Math.random().toString(36).slice(2, 8)}`;
const email = `oidc-${Math.random().toString(36).slice(2, 8)}@test.local`;
const created = await runWithContext(
{ req: makeReq({}) },
() =>
oidc().createUserFromOIDC('custom', {
sub,
email,
email_verified: true,
}),
const created = await runWithContext({ req: makeReq({}) }, () =>
oidc().createUserFromOIDC('custom', {
sub,
email,
email_verified: true,
}),
);
expect(created.success).toBe(true);
const firstUserId = created.user!.id;
@@ -838,14 +828,12 @@ describe('OIDCController login callback', () => {
it('redirects with auth_error when the user is suspended', async () => {
const sub = `sub-${Math.random().toString(36).slice(2, 8)}`;
const email = `sus-${Math.random().toString(36).slice(2, 8)}@test.local`;
const created = await runWithContext(
{ req: makeReq({}) },
() =>
oidc().createUserFromOIDC('custom', {
sub,
email,
email_verified: true,
}),
const created = await runWithContext({ req: makeReq({}) }, () =>
oidc().createUserFromOIDC('custom', {
sub,
email,
email_verified: true,
}),
);
expect(created.success).toBe(true);
// Flip the suspended bit on the existing row.
@@ -980,14 +968,12 @@ describe('OIDCController login callback', () => {
it('keeps an /app/<name> landing in the error redirect (suspended user)', async () => {
const sub = `sub-${Math.random().toString(36).slice(2, 8)}`;
const email = `sus-app-${Math.random().toString(36).slice(2, 8)}@test.local`;
const created = await runWithContext(
{ req: makeReq({}) },
() =>
oidc().createUserFromOIDC('custom', {
sub,
email,
email_verified: true,
}),
const created = await runWithContext({ req: makeReq({}) }, () =>
oidc().createUserFromOIDC('custom', {
sub,
email,
email_verified: true,
}),
);
expect(created.success).toBe(true);
await server.stores.user.update(created.user!.id, { suspended: 1 });
@@ -1075,14 +1061,12 @@ describe('OIDCController login callback', () => {
it('uses popup-style error URL (msg_id + opener_origin) when the popup-state user is suspended', async () => {
const sub = `sub-${Math.random().toString(36).slice(2, 8)}`;
const email = `pop-sus-${Math.random().toString(36).slice(2, 8)}@test.local`;
const created = await runWithContext(
{ req: makeReq({}) },
() =>
oidc().createUserFromOIDC('custom', {
sub,
email,
email_verified: true,
}),
const created = await runWithContext({ req: makeReq({}) }, () =>
oidc().createUserFromOIDC('custom', {
sub,
email,
email_verified: true,
}),
);
expect(created.success).toBe(true);
await server.stores.user.update(created.user!.id, { suspended: 1 });
@@ -1232,13 +1216,7 @@ describe('OIDCController login callback', () => {
email,
free_storage: 100 * 1024 * 1024,
});
const team = await server.stores.team.create({
ownerUserId: owner.id,
name: 'Acme',
});
await server.stores.team.addMember(team.uid, seat.id, {
orgOwned: true,
});
installSeatStub(server as never, [seat.id]);
// Provisioning issues a temporary password once the seat exists.
await server.stores.user.update(seat.id, { password: 'hashed' });
return seat;
@@ -1334,14 +1312,12 @@ describe('OIDCController signup veto (abuse harness)', () => {
const email = `veto-${Math.random().toString(36).slice(2, 8)}@test.local`;
vetoWithTrail(email, 'trail-svc-1');
const created = await runWithContext(
{ req: makeReq({}) },
() =>
oidc().createUserFromOIDC('custom', {
sub: `sub-${Math.random().toString(36).slice(2, 8)}`,
email,
email_verified: true,
}),
const created = await runWithContext({ req: makeReq({}) }, () =>
oidc().createUserFromOIDC('custom', {
sub: `sub-${Math.random().toString(36).slice(2, 8)}`,
email,
email_verified: true,
}),
);
expect(created.success).toBe(false);
expect(created.code).toBe('signup_blocked');
@@ -1676,14 +1652,12 @@ describe('OIDCController signup callback', () => {
// resolves to `linked-sub` rather than creating a new user.
const sub = `sub-${Math.random().toString(36).slice(2, 8)}`;
const email = `existing-${Math.random().toString(36).slice(2, 8)}@test.local`;
const created = await runWithContext(
{ req: makeReq({}) },
() =>
oidc().createUserFromOIDC('custom', {
sub,
email,
email_verified: true,
}),
const created = await runWithContext({ req: makeReq({}) }, () =>
oidc().createUserFromOIDC('custom', {
sub,
email,
email_verified: true,
}),
);
expect(created.success).toBe(true);
@@ -1755,14 +1729,12 @@ describe('OIDCController signup callback', () => {
it('redirects suspended users to action=signup with message=account_suspended', async () => {
const sub = `sub-${Math.random().toString(36).slice(2, 8)}`;
const email = `sus-${Math.random().toString(36).slice(2, 8)}@test.local`;
const created = await runWithContext(
{ req: makeReq({}) },
() =>
oidc().createUserFromOIDC('custom', {
sub,
email,
email_verified: true,
}),
const created = await runWithContext({ req: makeReq({}) }, () =>
oidc().createUserFromOIDC('custom', {
sub,
email,
email_verified: true,
}),
);
expect(created.success).toBe(true);
await server.stores.user.update(created.user!.id, { suspended: 1 });
@@ -1899,14 +1871,12 @@ describe('OIDCController revalidate callback', () => {
// The linked account exists, but state.user_uuid points at someone else.
const sub = `sub-${Math.random().toString(36).slice(2, 8)}`;
const email = `reval-${Math.random().toString(36).slice(2, 8)}@test.local`;
const created = await runWithContext(
{ req: makeReq({}) },
() =>
oidc().createUserFromOIDC('custom', {
sub,
email,
email_verified: true,
}),
const created = await runWithContext({ req: makeReq({}) }, () =>
oidc().createUserFromOIDC('custom', {
sub,
email,
email_verified: true,
}),
);
expect(created.success).toBe(true);
@@ -1938,14 +1908,12 @@ describe('OIDCController revalidate callback', () => {
it('happy path: sets the revalidation cookie and redirects to redirect-done', async () => {
const sub = `sub-${Math.random().toString(36).slice(2, 8)}`;
const email = `reval-${Math.random().toString(36).slice(2, 8)}@test.local`;
const created = await runWithContext(
{ req: makeReq({}) },
() =>
oidc().createUserFromOIDC('custom', {
sub,
email,
email_verified: true,
}),
const created = await runWithContext({ req: makeReq({}) }, () =>
oidc().createUserFromOIDC('custom', {
sub,
email,
email_verified: true,
}),
);
expect(created.success).toBe(true);
const userUuid = created.user!.uuid;
@@ -1992,14 +1960,12 @@ describe('OIDCController revalidate callback', () => {
it('falls back to /auth/revalidate-done when state.redirect_uri is cross-origin', async () => {
const sub = `sub-${Math.random().toString(36).slice(2, 8)}`;
const email = `reval-${Math.random().toString(36).slice(2, 8)}@test.local`;
const created = await runWithContext(
{ req: makeReq({}) },
() =>
oidc().createUserFromOIDC('custom', {
sub,
email,
email_verified: true,
}),
const created = await runWithContext({ req: makeReq({}) }, () =>
oidc().createUserFromOIDC('custom', {
sub,
email,
email_verified: true,
}),
);
expect(created.success).toBe(true);
const userUuid = created.user!.uuid;
@@ -2038,12 +2004,7 @@ describe('OIDCController revalidate callback', () => {
describe('OIDCController GET /auth/revalidate-done', () => {
it('renders the postMessage HTML body with the configured origin', async () => {
const { res, captured } = makeRes();
await callRoute(
'get',
'/auth/revalidate-done',
makeReq({}),
res,
);
await callRoute('get', '/auth/revalidate-done', makeReq({}), res);
expect(captured.contentType).toContain('text/html');
const body = String(captured.body);
expect(body).toContain('puter-revalidate-done');
@@ -2057,9 +2018,9 @@ describe('OIDCController GET /auth/revalidate-done', () => {
/**
* The proof exists because the popup return leg states two things the popup
* cannot check — the opener's origin and that a login completed — and a URL
* built from a verified `state` is byte-identical to one anybody can type.
* The opener's origin picks the app a token is minted for, so it has to be
* attested rather than read.
* built from a verified `state` is byte-identical to one anybody can type. The
* opener's origin picks the app a token is minted for, so it has to be attested
* rather than read.
*/
describe('OIDCController POST /auth/oidc/verify-popup-return', () => {
const NONCE = 'binding-nonce';
+72 -63
View File
@@ -24,6 +24,12 @@ import { HttpError } from '../../core/http/HttpError.js';
import type { PuterRouter } from '../../core/http/PuterRouter.js';
import { PuterController } from '../types.js';
import { startWebSession } from '../auth/webSession.js';
import {
completeOidcLoginReturn,
mintOidcPopupReturn,
OIDC_POPUP_RETURN_COOKIE_NAME,
type OidcLoginContinuation,
} from '../auth/oidcLoginReturn.js';
import type { UserRow } from '../../stores/user/UserStore.js';
import { sessionCookieFlags } from '../../util/cookieFlags.js';
import { normalizeBonusCode } from '../../util/signupBonus.js';
@@ -43,11 +49,6 @@ const REVALIDATION_EXPIRY_SEC = 300;
const OIDC_NONCE_COOKIE_NAME = 'puter_oidc_nonce';
const OIDC_NONCE_EXPIRY_SEC = 600;
// Single-use, and binds a popup-return proof to the browser that earned it.
// Expiry mirrors POPUP_RETURN_EXPIRY_SEC in OIDCService.
const OIDC_POPUP_RETURN_COOKIE_NAME = 'puter_oidc_popup_return';
const OIDC_POPUP_RETURN_EXPIRY_SEC = 300;
// The popup branch below hard-codes this return target.
const POPUP_RETURN_ACTION = 'sign-in';
@@ -284,11 +285,6 @@ function buildErrorRedirectUrl(
return `${base}${pagePath}?${params.toString()}`;
}
function appendQueryParam(url: string, key: string, value: string): string {
const sep = url.includes('?') ? '&' : '?';
return `${url}${sep}${encodeURIComponent(key)}=${encodeURIComponent(value)}`;
}
/** Length-safe constant-time string compare (never throws on mismatch). */
function constantTimeEqual(a: string, b: string): boolean {
const ba = Buffer.from(a);
@@ -803,18 +799,9 @@ if (window.opener) {
/** Sign a popup-return proof, bound to this browser and this popup. */
#mintPopupReturn(res: Response, payload: Record<string, unknown>): string {
const nonce = crypto.randomBytes(32).toString('base64url');
res.cookie(OIDC_POPUP_RETURN_COOKIE_NAME, nonce, {
// Redeemed same-origin.
...sessionCookieFlags(this.config, { crossSite: false }),
httpOnly: true,
maxAge: OIDC_POPUP_RETURN_EXPIRY_SEC * 1000,
path: '/',
});
return this.services.oidc.signPopupReturn({
action: POPUP_RETURN_ACTION,
...payload,
nonce,
return mintOidcPopupReturn(res, payload, {
config: this.config,
oidc: this.services.oidc,
});
}
@@ -872,7 +859,7 @@ if (window.opener) {
byEmail !== null &&
!byEmail.email_confirmed &&
userinfo.email_verified === true &&
(await this.services.team.releaseUnconfirmedSeatEmail(
(await this.services.team?.releaseUnconfirmedSeatEmail(
byEmail.id,
));
if (byEmail && !releasedFromSeat) {
@@ -1012,49 +999,71 @@ if (window.opener) {
stateDecoded: Record<string, unknown>,
extraQueryParams?: Record<string, string>,
): Promise<void> {
const origin = (this.config.origin ?? '').replace(/\/$/, '');
const continuation: OidcLoginContinuation = {
userUuid: user.uuid,
redirectUri:
typeof stateDecoded.redirect_uri === 'string'
? stateDecoded.redirect_uri
: origin || '/',
embeddedInPopup: stateDecoded.embedded_in_popup === true,
openerOrigin:
typeof stateDecoded.opener_origin === 'string'
? stateDecoded.opener_origin
: null,
messageId:
stateDecoded.msg_id == null
? null
: String(stateDecoded.msg_id),
switchedToLogin: extraQueryParams?.oidc_switched === 'login',
};
if (user.otp_enabled) {
const challenge = this.services.token.sign(
'otp',
{
purpose: 'otp-login',
user_uid: user.uuid,
jti: crypto.randomUUID(),
oidcContinuation: continuation,
},
{ expiresIn: '5m' },
);
res.set('Cache-Control', 'no-store');
res.set('Referrer-Policy', 'no-referrer');
let retryUrl = `${origin}/action/login`;
if (continuation.embeddedInPopup) {
const retry = new URL(
buildErrorRedirectUrl(
origin,
'login',
'other',
'unauthorized',
stateDecoded,
undefined,
(payload) => this.#mintPopupReturn(res, payload),
),
);
retry.searchParams.delete('auth_error');
retry.searchParams.delete('message');
retryUrl = retry.href;
}
const fragment = new URLSearchParams({
token: challenge,
retryUrl,
});
res.redirect(302, `${origin}/action/oidc-2fa#${fragment}`);
return;
}
await startWebSession(req, res, user, {
config: this.config,
auth: this.services.auth,
});
const origin = (this.config.origin ?? '').replace(/\/$/, '');
let target = (stateDecoded.redirect_uri as string) || origin || '/';
if (!isSameOrigin(target, origin)) {
target = origin || '/';
}
if (stateDecoded.embedded_in_popup) {
target = appendQueryParam(target, 'oidc_login', 'true');
// `opener_origin` and `oidc_login` reach the popup as bare query
// parameters, which say nothing about where they came from: the
// URL a verified state produces is byte-identical to one anybody
// can type. The popup treats the opener's origin as the app
// identity to mint a token for, so it needs the integrity this
// state already carries — re-signed here, at the one point where
// the round trip is known to have actually happened.
//
// `user_uuid` binds the proof to the account that just completed
// OIDC. Without it a proof from one login could be replayed in
// another signed-in browser to skip its account picker; the popup
// only honors `oidc_login` when this matches its current user.
target = appendQueryParam(
target,
'opener_state',
this.#mintPopupReturn(res, {
opener_origin: stateDecoded.opener_origin ?? null,
msg_id: stateDecoded.msg_id ?? null,
oidc_login: true,
user_uuid: user.uuid,
}),
);
}
if (extraQueryParams) {
for (const [k, v] of Object.entries(extraQueryParams)) {
if (v != null) target = appendQueryParam(target, k, v);
}
}
res.redirect(302, target);
res.redirect(
302,
completeOidcLoginReturn(res, user, continuation, {
config: this.config,
oidc: this.services.oidc,
}),
);
}
}
@@ -0,0 +1,323 @@
import { TOTP } from 'otpauth';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { v4 as uuidv4 } from 'uuid';
import {
setupPuterTestEnv,
createTestUser,
type PuterTestEnv,
} from '../../testUtil.js';
import type { IConfig } from '../../types.js';
import { hashRecoveryCode } from '../../services/auth/OTPUtil.js';
describe('OIDC account second-factor login', () => {
let env: PuterTestEnv;
let claims: { sub: string; email: string; email_verified: boolean };
beforeAll(async () => {
env = await setupPuterTestEnv({
cookie_name: 'puter_auth_token',
oidc: {
providers: {
custom: {
client_id: 'test-client',
client_secret: 'test-secret',
authorization_endpoint: 'https://idp.example/authorize',
token_endpoint: 'https://idp.example/token',
userinfo_endpoint: 'https://idp.example/userinfo',
},
},
},
} as IConfig);
vi.spyOn(
env.server.services.oidc,
'exchangeCodeForTokens',
).mockResolvedValue({ access_token: 'test-access' } as never);
vi.spyOn(env.server.services.oidc, 'getUserInfo').mockImplementation(
async () => claims,
);
}, 120_000);
afterAll(async () => {
await env?.shutdown();
});
const account = async (enabled = true, linked = true) => {
const username = `oidc2fa-${uuidv4().slice(0, 8)}`;
await createTestUser(env.server, {
username,
password: 'oidc-test-password',
});
const user = (await env.server.stores.user.getByUsername(username))!;
const otp = new TOTP({
issuer: 'puter.com',
label: username,
algorithm: 'SHA1',
digits: 6,
});
claims = {
sub: uuidv4(),
email: `${username}@example.com`,
email_verified: true,
};
await env.server.stores.user.update(user.id, {
email: claims.email,
email_confirmed: true,
otp_enabled: enabled,
otp_secret: otp.secret.base32,
otp_recovery_codes: hashRecoveryCode('ABCDEFG2'),
});
if (linked) {
await env.server.services.oidc.linkProviderToUser(
user.id,
'custom',
claims,
);
}
return { user, otp };
};
const callback = async (
flow = 'login',
extra: Record<string, string> = {},
) => {
const start = new URL(
`/auth/oidc/custom/start?flow=${flow}`,
env.origin,
);
Object.entries(extra).forEach(([key, value]) =>
start.searchParams.set(key, value),
);
const begun = await fetch(start, { redirect: 'manual' });
expect(begun.status).toBe(302);
const state = new URL(begun.headers.get('location')!).searchParams.get(
'state',
)!;
const nonce = begun.headers
.getSetCookie()
.find((value) => value.startsWith('puter_oidc_nonce='))!
.split(';')[0]!;
return fetch(
new URL(
`/auth/oidc/callback/${flow}?code=test-code&state=${encodeURIComponent(state)}`,
env.origin,
),
{ redirect: 'manual', headers: { cookie: nonce } },
);
};
const verify = (token: string, code: string, recovery = false) =>
fetch(
new URL(
recovery ? '/login/recovery-code' : '/login/otp',
env.origin,
),
{
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ token, code }),
},
);
const challenge = (response: Response) => {
expect(response.status).toBe(302);
expect(
response.headers
.getSetCookie()
.some((value) => value.startsWith('puter_auth_token=')),
).toBe(false);
const location = new URL(response.headers.get('location')!);
expect(location.pathname).toBe('/action/oidc-2fa');
const token = new URLSearchParams(location.hash.slice(1)).get('token');
expect(token).toBeTruthy();
return token!;
};
it.each(['login', 'signup'])(
'requires the authenticator before completing existing-account %s',
async (flow) => {
const { otp } = await account();
const token = challenge(
await callback(flow, { return_to: '/dashboard' }),
);
const response = await verify(token, otp.generate());
expect(response.status).toBe(200);
expect(await response.json()).toMatchObject({
proceed: true,
next_step: 'complete',
redirectUrl: `${env.origin}/dashboard${flow === 'signup' ? '?oidc_switched=login' : ''}`,
});
expect(
response.headers
.getSetCookie()
.some((value) => value.startsWith('puter_auth_token=')),
).toBe(true);
expect((await verify(token, otp.generate())).status).toBe(400);
},
);
it('incorrect codes do not start a session and allow a valid recovery-code retry', async () => {
const { otp } = await account();
const token = challenge(await callback());
const wrong = ['000000', '000001', '000002'].find(
(code) => otp.validate({ token: code }) === null,
)!;
const rejected = await verify(token, wrong);
expect(await rejected.json()).toMatchObject({ proceed: false });
expect(rejected.headers.getSetCookie()).toHaveLength(0);
const accepted = await verify(token, 'ABCDEFG2', true);
expect(accepted.status).toBe(200);
expect(await accepted.json()).toMatchObject({
proceed: true,
redirectUrl: env.origin,
});
});
it('preserves an unauthenticated popup retry and attests login only after verification', async () => {
const { otp, user } = await account();
const response = await callback('login', {
embedded_in_popup: 'true',
msg_id: 'message-123',
opener_origin: 'https://app.example',
});
const token = challenge(response);
const fragment = new URLSearchParams(
new URL(response.headers.get('location')!).hash.slice(1),
);
const retry = new URL(fragment.get('retryUrl')!);
expect(retry.origin).toBe(env.origin);
expect(retry.searchParams.get('action')).toBe('login');
expect(retry.searchParams.get('embedded_in_popup')).toBe('true');
expect(retry.searchParams.get('msg_id')).toBe('message-123');
expect(retry.searchParams.has('auth_error')).toBe(false);
const retryNonce = response.headers
.get('set-cookie')!
.match(/puter_oidc_popup_return=([^;, ]+)/)![1];
const retried = await fetch(
new URL('/auth/oidc/verify-popup-return', env.origin),
{
method: 'POST',
headers: {
'content-type': 'application/json',
cookie: `puter_oidc_popup_return=${retryNonce}`,
},
body: JSON.stringify({
opener_state: retry.searchParams.get('opener_state'),
}),
},
);
expect(retried.status).toBe(200);
expect(await retried.json()).toMatchObject({
action: 'login',
oidc_login: false,
user_uuid: null,
msg_id: 'message-123',
opener_origin: 'https://app.example',
});
const accepted = await verify(token, otp.generate());
const data = await accepted.json();
const target = new URL(data.redirectUrl);
expect(target.pathname).toBe('/action/sign-in');
expect(target.searchParams.get('msg_id')).toBe('message-123');
expect(target.searchParams.get('oidc_login')).toBe('true');
const proof = env.server.services.oidc.verifyPopupReturn(
target.searchParams.get('opener_state')!,
);
expect(proof).toMatchObject({
user_uuid: user.uuid,
opener_origin: 'https://app.example',
msg_id: 'message-123',
oidc_login: true,
});
expect(
accepted.headers
.getSetCookie()
.some((value) => value.startsWith('puter_oidc_popup_return=')),
).toBe(true);
});
it('requires the authenticator when verified email first links an existing account', async () => {
const { otp } = await account(true, false);
const token = challenge(await callback());
expect((await verify(token, otp.generate())).status).toBe(200);
});
it('requires the authenticator for an OIDC-only account', async () => {
const { user, otp } = await account();
await env.server.stores.user.update(user.id, { password: null });
const token = challenge(await callback());
const response = await verify(token, otp.generate());
expect(await response.json()).toMatchObject({
proceed: true,
next_step: 'complete',
});
});
it('uses distinct short-lived challenges and refuses a modified token', async () => {
const { otp } = await account();
const first = challenge(await callback());
const second = challenge(await callback());
expect(first).not.toBe(second);
const decoded = env.server.services.token.verify<{
purpose: string;
jti: string;
exp: number;
iat: number;
}>('otp', first);
expect(decoded.purpose).toBe('otp-login');
expect(decoded.jti).toBeTruthy();
expect(decoded.exp - decoded.iat).toBe(300);
const modified = `${first.slice(0, -4)}abcd`;
const rejected = await verify(modified, otp.generate());
expect(rejected.status).toBe(400);
expect(rejected.headers.get('set-cookie')).toBeNull();
});
it('validates continuation account binding before creating any session', async () => {
const { otp } = await account();
const issued = challenge(await callback());
const decoded = env.server.services.token.verify<
Record<string, unknown>
>('otp', issued);
const continuation = decoded.oidcContinuation as Record<
string,
unknown
>;
const mismatched = env.server.services.token.sign('otp', {
...decoded,
jti: uuidv4(),
oidcContinuation: { ...continuation, userUuid: uuidv4() },
});
const rejected = await verify(mismatched, otp.generate());
expect(rejected.status).toBe(400);
expect(rejected.headers.get('set-cookie')).toBeNull();
});
it('clamps a signed continuation redirect to the configured origin', async () => {
const { otp } = await account();
const issued = challenge(await callback());
const decoded = env.server.services.token.verify<
Record<string, unknown>
>('otp', issued);
const continuation = decoded.oidcContinuation as Record<
string,
unknown
>;
const redirected = env.server.services.token.sign('otp', {
...decoded,
jti: uuidv4(),
oidcContinuation: {
...continuation,
redirectUri: 'https://elsewhere.example/',
},
});
const accepted = await verify(redirected, otp.generate());
expect(await accepted.json()).toMatchObject({
proceed: true,
redirectUrl: env.origin,
});
});
it('preserves ordinary OIDC login for accounts without 2FA', async () => {
await account(false);
const response = await callback('login', { return_to: '/desktop' });
expect(response.headers.get('location')).toBe(`${env.origin}/desktop`);
expect(
response.headers.get('set-cookie')?.includes('puter_auth_token='),
).toBe(true);
});
});
@@ -1,978 +0,0 @@
/**
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { makeActor } from '../../core/actor.js';
import type { IConfig } from '../../types';
import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
const randomHandle = () => `ws-${Math.random().toString(36).slice(2, 10)}`;
describe('team endpoints over HTTP', () => {
let env: PuterTestEnv;
beforeAll(async () => {
// The cap has its own suite; these tests need many teams.
env = await setupPuterTestEnv({
teams_enabled: true,
max_teams_per_user: 100,
} as IConfig);
}, 120_000);
afterAll(async () => {
await env?.shutdown();
});
const call = (
method: string,
path: string,
token: string,
body?: unknown,
) =>
fetch(new URL(path, env.apiOrigin), {
method,
headers: {
'content-type': 'application/json',
authorization: `Bearer ${token}`,
},
...(body === undefined ? {} : { body: JSON.stringify(body) }),
});
/** An app token acting for `owner`, minted the way the dashboard does. */
const makeAppToken = async (owner: { username: string }) => {
const user = await env.server.stores.user.getByUsername(
owner.username,
);
const actor = makeActor({ user: user! });
const app = await env.server.stores.app.create(
{
name: `team-http-app-${crypto.randomUUID()}`,
title: 'Team app',
index_url: `https://team-${crypto.randomUUID()}.test/`,
},
{ ownerUserId: actor.user.id! },
);
return env.server.services.auth.getUserAppToken(actor, app.uid);
};
/** A scoped (non-full-access) access token for `owner`, carrying no app. */
const makeScopedToken = async (owner: {
username: string;
token: string;
}) => {
const user = await env.server.stores.user.getByUsername(
owner.username,
);
const anchor = `/${owner.username}/${crypto.randomUUID()}`;
await env.server.services.fs.mkdir(user!.id, {
path: anchor,
createMissingParents: true,
});
const entry = await env.server.stores.fsEntry.getEntryByPath(anchor);
const { actor } = await env.server.services.auth.authenticate(
owner.token,
);
return env.server.services.auth.createAccessToken(
actor!,
[[`fs:${entry!.uid}:list`]],
{ label: 'teams-scope' },
);
};
/** A team owned by `user`, with one provisioned member. */
const makeTeam = async () => {
const res = await call('POST', '/teams', env.users.user.token, {
name: 'Acme',
handle: randomHandle(),
});
// Unasserted, a broken create means tests run on `/teams/undefined`.
expect(res.status).toBe(200);
const team = (await res.json()) as { uid: string };
expect(team.uid).toBeTruthy();
const username = `http_${Math.random().toString(36).slice(2, 9)}`;
const member = await call(
'POST',
`/teams/${team.uid}/members`,
env.users.user.token,
{ username, email: `${username}@test.local` },
);
expect(member.status).toBe(200);
return { team, memberUsername: username };
};
// -- the owner-account gate --------------------------------------
it('creates a team and reports the caller as its owner', async () => {
const res = await call('POST', '/teams', env.users.user.token, {
name: 'Acme Design',
handle: randomHandle(),
});
expect(res.status).toBe(200);
const body = (await res.json()) as Record<string, unknown>;
expect(body).toMatchObject({ name: 'Acme Design', is_owner: true });
expect(body.uid).toMatch(/^[0-9a-f-]{36}$/u);
// `id` is internal and must not reach the wire.
expect(body).not.toHaveProperty('id');
});
it('returns 404, not 403, to a non-member', async () => {
const { team } = await makeTeam();
const res = await call(
'GET',
`/teams/${team.uid}`,
env.users.other.token,
);
// 403 would confirm the team exists.
expect(res.status).toBe(404);
});
it('refuses every administrative route to a member who is not the owner', async () => {
const { team, memberUsername } = await makeTeam();
// An activated seat: provisioning leaves the email unconfirmed, which
// `requireVerified` rejects, so an unactivated one cannot call at all.
const provisioned = await env.server.stores.user.getByUsername(
memberUsername,
);
await env.server.stores.user.update(provisioned!.id, {
email_confirmed: 1,
requires_email_confirmation: 0,
requires_password_change: 0,
});
const seat = await env.server.stores.user.getByUsername(memberUsername);
const { token } = await env.server.services.auth.createSessionToken(
seat!,
);
for (const [method, path] of [
['PUT', `/teams/${team.uid}`],
['DELETE', `/teams/${team.uid}`],
['POST', `/teams/${team.uid}/members`],
['GET', `/teams/${team.uid}/audit`],
] as const) {
const res = await call(
method,
path,
token,
method === 'GET' ? undefined : { name: 'nope' },
);
expect(res.status, `${method} ${path}`).toBe(403);
}
// Still a member, so reads it is entitled to still work.
const readable = await call('GET', `/teams/${team.uid}`, token);
expect(readable.status).toBe(200);
});
// -- the org_owned guard ------------------------------------------
it('refuses the team owner as the target of a member route', async () => {
const { team } = await makeTeam();
const res = await call(
'POST',
`/teams/${team.uid}/members/${env.users.user.username}/disable`,
env.users.user.token,
);
expect(res.status).toBe(404);
});
it('deletes a seat over HTTP only once it is disabled', async () => {
const { team, memberUsername } = await makeTeam();
const path = `/teams/${team.uid}/members/${memberUsername}`;
const live = await call('DELETE', path, env.users.user.token);
expect(live.status).toBe(409);
expect(await live.json()).toMatchObject({
code: 'account_must_be_disabled_first',
});
await call('POST', `${path}/disable`, env.users.user.token);
const gone = await call('DELETE', path, env.users.user.token);
expect(gone.status).toBe(200);
expect(
await env.server.stores.user.getByUsername(memberUsername),
).toBeFalsy();
});
// -- the directory ------------------------------------------------
it('keeps the directory shut until the team opts in', async () => {
const { team } = await makeTeam();
const before = await call(
'GET',
`/teams/${team.uid}/directory`,
env.users.user.token,
);
// 404, not 403: whether this is on is not an app's to probe for.
expect(before.status).toBe(404);
const on = await call('PUT', `/teams/${team.uid}`, env.users.user.token, {
directory_enabled: true,
});
expect(on.status).toBe(200);
expect(await on.json()).toMatchObject({ directory_enabled: true });
const after = await call(
'GET',
`/teams/${team.uid}/directory`,
env.users.user.token,
);
expect(after.status).toBe(200);
const body = (await after.json()) as { items: { username: string }[] };
expect(body.items.some((m) => m.username === env.users.user.username)).toBe(true);
// A scoped token was minted for one thing; a roster is not it.
const scoped = await makeScopedToken(env.users.user);
const narrow = await call(
'GET',
`/teams/${team.uid}/directory`,
typeof scoped === 'string' ? scoped : scoped.token,
);
expect(narrow.status).toBe(403);
});
it('refuses the directory to someone outside the team', async () => {
const { team } = await makeTeam();
await call('PUT', `/teams/${team.uid}`, env.users.user.token, {
directory_enabled: true,
});
const res = await call(
'GET',
`/teams/${team.uid}/directory`,
env.users.other.token,
);
expect(res.status).toBe(404);
});
// -- app actors, gated by the same opt-in as the directory --------
//
// Built through the service, not the wire: these routes share a
// rate-limit budget with the mutation tests above, and every HTTP
// create/update here would eat into that same window.
/** A team owned by `user`, created and toggled through the service. */
const makeServiceTeam = async () => {
const owner = (await env.server.stores.user.getByUsername(
env.users.user.username,
))!;
const team = await env.server.services.team.createTeam(owner.id, {
name: 'Acme',
handle: randomHandle(),
});
return { team, ownerId: owner.id };
};
it('omits a team from an app token until the directory opts in', async () => {
const { team, ownerId } = await makeServiceTeam();
const appToken = await makeAppToken(env.users.user);
const before = await call('GET', '/teams', appToken);
expect(before.status).toBe(200);
const beforeBody = (await before.json()) as { items: { uid: string }[] };
expect(beforeBody.items.some((t) => t.uid === team.uid)).toBe(false);
await env.server.services.team.updateTeam(team.uid, ownerId, {
directoryEnabled: true,
});
const after = await call('GET', '/teams', appToken);
expect(after.status).toBe(200);
const afterBody = (await after.json()) as { items: { uid: string }[] };
expect(afterBody.items.some((t) => t.uid === team.uid)).toBe(true);
});
it('does not count a team it hides from an app token', async () => {
const { team } = await makeServiceTeam();
const appToken = await makeAppToken(env.users.user);
const res = await call('GET', '/teams?includeTotal=true', appToken);
expect(res.status).toBe(200);
const body = (await res.json()) as {
items: { uid: string }[];
total: number;
};
expect(body.items.some((t) => t.uid === team.uid)).toBe(false);
// A count over rows the page withholds reports the hidden team.
expect(body.total).toBe(body.items.length);
});
it('still lists a team to its owner session with the directory off', async () => {
const { team } = await makeServiceTeam();
const res = await call('GET', '/teams', env.users.user.token);
expect(res.status).toBe(200);
const body = (await res.json()) as { items: { uid: string }[] };
expect(body.items.some((t) => t.uid === team.uid)).toBe(true);
});
it('lists a team to a full-access API token with the directory off', async () => {
const { team } = await makeServiceTeam();
const res = await call('GET', '/teams', env.users.user.apiToken);
expect(res.status).toBe(200);
const body = (await res.json()) as { items: { uid: string }[] };
expect(body.items.some((t) => t.uid === team.uid)).toBe(true);
});
it('404s /members to an app token until the directory opts in, then gives usernames only', async () => {
const { team, ownerId } = await makeServiceTeam();
const username = `svc_${Math.random().toString(36).slice(2, 9)}`;
await env.server.services.team.provisionAccount(team.uid, ownerId, {
username,
});
const appToken = await makeAppToken(env.users.user);
const before = await call(
'GET',
`/teams/${team.uid}/members`,
appToken,
);
expect(before.status).toBe(404);
expect(await before.json()).toMatchObject({ code: 'team_not_found' });
await env.server.services.team.updateTeam(team.uid, ownerId, {
directoryEnabled: true,
});
const after = await call(
'GET',
`/teams/${team.uid}/members`,
appToken,
);
expect(after.status).toBe(200);
const body = (await after.json()) as {
items: Record<string, unknown>[];
};
expect(body.items.length).toBeGreaterThan(0);
// An app gets username only — no org_owned, created_at, or uuid.
for (const m of body.items) expect(Object.keys(m)).toEqual(['username']);
});
it('still refuses an app token on GET /teams/:uid', async () => {
const { team } = await makeServiceTeam();
const appToken = await makeAppToken(env.users.user);
const res = await call('GET', `/teams/${team.uid}`, appToken);
expect(res.status).toBe(403);
expect(await res.json()).toMatchObject({ code: 'forbidden' });
});
it('excludes a never-activated or suspended seat from an app`s view of /members', async () => {
const { team, ownerId } = await makeServiceTeam();
const neverActivated = `svc_na_${Math.random().toString(36).slice(2, 9)}`;
await env.server.services.team.provisionAccount(team.uid, ownerId, {
username: neverActivated,
});
const suspendedUsername = `svc_su_${Math.random().toString(36).slice(2, 9)}`;
const suspended = await env.server.services.team.provisionAccount(
team.uid,
ownerId,
{ username: suspendedUsername },
);
await env.server.services.team.disableMember(
team.uid,
ownerId,
suspended.userId,
);
await env.server.services.team.updateTeam(team.uid, ownerId, {
directoryEnabled: true,
});
const appToken = await makeAppToken(env.users.user);
const appView = await call(
'GET',
`/teams/${team.uid}/members`,
appToken,
);
expect(appView.status).toBe(200);
const appItems = (
(await appView.json()) as { items: Record<string, unknown>[] }
).items;
for (const m of appItems) expect(Object.keys(m)).toEqual(['username']);
const appUsernames = appItems.map((m) => m.username);
expect(appUsernames).not.toContain(neverActivated);
expect(appUsernames).not.toContain(suspendedUsername);
// The owner's own seat is active, so it still shows.
expect(appUsernames).toContain(env.users.user.username);
// The owner's own session sees every seat regardless of status.
const ownerView = await call(
'GET',
`/teams/${team.uid}/members`,
env.users.user.token,
);
expect(ownerView.status).toBe(200);
const ownerUsernames = (
(await ownerView.json()) as { items: { username: string }[] }
).items.map((m) => m.username);
expect(ownerUsernames).toContain(neverActivated);
expect(ownerUsernames).toContain(suspendedUsername);
});
it('gives a full-access API token the owner`s uuid on /members', async () => {
const { team } = await makeServiceTeam();
const res = await call(
'GET',
`/teams/${team.uid}/members`,
env.users.user.apiToken,
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
items: { username: string; uuid?: string }[];
};
const owner = body.items.find(
(m) => m.username === env.users.user.username,
);
expect(owner?.uuid).toEqual(expect.any(String));
});
it('refuses a scoped access token on GET /teams and /members', async () => {
const { team } = await makeServiceTeam();
const scoped = await makeScopedToken(env.users.user);
const teamsRes = await call('GET', '/teams', scoped);
expect(teamsRes.status).toBe(403);
expect(await teamsRes.json()).toMatchObject({ code: 'forbidden' });
const membersRes = await call(
'GET',
`/teams/${team.uid}/members`,
scoped,
);
expect(membersRes.status).toBe(403);
expect(await membersRes.json()).toMatchObject({ code: 'forbidden' });
});
it('lists members with org_owned distinguishing the owner', async () => {
const { team, memberUsername } = await makeTeam();
const res = await call(
'GET',
`/teams/${team.uid}/members`,
env.users.user.token,
);
expect(res.status).toBe(200);
const body = (await res.json()) as {
items: { username: string; org_owned: boolean }[];
};
const owner = body.items.find(
(m) => m.username === env.users.user.username,
);
const member = body.items.find((m) => m.username === memberUsername);
expect(owner?.org_owned).toBe(false);
expect(member?.org_owned).toBe(true);
});
it('gives seat uuids to the owner and to nobody else', async () => {
// Built through the service, not the wire: provisioning shares a
// rate-limit budget with the seat tests below, and one more HTTP
// provision here 429s them.
const owner = (await env.server.stores.user.getByUsername(
env.users.user.username,
))!;
const slug0 = Math.random().toString(36).slice(2, 9);
const team = await env.server.services.team.createTeam(owner.id, {
name: 'Acme',
handle: `uuidt-${slug0}`,
});
const memberUsername = `seat_${slug0}`;
await env.server.services.team.provisionAccount(team.uid, owner.id, {
username: memberUsername,
});
const owned = (await (
await call('GET', `/teams/${team.uid}/members`, env.users.user.token)
).json()) as { items: { username: string; uuid?: string }[] };
const seat = owned.items.find((m) => m.username === memberUsername);
// Billing keys a seat's plan on this, so the owner cannot act without it.
expect(seat?.uuid).toEqual(expect.any(String));
// A throwaway member, not the shared fixture: joining a team is
// permanent and would follow `other` into every later test.
const slug = Math.random().toString(36).slice(2, 9);
const joiner = await env.server.stores.user.create({
username: `joiner_${slug}`,
uuid: crypto.randomUUID(),
password: 'hashed',
email: `joiner_${slug}@test.local`,
});
await env.server.stores.team.addMember(team.uid, joiner.id, {
orgOwned: false,
});
const { token } =
await env.server.services.auth.createSessionToken(joiner);
const seen = (await (
await call('GET', `/teams/${team.uid}/members`, token)
).json()) as { items: { uuid?: string }[] };
expect(seen.items.length).toBeGreaterThan(0);
for (const m of seen.items) expect(m.uuid).toBeUndefined();
});
// -- provisioning over the wire -----------------------------------
it('never returns the activation link to the administrator', async () => {
const { team } = await makeTeam();
const username = `secret_${Math.random().toString(36).slice(2, 9)}`;
const res = await call(
'POST',
`/teams/${team.uid}/members`,
env.users.user.token,
{ username, email: `${username}@test.local` },
);
expect(res.status).toBe(200);
const raw = JSON.stringify(await res.json());
// Emailed to the member; returning it would let an admin use it.
expect(raw).not.toContain('set-new-password');
expect(raw).not.toContain('token');
});
it('refuses a taken username and offers alternatives', async () => {
const { team } = await makeTeam();
const res = await call(
'POST',
`/teams/${team.uid}/members`,
env.users.user.token,
{
username: env.users.other.username,
email: '[email protected]',
},
);
expect(res.status).toBe(409);
const body = (await res.json()) as {
suggestions?: string[];
fields?: { suggestions?: string[] };
};
const suggestions = body.suggestions ?? body.fields?.suggestions ?? [];
expect(suggestions.length).toBeGreaterThan(0);
expect(suggestions).not.toContain(env.users.other.username);
});
// -- audit --------------------------------------------------------
it('exposes the team audit to the team owner only', async () => {
const { team } = await makeTeam();
const mine = await call(
'GET',
`/teams/${team.uid}/audit`,
env.users.user.token,
);
expect(mine.status).toBe(200);
const body = (await mine.json()) as { items: { action: string }[] };
expect(body.items.map((e) => e.action)).toContain('provision');
const theirs = await call(
'GET',
`/teams/${team.uid}/audit`,
env.users.other.token,
);
expect(theirs.status).toBe(404);
});
// -- sharing with a team -------------------------------------
it('names the team it shared with instead of an empty recipient', async () => {
const { team } = await makeTeam();
const file = `/${env.users.user.username}/Documents/label-${Math.random()
.toString(36)
.slice(2, 8)}.txt`;
const written = await call('POST', '/fs/write', env.users.user.token, {
fileMetadata: { path: file, size: 3, contentType: 'text/plain' },
fileContent: 'abc',
});
expect(written.status).toBe(200);
const shared = await call('POST', '/share', env.users.user.token, {
recipients: [{ team: team.uid }],
items: [file],
mode: 'read',
});
expect(shared.status).toBe(200);
const body = (await shared.json()) as {
results: { recipient: string; status: string }[];
};
expect(body.results[0].status).toBe('success');
// Echoes the identifier the caller named; '' would leave a client with
// nothing to render or to match its request against.
expect(body.results[0].recipient).toBe(team.uid);
});
it('shares with two teams rather than collapsing them into one', async () => {
const second = await call('POST', '/teams', env.users.user.token, {
name: 'Second',
handle: randomHandle(),
});
expect(second.status).toBe(200);
const teamB = (await second.json()) as { uid: string };
const { team: teamA } = await makeTeam();
const file = `/${env.users.user.username}/Documents/two-${Math.random()
.toString(36)
.slice(2, 8)}.txt`;
const written = await call('POST', '/fs/write', env.users.user.token, {
fileMetadata: { path: file, size: 3, contentType: 'text/plain' },
fileContent: 'abc',
});
const fileUid = ((await written.json()) as { fsEntry: { uid: string } })
.fsEntry.uid;
const shared = await call('POST', '/share', env.users.user.token, {
recipients: [{ team: teamA.uid }, { team: teamB.uid }],
items: [file],
mode: 'read',
});
expect(shared.status).toBe(200);
const body = (await shared.json()) as {
results: { recipient: string; status: string }[];
};
expect(body.results).toHaveLength(2);
expect(body.results.every((r) => r.status === 'success')).toBe(true);
// The response shape alone proves nothing: `results` is built from the
// request's pairs, so a collapsed pair still reports two successes with
// the right labels. Only the shares that exist afterwards show it.
const mine = await call(
'GET',
'/share/shared-by-me?limit=100',
env.users.user.token,
);
expect(mine.status).toBe(200);
const listing = (await mine.json()) as {
items: { uid_entry?: string; holder_team?: { uid: string } }[];
};
const holders = listing.items
.filter((i) => i.uid_entry === fileUid)
.map((i) => i.holder_team?.uid)
.filter(Boolean)
.sort();
expect(holders).toEqual([teamA.uid, teamB.uid].sort());
});
it('names the team by handle when the caller shared by handle', async () => {
const handle = randomHandle();
const res = await call('POST', '/teams', env.users.user.token, {
name: 'Byhandle',
handle,
});
expect(res.status).toBe(200);
const file = `/${env.users.user.username}/Documents/handle-${Math.random()
.toString(36)
.slice(2, 8)}.txt`;
await call('POST', '/fs/write', env.users.user.token, {
fileMetadata: { path: file, size: 3, contentType: 'text/plain' },
fileContent: 'abc',
});
const shared = await call('POST', '/share', env.users.user.token, {
recipients: [{ teamHandle: handle }],
items: [file],
mode: 'read',
});
expect(shared.status).toBe(200);
const body = (await shared.json()) as {
results: { recipient: string }[];
};
expect(body.results[0].recipient).toBe(handle);
});
it('gives a non-member 404 on the member view, not an empty page', async () => {
const { team } = await makeTeam();
const res = await call(
'GET',
`/teams/${team.uid}/audit/me`,
env.users.other.token,
);
expect(res.status).toBe(404);
});
// -- the forced-change gate ---------------------------------------
/**
* A seat that has signed in on its temporary password. Email confirmation
* is cleared first so the gate under test is the one that answers.
*/
const signedInSeat = async (teamUid: string) => {
const username = `seat_${Math.random().toString(36).slice(2, 9)}`;
const res = await call(
'POST',
`/teams/${teamUid}/members`,
env.users.user.token,
{ username, email: `${username}@test.local` },
);
expect(res.status).toBe(200);
const { temporary_password: password } = (await res.json()) as {
temporary_password: string;
};
const row = (await env.server.stores.user.getByUsername(username))!;
await env.server.stores.user.update(row.id, {
requires_email_confirmation: false,
email_confirmed: true,
});
await env.server.stores.user.invalidateById(row.id);
const fresh = (await env.server.stores.user.getById(row.id))!;
const { token } = await env.server.services.auth.createSessionToken(
fresh,
{ user_agent: 'puter-test-seat' },
);
return { username, password, userId: row.id, token };
};
/**
* Cookie-credentialed on the GUI origin, as the user-protected gate insists.
* Sent with the seat's own user agent: the session touch otherwise rewrites
* it to Node's once a second boundary passes.
*/
const changePassword = (token: string, password: string, next: string) =>
fetch(new URL('/user-protected/change-password', env.origin), {
method: 'POST',
headers: {
'content-type': 'application/json',
'user-agent': 'puter-test-seat',
authorization: `Bearer ${token}`,
cookie: `puter_auth_token=${token}`,
},
body: JSON.stringify({ password, new_pass: next }),
});
it('signs the seat in but refuses every authenticated route after that', async () => {
const { team } = await makeTeam();
const seat = await signedInSeat(team.uid);
const res = await call('GET', '/teams', seat.token);
expect(res.status).toBe(403);
expect(await res.text()).toContain('password_change_required');
});
it('leaves change-password reachable, since it is what clears the gate', async () => {
const { team } = await makeTeam();
const seat = await signedInSeat(team.uid);
const changed = await changePassword(
seat.token,
seat.password,
'chosen-by-the-member',
);
expect(changed.status).toBe(200);
const row = await env.server.stores.user.getByProperty(
'id',
seat.userId,
{ force: true },
);
expect(Number(row?.requires_password_change)).toBe(0);
expect(row?.temp_password_expires_at ?? null).toBeNull();
});
it('admits the seat to the product once it has chosen a password', async () => {
const { team } = await makeTeam();
const seat = await signedInSeat(team.uid);
expect(
(await changePassword(seat.token, seat.password, 'my-own-password'))
.status,
).toBe(200);
// The session that changed the password is the one that survives.
const res = await call('GET', '/teams', seat.token);
expect(res.status).toBe(200);
});
it('closes re-issue behind a seat that has activated', async () => {
const { team } = await makeTeam();
const seat = await signedInSeat(team.uid);
await changePassword(seat.token, seat.password, 'a-password-of-mine');
const res = await call(
'POST',
`/teams/${team.uid}/members/${seat.username}/activation`,
env.users.user.token,
);
expect(res.status).toBe(409);
});
it('shows the member the reset and their own sign-in, and nothing else', async () => {
const { team } = await makeTeam();
const seat = await signedInSeat(team.uid);
await changePassword(seat.token, seat.password, 'chosen-once-already');
const reset = await call(
'POST',
`/teams/${team.uid}/members/${seat.username}/password-reset`,
env.users.user.token,
);
expect(reset.status).toBe(200);
const { temporary_password: issued } = (await reset.json()) as {
temporary_password: string;
};
const { token } = await env.server.services.auth.createSessionToken(
(await env.server.stores.user.getById(seat.userId))!,
{ user_agent: 'puter-test-seat' },
);
const mine = await call('GET', `/teams/${team.uid}/audit/me`, token);
// The seat owes a password change again, so its own view is all it reaches.
expect(mine.status).toBe(403);
const admin = await call(
'GET',
`/teams/${team.uid}/audit`,
env.users.user.token,
);
const body = (await admin.json()) as { items: { action: string }[] };
expect(body.items.map((e) => e.action)).toContain(
'reset_member_password',
);
expect(JSON.stringify(body)).not.toContain(issued);
});
it('shows an activated seat its own reset and sign-in over the wire', async () => {
const { team } = await makeTeam();
const seat = await signedInSeat(team.uid);
await changePassword(seat.token, seat.password, 'the-one-i-picked');
const res = await call('GET', `/teams/${team.uid}/audit/me`, seat.token);
expect(res.status).toBe(200);
const body = (await res.json()) as {
items: {
action: string;
username: string | null;
ip: string | null;
user_agent: string | null;
}[];
};
expect(body.items.map((e) => e.action)).toContain('activate');
const tell = body.items.find((e) => e.action === 'sign_in');
expect(tell?.username).toBe(seat.username);
expect(tell?.user_agent).toBe('puter-test-seat');
// Only their own; the team owner's entries are not theirs to read.
expect(
body.items.every((e) => e.username === seat.username),
).toBe(true);
});
it('refuses a temporary password that was never used in time', async () => {
const { team } = await makeTeam();
const seat = await signedInSeat(team.uid);
await env.server.stores.user.update(seat.userId, {
temp_password_expires_at: Math.floor(Date.now() / 1000) - 1,
});
await env.server.stores.user.invalidateById(seat.userId);
const res = await fetch(new URL('/login', env.origin), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
username: seat.username,
password: seat.password,
}),
});
expect(res.status).toBe(401);
expect(await res.text()).toContain('temporary_password_expired');
});
});
describe('team endpoints with teams_enabled off', () => {
let env: PuterTestEnv;
beforeAll(async () => {
env = await setupPuterTestEnv({ teams_enabled: false } as IConfig);
}, 120_000);
afterAll(async () => {
await env?.shutdown();
});
it('404s every team route, so no team code is reachable', async () => {
// Real team: 200 with the flag on, so a 404 means no route.
const owner = await env.server.stores.user.getByUsername(
env.users.user.username,
);
const team = await env.server.stores.team.create({
ownerUserId: owner!.id,
name: 'Unreachable',
handle: `off-${Math.random().toString(36).slice(2, 8)}`,
});
await env.server.stores.team.addMember(team.uid, owner!.id, {
orgOwned: false,
});
const paths: [string, string][] = [
['POST', '/teams'],
['GET', '/teams'],
['GET', `/teams/${team.uid}`],
['PUT', `/teams/${team.uid}`],
['DELETE', `/teams/${team.uid}`],
['GET', `/teams/${team.uid}/members`],
['POST', `/teams/${team.uid}/members`],
['GET', `/teams/${team.uid}/audit`],
['GET', `/teams/${team.uid}/audit/me`],
];
for (const [method, path] of paths) {
const res = await fetch(new URL(path, env.apiOrigin), {
method,
headers: {
'content-type': 'application/json',
authorization: `Bearer ${env.users.user.token}`,
},
...(method === 'POST' || method === 'PUT'
? { body: JSON.stringify({ name: 'x' }) }
: {}),
});
expect(res.status, `${method} ${path}`).toBe(404);
// Ours would carry a team legacyCode; the framework's does not.
const body = await res.text();
expect(body, `${method} ${path}`).not.toContain('team_not_found');
expect(body, `${method} ${path}`).not.toContain(
'not_the_team_owner',
);
}
});
it('registers no team routes at all with the flag off', async () => {
// The controller exists and is constructed; only its routes are absent.
const controller = env.server.controllers.team as unknown as {
isEnabled?: () => boolean;
};
expect(controller).toBeTruthy();
expect(controller.isEnabled?.()).toBe(false);
});
it('leaves the schema inert rather than absent', async () => {
// The flag gates reachability, not DDL.
await expect(
env.server.stores.team.getByHandle('no-such-team'),
).resolves.toBeNull();
});
});
@@ -1,565 +0,0 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import type { Request, Response } from 'express';
import {
Controller,
Delete,
Get,
Post,
Put,
} from '../../core/http/decorators.js';
import { isAccountContext } from '../../core/actor.js';
import { HttpError } from '../../core/http/HttpError.js';
import type { TeamRow } from '../../stores/team/TeamStore.js';
import { PuterController } from '../types.js';
/** Mirrors ShareController's dual-window shape. */
const TEAM_LIMIT = [
{ scope: 'team:mutate', limit: 60, window: 60_000, key: 'user' as const },
{
scope: 'team:mutate-daily',
limit: 500,
window: 24 * 60 * 60_000,
key: 'user' as const,
},
];
/** Tighter than TEAM_LIMIT: a reset hands the admin a working credential. */
const TEAM_RESET_LIMIT = [
...TEAM_LIMIT,
{
scope: 'team:reset-password',
limit: 20,
window: 24 * 60 * 60_000,
key: 'user' as const,
},
];
const TEAM_READ_LIMIT = {
scope: 'team:read',
limit: 600,
window: 60_000,
key: 'user' as const,
};
/** What a team looks like on the wire. `id` stays internal. */
const toClientTeam = (team: TeamRow, isOwner: boolean) => ({
uid: team.uid,
name: team.name,
handle: team.handle,
is_owner: isOwner,
created_at: team.created_at,
directory_enabled: Number(team.directory_enabled) === 1,
require_2fa: Number(team.require_2fa) === 1,
});
@Controller('/teams')
export class TeamController extends PuterController {
// Most routes gate with `requireUserActor`; `listTeams` and `listMembers`
// use `requireAuth` so an app can read a team whose owner opted into the
// directory (see `directory_enabled`).
/** Off means `/teams` 404s and no team route is registered at all. */
isEnabled(): boolean {
return this.config.teams_enabled === true;
}
@Post('', {
subdomain: 'api',
requireUserActor: true,
requireVerified: true,
rateLimit: TEAM_LIMIT,
})
async createTeam(req: Request, res: Response): Promise<void> {
const userId = this.#requireUserId(req);
await this.#requireTeamsAvailable(req, userId);
const body = this.#body(req);
const team = await this.services.team.createTeam(userId, {
name: this.#requireString(body.name, 'name'),
handle:
body.handle === undefined || body.handle === null
? null
: this.#requireString(body.handle, 'handle'),
});
res.json(toClientTeam(team, true));
}
@Get('', {
subdomain: 'api',
requireAuth: true,
requireVerified: true,
rateLimit: TEAM_READ_LIMIT,
})
async listTeams(req: Request, res: Response): Promise<void> {
const userId = this.#requireUserId(req);
await this.#requireTeamsAvailable(req, userId);
// In SQL, or cursor and total describe teams the app cannot see.
const page = await this.stores.team.listTeamsForUser(userId, {
limit: req.query.limit,
cursor:
typeof req.query.cursor === 'string'
? req.query.cursor
: undefined,
includeTotal: req.query.includeTotal === 'true',
openOnly: !isAccountContext(req.actor),
});
res.json({
items: page.items.map((t) =>
toClientTeam(t, t.owner_user_id === userId),
),
...(page.cursor ? { cursor: page.cursor } : {}),
...(page.total === undefined ? {} : { total: page.total }),
});
}
@Get('/:uid', {
subdomain: 'api',
requireUserActor: true,
requireVerified: true,
rateLimit: TEAM_READ_LIMIT,
})
async getTeam(req: Request, res: Response): Promise<void> {
const userId = this.#requireUserId(req);
const team = await this.services.team.requireMembership(
this.#param(req, 'uid'),
userId,
);
res.json(toClientTeam(team, team.owner_user_id === userId));
}
@Put('/:uid', {
subdomain: 'api',
requireUserActor: true,
requireVerified: true,
rateLimit: TEAM_LIMIT,
})
async updateTeam(req: Request, res: Response): Promise<void> {
const userId = this.#requireUserId(req);
await this.services.team.requireOwner(this.#param(req, 'uid'), userId);
const body = this.#body(req);
const changes: {
name?: string;
handle?: string | null;
directoryEnabled?: boolean;
require2fa?: boolean;
} = {};
if (body.name !== undefined)
changes.name = this.#requireString(body.name, 'name');
if (body.handle !== undefined)
changes.handle = body.handle === null ? null : String(body.handle);
if (body.directory_enabled !== undefined)
changes.directoryEnabled = body.directory_enabled === true;
if (body.require_2fa !== undefined)
changes.require2fa = body.require_2fa === true;
// Through the service: turning the directory on writes an audit row.
const team = await this.services.team.updateTeam(
this.#param(req, 'uid'),
userId,
changes,
);
res.json(toClientTeam(team, true));
}
@Delete('/:uid', {
subdomain: 'api',
requireUserActor: true,
requireVerified: true,
rateLimit: TEAM_LIMIT,
})
async deleteTeam(req: Request, res: Response): Promise<void> {
const userId = this.#requireUserId(req);
await this.services.team.deleteTeam(this.#param(req, 'uid'), userId);
res.json({ success: true });
}
// -- Members ------------------------------------------------------
@Get('/:uid/members', {
subdomain: 'api',
requireAuth: true,
requireVerified: true,
rateLimit: TEAM_READ_LIMIT,
})
async listMembers(req: Request, res: Response): Promise<void> {
const userId = this.#requireUserId(req);
const team = await this.services.team.requireMembership(
this.#param(req, 'uid'),
userId,
);
const accountContext = isAccountContext(req.actor);
// Same consent as the directory: an app only reads a team that opted in.
if (!accountContext) this.services.team.assertDirectoryOpen(team);
// uuid only when the team owner calls with their own session or API
// token; an app needing a stable id uses `listDirectory()` instead.
const showUuid = accountContext && team.owner_user_id === userId;
const page = await this.stores.team.listMembers(
this.#param(req, 'uid'),
{
limit: req.query.limit,
cursor:
typeof req.query.cursor === 'string'
? req.query.cursor
: undefined,
// An app's view matches the directory's: active seats only.
activeOnly: !accountContext,
},
);
// An app only ever gets a username; org_owned/created_at are account-only.
res.json({
items: page.items.map((m) =>
accountContext
? {
username: m.username,
org_owned: Number(m.org_owned) === 1,
created_at: m.created_at,
...(showUuid ? { uuid: m.uuid } : {}),
}
: { username: m.username },
),
...(page.cursor ? { cursor: page.cursor } : {}),
});
}
/** App actors too, once opted in; carries the `uuid` `/members` hides. */
@Get('/:uid/directory', {
subdomain: 'api',
requireVerified: true,
rateLimit: TEAM_READ_LIMIT,
})
async listDirectory(req: Request, res: Response): Promise<void> {
// The membership is the person's, never the app's.
const userId = this.#requireUserId(req);
// A scoped token holds only what it was minted for; a roster is not that.
const token = req.actor?.accessToken;
if (token && token.fullAccess !== true) {
throw new HttpError(
403,
'This endpoint is not available to scoped access tokens',
{ legacyCode: 'forbidden' },
);
}
const page = await this.services.team.listDirectory(
this.#param(req, 'uid'),
userId,
{
limit: req.query.limit,
cursor:
typeof req.query.cursor === 'string'
? req.query.cursor
: undefined,
},
);
res.json(page);
}
@Post('/:uid/members', {
subdomain: 'api',
requireUserActor: true,
requireVerified: true,
rateLimit: TEAM_LIMIT,
})
async createMember(req: Request, res: Response): Promise<void> {
const userId = this.#requireUserId(req);
const uid = this.#param(req, 'uid');
// Authority before shape, or a stranger learns if their body parsed.
await this.services.team.requireOwner(uid, userId);
const body = this.#body(req);
const result = await this.services.team.provisionAccount(
uid,
userId,
{
username: this.#requireString(body.username, 'username'),
email: this.#optionalString(body.email, 'email'),
},
// The owner's request is what the signup gate gets to judge.
{ req, fingerprint: req.deviceFingerprint ?? null },
);
// Shown once; the admin delivers it out of band.
res.json({
username: result.username,
temporary_password: result.temporaryPassword,
});
}
// Same budget as a reset: both hand the administrator a working credential,
// and a reset re-arms the flag this route needs.
@Post('/:uid/members/:username/activation', {
subdomain: 'api',
requireUserActor: true,
requireVerified: true,
rateLimit: TEAM_RESET_LIMIT,
})
async reissueCredential(req: Request, res: Response): Promise<void> {
const userId = this.#requireUserId(req);
const uid = this.#param(req, 'uid');
// Authority first, or resolving `:username` is an existence oracle.
await this.services.team.requireOwner(uid, userId);
const target = await this.#requireTargetUserId(req);
const { temporaryPassword } =
await this.services.team.reissueCredential(uid, userId, target);
// Shown once for the admin to deliver out of band.
res.json({ temporary_password: temporaryPassword });
}
@Post('/:uid/members/:username/password-reset', {
subdomain: 'api',
requireUserActor: true,
requireVerified: true,
rateLimit: TEAM_RESET_LIMIT,
})
async resetMemberPassword(req: Request, res: Response): Promise<void> {
const userId = this.#requireUserId(req);
const uid = this.#param(req, 'uid');
// Authority first, or resolving `:username` is an existence oracle.
await this.services.team.requireOwner(uid, userId);
const target = await this.#requireTargetUserId(req);
const { temporaryPassword } =
await this.services.team.resetMemberPassword(uid, userId, target);
// Shown once; it is not retrievable afterwards.
res.json({ temporary_password: temporaryPassword });
}
// The gate closes the seat's own route to fix it, so the owner must.
@Post('/:uid/members/:username/email', {
subdomain: 'api',
requireUserActor: true,
requireVerified: true,
rateLimit: TEAM_LIMIT,
})
async updateSeatEmail(req: Request, res: Response): Promise<void> {
const userId = this.#requireUserId(req);
const uid = this.#param(req, 'uid');
// Authority first, or resolving `:username` is an existence oracle.
await this.services.team.requireOwner(uid, userId);
const target = await this.#requireTargetUserId(req);
await this.services.team.updateSeatEmail(
uid,
userId,
target,
(req.body as { email?: unknown } | undefined)?.email ?? null,
);
res.json({ success: true });
}
// Same budget as a password reset: both are a step toward the account.
@Post('/:uid/members/:username/2fa-reset', {
subdomain: 'api',
requireUserActor: true,
requireVerified: true,
rateLimit: TEAM_RESET_LIMIT,
})
async resetMemberTwoFactor(req: Request, res: Response): Promise<void> {
const userId = this.#requireUserId(req);
const uid = this.#param(req, 'uid');
// Authority first, or resolving `:username` is an existence oracle.
await this.services.team.requireOwner(uid, userId);
const target = await this.#requireTargetUserId(req);
await this.services.team.resetMemberTwoFactor(uid, userId, target);
res.json({ success: true });
}
@Post('/:uid/members/:username/disable', {
subdomain: 'api',
requireUserActor: true,
requireVerified: true,
rateLimit: TEAM_LIMIT,
})
async disableMember(req: Request, res: Response): Promise<void> {
const userId = this.#requireUserId(req);
const uid = this.#param(req, 'uid');
// Authority first, or resolving `:username` is an existence oracle.
await this.services.team.requireOwner(uid, userId);
const target = await this.#requireTargetUserId(req);
await this.services.team.disableMember(
this.#param(req, 'uid'),
userId,
target,
);
res.json({ success: true });
}
@Post('/:uid/members/:username/enable', {
subdomain: 'api',
requireUserActor: true,
requireVerified: true,
rateLimit: TEAM_LIMIT,
})
async enableMember(req: Request, res: Response): Promise<void> {
const userId = this.#requireUserId(req);
const uid = this.#param(req, 'uid');
// Authority first, or resolving `:username` is an existence oracle.
await this.services.team.requireOwner(uid, userId);
const target = await this.#requireTargetUserId(req);
await this.services.team.enableMember(
this.#param(req, 'uid'),
userId,
target,
);
res.json({ success: true });
}
@Delete('/:uid/members/:username', {
subdomain: 'api',
requireUserActor: true,
requireVerified: true,
rateLimit: TEAM_LIMIT,
})
async deleteMember(req: Request, res: Response): Promise<void> {
const userId = this.#requireUserId(req);
const uid = this.#param(req, 'uid');
// Authority first (anti-oracle); deleted team included, see the service.
await this.services.team.requireOwnedTeam(uid, userId);
const target = await this.#requireTargetUserId(req);
await this.services.team.deleteMember(uid, userId, target);
res.json({ success: true });
}
// -- Audit --------------------------------------------------------
@Get('/:uid/audit', {
subdomain: 'api',
requireUserActor: true,
requireVerified: true,
rateLimit: TEAM_READ_LIMIT,
})
async listAudit(req: Request, res: Response): Promise<void> {
const userId = this.#requireUserId(req);
res.json(
await this.services.team.listAudit(
this.#param(req, 'uid'),
userId,
this.#pageOpts(req),
),
);
}
@Get('/:uid/audit/me', {
subdomain: 'api',
requireUserActor: true,
requireVerified: true,
rateLimit: TEAM_READ_LIMIT,
})
async listOwnAudit(req: Request, res: Response): Promise<void> {
const userId = this.#requireUserId(req);
res.json(
await this.services.team.listOwnAudit(
this.#param(req, 'uid'),
userId,
this.#pageOpts(req),
),
);
}
// -- Helpers ------------------------------------------------------
/** `limit` and `cursor` as doc/pagination.md defines them. */
#pageOpts(req: Request): { limit?: unknown; cursor?: string } {
return {
limit: req.query.limit,
cursor:
typeof req.query.cursor === 'string'
? req.query.cursor
: undefined,
};
}
/**
* The domain-allowlist gate, on the two routes that enter the feature. Same
* 404 as a teams-off deployment; other routes bound by membership.
*/
async #requireTeamsAvailable(req: Request, userId: number): Promise<void> {
const email = (
req.actor as { user?: { email?: string | null } } | undefined
)?.user?.email;
if (await this.services.team.teamsAvailableTo(userId, email)) return;
throw new HttpError(404, 'Not found', { legacyCode: 'not_found' });
}
#requireUserId(req: Request): number {
const id = (req.actor as { user?: { id?: number } } | undefined)?.user
?.id;
if (!id)
throw new HttpError(401, 'User required', {
legacyCode: 'unauthorized',
});
return id;
}
/** Express types a param as `string | string[]`; routes here take one. */
#param(req: Request, name: string): string {
const value = req.params[name];
return Array.isArray(value) ? (value[0] ?? '') : (value ?? '');
}
#body(req: Request): Record<string, unknown> {
return (req.body ?? {}) as Record<string, unknown>;
}
/** Absent or empty means "not given"; a wrong type is still a 400. */
#optionalString(value: unknown, field: string): string | null {
if (value === undefined || value === null) return null;
if (typeof value !== 'string') {
throw new HttpError(400, `${field} must be a string`, {
legacyCode: 'bad_request',
});
}
return value.trim() === '' ? null : value;
}
#requireString(value: unknown, field: string): string {
if (typeof value !== 'string' || value.trim() === '') {
throw new HttpError(400, `${field} is required`, {
legacyCode: 'bad_request',
});
}
return value;
}
#notFound(): HttpError {
return new HttpError(404, 'Team not found', {
legacyCode: 'team_not_found',
});
}
/** Resolves `:username` to an id; the service decides whether it may act. */
async #requireTargetUserId(req: Request): Promise<number> {
const user = await this.stores.user.getByUsername(
this.#param(req, 'username'),
);
if (!user)
throw new HttpError(404, 'Not an account of this team', {
legacyCode: 'not_an_org_account',
});
return user.id;
}
}
@@ -1,220 +0,0 @@
/**
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/**
* A team manages accounts and cannot read them. Nobody writes that grant
* deliberately, but a new implicator or a widened actor would create it and no
* other test would fail. Asserts outcomes, never that an implicator is absent.
*/
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { makeActor } from '../../core/actor.js';
import type { IConfig } from '../../types';
import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
describe('a team cannot read its members data', () => {
let env: PuterTestEnv;
let teamUid: string;
let memberUsername: string;
let memberFile: string;
let ownerFile: string;
let memberToken: string;
/** Written directly: these tests are about reading, not about writing. */
const makeFile = async (username: string, label: string) => {
const uid = crypto.randomUUID();
const name = `${label}-${uid.slice(0, 8)}.txt`;
const path = `/${username}/${name}`;
const user = await env.server.stores.user.getByUsername(username);
await env.server.clients.db.write(
'INSERT INTO `fsentries` (`uuid`, `name`, `path`, `user_id`, `is_dir`, `modified`) ' +
'VALUES (?, ?, ?, ?, ?, ?)',
[
uid,
name,
path,
user!.id,
// `is_dir` is a real boolean on postgres.
env.server.clients.db.booleanValue(false),
Math.floor(Date.now() / 1000),
],
);
return path;
};
const stat = (path: string, token: string) =>
fetch(new URL('/stat', env.apiOrigin), {
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${token}`,
},
body: JSON.stringify({ path }),
});
const readdir = (path: string, token: string) =>
fetch(new URL('/readdir', env.apiOrigin), {
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${token}`,
},
body: JSON.stringify({ path }),
});
beforeAll(async () => {
// The cap has its own suite; these tests need many teams.
env = await setupPuterTestEnv({
teams_enabled: true,
max_teams_per_user: 100,
} as IConfig);
// A provisioned account cannot authenticate until it activates.
const res = await fetch(new URL('/teams', env.apiOrigin), {
method: 'POST',
headers: {
'content-type': 'application/json',
authorization: `Bearer ${env.users.user.token}`,
},
body: JSON.stringify({ name: 'Isolation', handle: `iso-${Date.now()}` }),
});
teamUid = ((await res.json()) as { uid: string }).uid;
// A real provisioned seat, activated so it can authenticate.
memberUsername = `iso_${Math.random().toString(36).slice(2, 9)}`;
await env.server.services.team.provisionAccount(
teamUid,
(await env.server.stores.user.getByUsername(
env.users.user.username,
))!.id,
{ username: memberUsername, email: `${memberUsername}@test.local` },
);
const member = await env.server.stores.user.getByUsername(memberUsername);
await env.server.stores.user.update(member!.id, {
email_confirmed: 1,
requires_email_confirmation: 0,
requires_password_change: 0,
});
memberToken = (
await env.server.services.auth.createSessionToken(
(await env.server.stores.user.getByUsername(memberUsername))!,
)
).token;
memberFile = await makeFile(memberUsername, 'member');
ownerFile = await makeFile(env.users.user.username, 'owner');
}, 120_000);
afterAll(async () => {
await env?.shutdown();
});
// -- refused ------------------------------------------------------
it('refuses the team owner a members file', async () => {
const res = await stat(memberFile, env.users.user.token);
// Administering, paying for, and reading an account are three things.
expect(res.status).toBeGreaterThanOrEqual(400);
});
it('refuses the team owner a members home directory', async () => {
const res = await readdir(`/${memberUsername}`, env.users.user.token);
expect(res.status).toBeGreaterThanOrEqual(400);
});
it('refuses a full-access token no less than a session', async () => {
// `#scanAccessToken` re-scans the issuer as a plain user actor.
const res = await stat(memberFile, env.users.user.apiToken);
expect(res.status).toBeGreaterThanOrEqual(400);
});
it('grants no team-wide reach through the team routes', async () => {
// There is no route to a member's files or KV, and none should appear.
for (const path of [
`/teams/${teamUid}/files`,
`/teams/${teamUid}/members/${memberUsername}/files`,
`/teams/${teamUid}/kv`,
]) {
const res = await fetch(new URL(path, env.apiOrigin), {
headers: { authorization: `Bearer ${env.users.user.token}` },
});
expect(res.status, path).toBe(404);
}
});
// -- allowed ------------------------------------------------------
it('lets the team owner read its own files', async () => {
const res = await stat(ownerFile, env.users.user.token);
expect(res.status).toBe(200);
});
it('lets a member read their own files', async () => {
const res = await stat(memberFile, memberToken);
expect(res.status).toBe(200);
});
it('lets an explicit grant through, as an ordinary share', async () => {
const member = await env.server.stores.user.getByUsername(
memberUsername,
);
const [entry] = (await env.server.clients.db.read(
'SELECT `uuid` FROM `fsentries` WHERE `path` = ?',
[memberFile],
)) as { uuid: string }[];
// Only the member's own grant changes, never team authority.
const before = await stat(memberFile, env.users.user.token);
expect(before.status).toBeGreaterThanOrEqual(400);
await env.server.services.permission.grantUserUserPermission(
makeActor({ user: member!, app: null, accessToken: null } as never),
env.users.user.username,
`fs:${entry.uuid}:read`,
);
const after = await stat(memberFile, env.users.user.token);
expect(after.status).toBe(200);
});
it('disabling a member neither grants nor transfers their files', async () => {
const owner = await env.server.stores.user.getByUsername(
env.users.user.username,
);
const member = await env.server.stores.user.getByUsername(
memberUsername,
);
// A fresh file: the earlier test granted the owner read on `memberFile`.
const untouched = await makeFile(memberUsername, 'untouched');
await env.server.services.team.disableMember(
teamUid,
owner!.id,
member!.id,
);
// The member loses access to their own files...
const asMember = await stat(untouched, memberToken);
expect(asMember.status).toBeGreaterThanOrEqual(400);
// ...and the team still does not gain it.
const asOwner = await stat(untouched, env.users.user.token);
expect(asOwner.status).toBeGreaterThanOrEqual(400);
});
});
@@ -1,5 +1,6 @@
// This suite tests basic features of puter webdav. it is not a comprehensive webdav test suite unlike litmus
// but rather it performs some common sense checks to ensure that WebDAV support isn't irrevocably broken in puter
import { installSeatStub } from '../../testFixtures/seatStub.js';
import type { Request, RequestHandler, Response } from 'express';
import { Readable, Writable } from 'node:stream';
import { GetObjectCommand } from '@aws-sdk/client-s3';
@@ -736,9 +737,9 @@ describe('WebDAVController', () => {
otp_enabled: 1,
otp_secret: secret,
});
const seat = vi
.spyOn(server.stores.team, 'getOrgSeat')
.mockResolvedValue({ require_2fa: 1 } as never);
// Teams is optional, so the gate's input is stood up directly.
installSeatStub(server as never, [id], { require2fa: true });
const seat = vi.spyOn(server.stores.team as never, 'getOrgSeat');
try {
const code = new TOTP({
issuer: 'puter.com',
@@ -17,6 +17,7 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import type { Team2faLookup } from '../../core/http/middleware/gates.js';
import { compare as bcryptCompare } from 'bcrypt';
import type { Request, Response } from 'express';
import { posix as pathPosix } from 'node:path';
@@ -288,7 +289,7 @@ export class WebDAVController extends PuterController {
// filesystem over the `dav` subdomain, bypassing the gates.
assertNotSuspended(actor.user);
assertVerifiedAccount(actor.user);
await assertTeam2fa(actor.user, this.stores.team);
await assertTeam2fa(actor.user, this.stores.team as Team2faLookup);
// The actor was absent when the auth probe ran, so anything that reads
// it off the request or the context — shared-path masking, egress
+26
View File
@@ -23,6 +23,7 @@ import {
makeActor,
SYSTEM_ACTOR,
userRelatedActor,
viaWorkerOf,
type Actor,
} from './actor';
@@ -111,3 +112,28 @@ describe('assertResolvedActor', () => {
);
});
});
describe('viaWorkerOf', () => {
const user = { uuid: 'u-1', id: 1 };
it('reads a worker session or an events handler as server-side', () => {
expect(
viaWorkerOf(
makeActor({ user, session: { uid: 's-1', kind: 'worker' } }),
),
).toBe(true);
expect(viaWorkerOf(makeActor({ user, handlerDepth: 1 }))).toBe(true);
});
it('reads a browser session, an app or no actor at all as not', () => {
expect(
viaWorkerOf(
makeActor({ user, session: { uid: 's-1', kind: 'web' } }),
),
).toBe(false);
expect(viaWorkerOf(makeActor({ user, app: { uid: 'app-1' } }))).toBe(
false,
);
expect(viaWorkerOf(undefined)).toBe(false);
});
});
+7
View File
@@ -239,3 +239,10 @@ export const userRelatedActor = (actor: Actor): Actor => {
/** How many events handler runs led to a write by this actor. */
export const handlerDepthOf = (actor: Actor | undefined | null): number =>
actor?.handlerDepth ?? 0;
/**
* Whether a write by this actor was made by server-side code running as the
* account: a worker session, or an events handler.
*/
export const viaWorkerOf = (actor: Actor | undefined | null): boolean =>
actor?.session?.kind === 'worker' || handlerDepthOf(actor) > 0;
+7
View File
@@ -67,6 +67,12 @@ export interface KnownContextFields {
* receive stops early and is never metered.
*/
abortSignal: AbortSignal;
/**
* Aborts the chat driver's upstream request. A client disconnect doesn't
* fire it: the upstream bills a request it has accepted either way, so the
* driver lets it finish and meters what it reports.
*/
upstreamSignal: AbortSignal;
/**
* When true, `ChatCompletionDriver.complete` treats a request-level
* upstream 4xx as final instead of falling back to another provider — set
@@ -84,6 +90,7 @@ const KNOWN_KEY_FLAGS: Record<keyof KnownContextFields, true> = {
requestId: true,
driverName: true,
abortSignal: true,
upstreamSignal: true,
strictUpstreamErrors: true,
};
const KNOWN_KEYS: ReadonlySet<string> = new Set(Object.keys(KNOWN_KEY_FLAGS));
@@ -29,6 +29,7 @@ import {
createNativeAppStatic,
createUserSubdomainNotFound,
createWwwRedirect,
subdomainLabelOf,
type ResolvedClaim,
type SubdomainClaim,
} from './hostRedirects';
@@ -127,6 +128,18 @@ describe('createWwwRedirect', () => {
expect(next).toHaveBeenCalledTimes(1);
});
it('leaves www. alone on a host outside our domains', () => {
const req = makeReq({
subdomains: ['com', 'example', 'www'],
host: 'www.example.com',
});
(req as Request & { is_custom_domain?: boolean }).is_custom_domain =
true;
const { out, next } = run(createWwwRedirect(config), req);
expect(out.redirectArgs).toBeUndefined();
expect(next).toHaveBeenCalledTimes(1);
});
it("passes through when config.domain isn't configured (no target to redirect to)", () => {
const { out, next } = run(
createWwwRedirect({} as IConfig),
@@ -175,7 +188,7 @@ describe('createUserSubdomainNotFound', () => {
it('passes through reserved subdomains (api, js, native apps, etc.)', () => {
// `api`, `js`, `dav`, `docs`, `developer`, `editor`, `pdf`,
// `puter-app-icons`, `onlyoffice`, etc. all bypass.
// `puter-app-icons`, etc. all pass through.
for (const sub of ['api', 'js', 'docs', 'editor', 'puter-app-icons']) {
const { out, next } = run(
createUserSubdomainNotFound(config),
@@ -189,6 +202,17 @@ describe('createUserSubdomainNotFound', () => {
}
});
it('404s an extension-owned subdomain nobody has claimed', () => {
const { next } = run(
createUserSubdomainNotFound(config),
makeReq({
subdomains: ['com', 'puter', 'onlyoffice'],
host: 'onlyoffice.puter.com',
}),
);
expectNotFound(next);
});
it('passes through when no subdomain is present (root)', () => {
const { next } = run(
createUserSubdomainNotFound(config),
@@ -640,6 +664,21 @@ describe('createUserSubdomainNotFound with claimed subdomains', () => {
expect(args).toEqual([]);
});
it('hands a main-pipeline claim to the rest of the stack, like a reserved name', async () => {
const claims = lookup({ office: { mainPipeline: true } });
for (const [host, subdomains] of [
['office.puter.com', ['office']],
['office.puter.com:4100', ['office']],
['office.x.puter.com', ['x', 'office']],
] as const) {
const args = await runClaimed(
createUserSubdomainNotFound(config, claims),
hostReq(host, [...subdomains]),
);
expect(args, host).toEqual([]);
}
});
it('looks the claim up on every request', async () => {
const claims: Record<string, ResolvedClaim> = {};
const middleware = createUserSubdomainNotFound(config, lookup(claims));
@@ -650,6 +689,32 @@ describe('createUserSubdomainNotFound with claimed subdomains', () => {
});
});
describe('subdomainLabelOf', () => {
it('returns the single label under the domain, ignoring case and port', () => {
expect(subdomainLabelOf('shop.puter.com', 'puter.com')).toBe('shop');
expect(subdomainLabelOf('SHOP.Puter.com:4100', 'puter.com')).toBe(
'shop',
);
expect(subdomainLabelOf('shop.puter.com', 'Puter.com:443')).toBe(
'shop',
);
});
it('returns undefined for anything but exactly one label under the domain', () => {
for (const host of [
'puter.com',
'.puter.com',
'a.shop.puter.com',
'shop.puter.com.example.test',
'shopputer.com',
'shop.other.com',
]) {
expect(subdomainLabelOf(host, 'puter.com')).toBeUndefined();
}
expect(subdomainLabelOf('shop.puter.com', '')).toBeUndefined();
});
});
describe('addClaimedSubdomain', () => {
const claim: SubdomainClaim = { authorize: () => true };
@@ -677,7 +742,7 @@ describe('addClaimedSubdomain', () => {
});
it('rejects subdomains core already handles', () => {
for (const name of ['www', 'api', 'js', 'dav', 'docs', 'onlyoffice']) {
for (const name of ['www', 'api', 'js', 'dav', 'docs']) {
expect(() => addClaimedSubdomain(new Map(), name, claim)).toThrow(
/reserved/,
);
@@ -706,6 +771,26 @@ describe('addClaimedSubdomain', () => {
).toThrow(/invalid checkRoute/);
});
it('records a main-pipeline claim, and refuses one that also carries a gate', () => {
const claims = new Map<string, SubdomainClaim>();
addClaimedSubdomain(claims, 'onlyoffice', { mainPipeline: true });
expect(claims.get('onlyoffice')).toEqual({ mainPipeline: true });
expect(() =>
addClaimedSubdomain(claims, 'onlyoffice', { mainPipeline: true }),
).toThrow(/already claimed/);
for (const gate of [
{ authorize: () => true },
{ checkRoute: () => undefined },
]) {
expect(() =>
addClaimedSubdomain(new Map(), 'portal', {
mainPipeline: true,
...gate,
} as unknown as SubdomainClaim),
).toThrow(/main pipeline with a gate/);
}
});
it('rejects a claim without authorize', () => {
for (const bad of [undefined, {}, { authorize: true }]) {
expect(() =>
@@ -806,6 +891,18 @@ describe('createNativeAppStatic', () => {
const withRoot = () =>
createNativeAppStatic({ native_apps_root: root } as unknown as IConfig);
it('leaves a native app name alone on a host outside our domains', async () => {
const req = staticReq({
subdomains: ['com', 'example', 'editor'],
path: '/index.html',
});
(req as Request & { is_custom_domain?: boolean }).is_custom_domain =
true;
const { out, next } = await runStatic(withRoot(), req);
expect(out.sent).toBeUndefined();
expect(next).toHaveBeenCalledTimes(1);
});
it('is a no-op when native_apps_root is unset', async () => {
const { out, next } = await runStatic(
createNativeAppStatic(config),
@@ -41,8 +41,9 @@ const NATIVE_APP_SUBDOMAINS = [
const NATIVE_APPS_WITH_DIST = new Set(['docs', 'developer']);
/**
* Subdomains that v2 serves itself. Anything NOT in this set that lives on the
* root domain is treated as a user-defined site and rejected with a 404.
* Subdomains that v2 serves itself. A root-domain subdomain neither in this set
* nor claimed by an extension is treated as a user-defined site and rejected
* with a 404.
*
* Kept as a plain Set so `has()` is O(1); order doesn't matter.
*/
@@ -55,8 +56,6 @@ const RESERVED_SUBDOMAINS = new Set<string>([
...NATIVE_APP_SUBDOMAINS,
// App-icon serving subdomain.
'puter-app-icons',
// Extension-owned subdomains.
'onlyoffice',
]);
/**
@@ -69,7 +68,8 @@ export type ClaimAuthorize = (
res: Response,
) => boolean | Promise<boolean>;
export interface SubdomainClaim {
/** A claim answered on its own router, only past `authorize`. */
export interface GatedSubdomainClaim {
authorize: ClaimAuthorize;
/**
* Vets each route declared for the claim as the server builds it; a throw
@@ -78,16 +78,39 @@ export interface SubdomainClaim {
checkRoute?: (options: RouteOptions, label: string) => void;
}
/** A claim as dispatch sees it: its gate, plus the routes served behind it. */
export interface ResolvedClaim {
authorize: ClaimAuthorize;
handler?: RequestHandler;
/** Served like core's own subdomains: CORS, auth and every route. */
export interface MainPipelineSubdomainClaim {
mainPipeline: true;
}
export type SubdomainClaim = GatedSubdomainClaim | MainPipelineSubdomainClaim;
/** A claim as dispatch sees it: a gate and the routes behind it, or neither. */
export type ResolvedClaim =
| { authorize: ClaimAuthorize; handler?: RequestHandler }
| MainPipelineSubdomainClaim;
export const isMainPipelineClaim = (
claim: SubdomainClaim | ResolvedClaim | undefined,
): claim is MainPipelineSubdomainClaim =>
(claim as { mainPipeline?: unknown } | undefined)?.mainPipeline === true;
export type ClaimLookup = (name: string) => ResolvedClaim | undefined;
const SUBDOMAIN_LABEL = /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/;
/** The label `x` when `host` is exactly `x.<domain>`, ignoring case and port. */
export const subdomainLabelOf = (
host: string,
domain: string,
): string | undefined => {
const hostName = host.toLowerCase().split(':')[0];
const base = domain.toLowerCase().split(':')[0];
if (!base || !hostName.endsWith(`.${base}`)) return undefined;
const label = hostName.slice(0, -(base.length + 1));
return label && !label.includes('.') ? label : undefined;
};
/**
* Record a claim on `<name>.<domain>`. Throws on a malformed or uppercase
* label, a subdomain core already handles, or a name that is already claimed.
@@ -106,6 +129,15 @@ export const addClaimedSubdomain = (
if (claims.has(name)) {
throw new Error(`Subdomain '${name}' is already claimed`);
}
if (isMainPipelineClaim(claim)) {
if ('authorize' in claim || 'checkRoute' in claim) {
throw new Error(
`Subdomain '${name}' claimed for the main pipeline with a gate`,
);
}
claims.set(name, { mainPipeline: true });
return;
}
if (typeof claim?.authorize !== 'function') {
throw new Error(`Subdomain '${name}' claimed without authorize`);
}
@@ -131,7 +163,7 @@ const subdomainNotFound = () =>
* unclaimed-subdomain 404, and errors reach the error handler.
*/
const dispatchClaimed = async (
claim: ResolvedClaim,
claim: Exclude<ResolvedClaim, MainPipelineSubdomainClaim>,
req: Request,
res: Response,
next: NextFunction,
@@ -175,6 +207,8 @@ const dispatchClaimed = async (
export const createWwwRedirect = (config: IConfig): RequestHandler => {
const domain = (config.domain ?? '').toLowerCase();
return (req, res, next) => {
// `www.` on a host outside our domains belongs to that host's site.
if (req.is_custom_domain) return next();
const active = req.subdomains?.[req.subdomains.length - 1] ?? '';
if (active !== 'www') return next();
if (!domain) return next();
@@ -190,7 +224,8 @@ export const createWwwRedirect = (config: IConfig): RequestHandler => {
* Passes through when:
*
* - No active subdomain (root)
* - Active subdomain is reserved (api, js, native apps, …)
* - Active subdomain is reserved (api, js, native apps, …) or claimed for the
* main pipeline
* - Host is on one of the hosting domains (they may nest under `config.domain`)
* - Host is a local worker host and the local worker server is on
* - Host isn't under `config.domain` (custom domains, other hosts)
@@ -223,6 +258,8 @@ export const createUserSubdomainNotFound = (
req.subdomains?.[req.subdomains.length - 1] ?? ''
).toLowerCase();
if (active === '' || RESERVED_SUBDOMAINS.has(active)) return next();
const claim = lookupClaim(active);
if (isMainPipelineClaim(claim)) return next();
const hostName = (req.headers.host ?? '').toLowerCase().split(':')[0];
if (
@@ -239,8 +276,9 @@ export const createUserSubdomainNotFound = (
// responder, body parsing, auth and every route, `subdomain: '*'` ones
// included. One label deep only: `<name>.x.<domain>` is not the claim.
const claimed =
req.subdomains?.length === 1 && hostName === `${active}.${domain}`
? lookupClaim(active)
req.subdomains?.length === 1 &&
subdomainLabelOf(hostName, domain) === active
? claim
: undefined;
if (claimed) {
void dispatchClaimed(claimed, req, res, next);
@@ -269,6 +307,7 @@ export const createNativeAppStatic = (config: IConfig): RequestHandler => {
return (_req, _res, next) => next();
}
return async (req, res, next) => {
if (req.is_custom_domain) return next();
const active = (
req.subdomains?.[req.subdomains.length - 1] ?? ''
).toLowerCase();
@@ -950,7 +950,8 @@ describe('resolvePrivateIdentity', () => {
expect(out.userUid).toBeUndefined();
});
it('accepts an app-issued access token minted for the target app', async () => {
it('ignores an app-issued access token even for the target app', async () => {
// A scoped token (e.g. a read URL) is not the account, whoever issued it.
const user = await makeUser();
const targetUid = `app-target-${uuidv4()}`;
const issuer = makeActor({
@@ -970,11 +971,11 @@ describe('resolvePrivateIdentity', () => {
sessionCookieName: 'puter_auth_token',
expectedAppUid: targetUid,
});
expect(out.source).toBe('session-cookie');
expect(out.userUid).toBe(user.uuid);
expect(out.source).toBe('none');
expect(out.userUid).toBeUndefined();
});
it('still accepts a user-issued access token, which is portable', async () => {
it('ignores a user-issued scoped access token', async () => {
const user = await makeUser();
const issuer = makeActor({ user: { uuid: user.uuid } });
const out = await resolvePrivateIdentity({
@@ -983,7 +984,46 @@ describe('resolvePrivateIdentity', () => {
actor: makeActor({
user: { uuid: user.uuid },
accessToken: { uid: `tok-${uuidv4()}`, issuer },
session: { uid: 'sess-1' },
}),
}),
authService,
sessionCookieName: 'puter_auth_token',
expectedAppUid: `app-target-${uuidv4()}`,
});
expect(out.source).toBe('none');
expect(out.userUid).toBeUndefined();
});
it('ignores a scoped access token when the host has no app', async () => {
const user = await makeUser();
const issuer = makeActor({ user: { uuid: user.uuid } });
const out = await resolvePrivateIdentity({
req: reqOf({
cookies: {},
actor: makeActor({
user: { uuid: user.uuid },
accessToken: { uid: `tok-${uuidv4()}`, issuer },
}),
}),
authService,
sessionCookieName: 'puter_auth_token',
});
expect(out.source).toBe('none');
});
it('accepts a full-access token, which holds the account', async () => {
const user = await makeUser();
const issuer = makeActor({ user: { uuid: user.uuid } });
const out = await resolvePrivateIdentity({
req: reqOf({
cookies: {},
actor: makeActor({
user: { uuid: user.uuid },
accessToken: {
uid: `tok-${uuidv4()}`,
issuer,
fullAccess: true,
},
}),
}),
authService,
@@ -25,7 +25,7 @@ import {
} from '../../../services/auth/AuthService';
import type { AppStore } from '../../../stores/app/AppStore.js';
import type { IConfig } from '../../../types';
import type { Actor } from '../../actor';
import { isAccessTokenActor, isAccountContext, type Actor } from '../../actor';
/**
* Support helpers for the private-app access gate — ported from v1's
@@ -196,10 +196,12 @@ export async function resolveOwnedAppForHostedSite(opts: {
appStore: Pick<AppStore, 'listOwnedByIndexUrlCandidates'>;
config: PrivateHostingConfig;
requirePrivate?: boolean;
/** Match against this hosting-domain host instead of the request's. */
host?: string;
}): Promise<AppLike | null> {
if (!opts.site?.user_id) return null;
const host = normalizeHost(opts.req.hostname);
const host = normalizeHost(opts.host ?? opts.req.hostname);
if (!host) return null;
const hostedSubdomain = subdomainFromHost(host, [
@@ -215,7 +217,7 @@ export async function resolveOwnedAppForHostedSite(opts: {
const hostCandidates = new Set<string>();
hostCandidates.add(host);
const headerHost =
typeof opts.req.headers?.host === 'string'
!opts.host && typeof opts.req.headers?.host === 'string'
? opts.req.headers.host.trim().toLowerCase()
: '';
if (headerHost) hostCandidates.add(headerHost);
@@ -458,11 +460,15 @@ export async function resolvePrivateIdentity(opts: {
* "No app anywhere in the chain" is the portable case, so read `effectiveApp` —
* an app-issued access token has no `app` of its own and would otherwise pass
* as a plain session on any private host.
*
* A scoped access token (a read URL, whoever issued it) is never the account
* here: the hosted cookie minted from it would outlive the token's revocation.
*/
function actorMatchesExpectedApp(
actor: Actor,
expectedAppUid: string | undefined,
): boolean {
if (isAccessTokenActor(actor) && !isAccountContext(actor)) return false;
if (!expectedAppUid) return true;
const app = actor.effectiveApp;
if (!app) return true;
@@ -1030,6 +1030,133 @@ describe('createPuterSiteMiddleware — site.access.check', () => {
});
});
describe('createPuterSiteMiddleware — hosts outside the configured domains', () => {
const claims = new Map<string, string>();
const resolveHost = (
_key: string,
data: { host: string; result: { subdomain: string | null } },
) => {
const sub = claims.get(data.host);
if (sub) data.result.subdomain = sub;
};
beforeAll(() => {
server.clients.event.on('site.resolveHost', resolveHost as never);
});
afterAll(() => {
server.clients.event.off('site.resolveHost', resolveHost as never);
});
const customReq = (hostname: string, path = '/index.html') => {
const req = makeReq({ hostname, path });
(req as Request & { is_custom_domain?: boolean }).is_custom_domain =
true;
return req;
};
const makeSite = async (opts: { suspended?: boolean } = {}) => {
const owner = await makeUserWithHome();
const homePath = `/${owner.username}`;
const homeEntry = await server.stores.fsEntry.getEntryByPath(homePath);
const sub = `cust-${Math.random().toString(36).slice(2, 8)}`;
await server.stores.subdomain.create({
userId: owner.id,
subdomain: sub,
rootDirId: homeEntry!.id,
});
await writeFile(
owner.id,
`${homePath}/index.html`,
Buffer.from('<html>custom</html>'),
'text/html',
);
if (opts.suspended) {
await server.stores.user.update(owner.id, { suspended: 1 });
}
const host = `${sub}.example.test`;
claims.set(host, sub);
return { owner, sub, host };
};
it('serves the claimed site on its host', async () => {
const { host } = await makeSite();
const { out, next } = await runMiddleware(
buildMiddleware(),
customReq(host),
);
await new Promise<void>((resolve) => setImmediate(resolve));
expect(next).not.toHaveBeenCalled();
expect(out.statusCode).toBe(200);
expect(String(out.body)).toBe('<html>custom</html>');
});
it('404s an unclaimed host instead of falling through to the API/GUI', async () => {
const { out, next } = await runMiddleware(
buildMiddleware(),
customReq('nobody-claims-this.example.test'),
);
expect(next).not.toHaveBeenCalled();
expect(out.statusCode).toBe(404);
expect(out.body).toBe('Site not found');
});
it('404s when a listener throws', async () => {
const host = 'throws.example.test';
const boom = (_key: string, data: { host: string }) => {
if (data.host === host) throw new Error('boom');
};
server.clients.event.on('site.resolveHost', boom as never);
try {
const { out, next } = await runMiddleware(
buildMiddleware(),
customReq(host),
);
expect(next).not.toHaveBeenCalled();
expect(out.statusCode).toBe(404);
} finally {
server.clients.event.off('site.resolveHost', boom as never);
}
});
it('404s a claimed site whose owner is suspended', async () => {
const { host } = await makeSite({ suspended: true });
const { out } = await runMiddleware(buildMiddleware(), customReq(host));
expect(out.statusCode).toBe(404);
expect(out.body).toBe('Subdomain not found');
});
it('404s a private app rather than serving it ungated', async () => {
const { owner, sub, host } = await makeSite();
await server.clients.db.write(
'INSERT INTO `apps` (`uid`, `name`, `title`, `index_url`, `owner_user_id`, `is_private`) VALUES (?, ?, ?, ?, ?, ?)',
[
`app-${uuidv4()}`,
sub,
sub,
`http://${sub}.site.puter.localhost/`,
owner.id,
1,
],
);
const { out } = await runMiddleware(buildMiddleware(), customReq(host));
expect(out.statusCode).toBe(404);
expect(out.body).toBe('Site not found');
expect(out.redirected).toBeUndefined();
expect(out.cookies).toEqual({});
});
it('passes the host through untouched when custom domains are not flagged', async () => {
const { host } = await makeSite();
const { out, next } = await runMiddleware(
buildMiddleware(),
makeReq({ hostname: host }),
);
expect(next).toHaveBeenCalledTimes(1);
expect(out.statusCode).toBeUndefined();
});
});
describe('createPuterSiteMiddleware — .puter_site_config', () => {
const setupSiteWithConfig = async (config: unknown) => {
const owner = await makeUserWithHome();
@@ -1254,12 +1381,7 @@ describe('createPuterSiteMiddleware — .puter_site_config', () => {
errors: { '404': { file: '/index.html', status: 200 } },
});
const body = Buffer.from('real-page');
await writeFile(
owner.id,
`${homePath}/page.html`,
body,
'text/html',
);
await writeFile(owner.id, `${homePath}/page.html`, body, 'text/html');
const mw = buildMiddleware();
const { res, out } = makeRes();
@@ -1373,9 +1495,9 @@ describe('createPuterSiteMiddleware — .puter_site_config', () => {
// SPA fallback still fires → cache served the deleted config.
expect(second.out.statusCode).toBe(200);
expect(
(second.out.body as Buffer | undefined)?.equals(body),
).toBe(true);
expect((second.out.body as Buffer | undefined)?.equals(body)).toBe(
true,
);
});
it('ignores `errors` entries with status codes outside 4xx/5xx', async () => {
+63 -20
View File
@@ -61,12 +61,12 @@ import {
* - Private-app gate via `app.privateAccess.check` — marketplace extension
* decides; default is denied + redirect to `app-center`
* - Range / ETag / Last-Modified passthrough via `fsEntry.readContent`
* - Hosts outside the configured domains (`custom_domains_enabled`) map to a site
* via `site.resolveHost`; unclaimed ones 404. Public sites only.
*
* Deferred (not yet implemented):
*
* - `.at` username-based sites (UUIDv5-keyed `/user/Public`).
* - Custom domains (subdomains table `domain` column) — requires host validation
* to allow arbitrary hostnames first.
*
* Site config:
*
@@ -228,29 +228,60 @@ export const createPuterSiteMiddleware = (
if (!host) return next();
const matched = matchHostingDomain(host);
if (!matched) return next();
// A host outside every configured domain. Listeners may map it to a
// site; an unclaimed one is answered here rather than falling through
// to the API/GUI routers.
const isCustomHost = !matched && Boolean(req.is_custom_domain);
if (!matched && !isCustomHost) return next();
// Bare hosting domain (e.g. `puter.site`) → redirect to the main site.
if (host === matched) {
if (domain) {
res.redirect(302, `${req.protocol}://${domain}`);
let subdomain: string;
if (isCustomHost) {
const resolveEvent = {
host,
result: { subdomain: null as string | null },
};
try {
await layers.clients.event.emitAndWait(
'site.resolveHost',
resolveEvent,
{},
);
} catch {
resolveEvent.result.subdomain = null;
}
const claimed = resolveEvent.result.subdomain;
if (typeof claimed !== 'string' || !claimed) {
res.status(404).type('text/plain').send('Site not found');
return;
}
res.status(404).type('text/plain').send('Subdomain not found');
return;
}
subdomain = claimed;
} else {
const hostingDomain = matched as string;
// Bare hosting domain (e.g. `puter.site`) → redirect to the main site.
if (host === hostingDomain) {
if (domain) {
res.redirect(302, `${req.protocol}://${domain}`);
return;
}
res.status(404).type('text/plain').send('Subdomain not found');
return;
}
// `host` is `<prefix>.<matched>`; subdomain is the left-most label.
const prefix = host.slice(0, host.length - matched.length - 1);
const subdomain = prefix.split('.')[0] || '';
// `host` is `<prefix>.<matched>`; subdomain is the left-most label.
const prefix = host.slice(
0,
host.length - hostingDomain.length - 1,
);
subdomain = prefix.split('.')[0] || '';
if (!subdomain || subdomain === 'www') {
if (domain) {
res.redirect(302, `${req.protocol}://${domain}`);
if (!subdomain || subdomain === 'www') {
if (domain) {
res.redirect(302, `${req.protocol}://${domain}`);
return;
}
res.status(404).type('text/plain').send('Subdomain not found');
return;
}
res.status(404).type('text/plain').send('Subdomain not found');
return;
}
const site = (await layers.stores.subdomain.getBySubdomain(
@@ -286,6 +317,11 @@ export const createPuterSiteMiddleware = (
site: { user_id: site.user_id },
appStore: layers.stores.app,
config: hostingCfg,
// App index URLs name the hosting-domain address, not the
// custom host the visitor used.
host: isCustomHost
? `${subdomain}.${hostingDomains[0]}`
: undefined,
}),
);
const rootLookup =
@@ -300,7 +336,14 @@ export const createPuterSiteMiddleware = (
const isPrivateApp = Boolean(associatedApp?.is_private);
const privateApp = isPrivateApp ? associatedApp : null;
if (isPrivateApp) {
if (isCustomHost) {
// The private-app gate and its cookies are bound to the private
// hosting domain, so these hosts serve public sites only.
if (isPrivateApp) {
res.status(404).type('text/plain').send('Site not found');
return;
}
} else if (isPrivateApp) {
// Private apps must run on the private hosting domain. If a
// visitor arrives via the public domain (puter.site), redirect
// them to the equivalent private-host URL so the cookie scope
@@ -448,7 +491,7 @@ export const createPuterSiteMiddleware = (
res.redirect(302, cleanPath);
return;
}
} else if (privateHostingDomains.has(matched)) {
} else if (privateHostingDomains.has(matched as string)) {
// Non-private content landed on the private hosting domain —
// mirror of the private redirect above. Covers two cases:
// - a paid app that just flipped to free (is_private 1→0)
+81
View File
@@ -0,0 +1,81 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/**
* What core asks of Teams. Nothing here is an implementation: the pair is
* registered by whatever provides the feature, and a deployment that does not
* run it registers neither, so every caller treats both as absent.
*/
/** A team, as much of one as core reads back. */
export interface TeamRow {
id: number;
uid: string;
owner_user_id: number;
kind: string;
name: string | null;
handle: string | null;
deleted_at: string | null;
created_at: string;
/** Whether an app acting for a member may read the member list. */
directory_enabled: number;
/** Whether the accounts this team provisioned must hold 2FA. */
require_2fa: number;
}
/** The seat a user holds, for the gates that ask whether they hold one. */
export interface OrgSeatRow {
id: number;
user_id: number;
uuid: string;
username: string;
team_uid: string;
team_name: string | null;
owner_user_id: number;
/** Carried here so the per-request 2FA gate costs no extra read. */
require_2fa: number;
}
/** Most members one notification fans out to. */
export const NOTIFY_FANOUT_CAP = 500;
/** The reads core makes of teams it did not create. */
export interface TeamStoreContract {
getOrgSeat(userId: number): Promise<OrgSeatRow | null>;
isMember(teamUid: string, userId: number): Promise<boolean>;
getByUid(uid: string): Promise<TeamRow | null>;
getByHandle(handle: string): Promise<TeamRow | null>;
getByIdIncludingDeleted(id: number): Promise<TeamRow | null>;
getByIdsIncludingDeleted(ids: number[]): Promise<Map<number, TeamRow>>;
listGroupIdsForUser(userId: number): Promise<number[]>;
listMemberIdsByGroupId(groupId: number): Promise<number[]>;
memberIdsAmong(teamUid: string, userIds: number[]): Promise<number[]>;
countOwned(ownerUserId: number): Promise<number>;
countOwnedTeamsWithAccounts(ownerUserId: number): Promise<number>;
}
/** The calls core makes when an account it owns changes. */
export interface TeamServiceContract {
teamsAvailableTo(userId: number, email: string | null): Promise<boolean>;
captureSeatForBilling(userId: number): Promise<unknown | null>;
emitSeatDeleted(seat: unknown): void;
forgetSeat(seat: unknown): Promise<void>;
releaseUnconfirmedSeatEmail(userId: number): Promise<boolean>;
recordPasswordSelfChange(userId: number): Promise<void>;
}
@@ -35,7 +35,7 @@ describe('chat cancellation over HTTP', () => {
let env: PuterTestEnv;
let restoreFetch: () => void;
let actor: Actor;
let onRequest: (res: ServerResponse) => void;
let onRequest: (res: ServerResponse, body: string) => void;
beforeAll(async () => {
upstream = createServer((req, res) => {
@@ -44,8 +44,12 @@ describe('chat cancellation over HTTP', () => {
res.end(JSON.stringify({ models: [{ name: 'cancel-test' }] }));
return;
}
req.resume();
req.once('end', () => onRequest(res));
let body = '';
req.setEncoding('utf8');
req.on('data', (chunk: string) => {
body += chunk;
});
req.once('end', () => onRequest(res, body));
});
await new Promise<void>((resolve) =>
upstream.listen(0, '127.0.0.1', resolve),
@@ -143,24 +147,137 @@ describe('chat cancellation over HTTP', () => {
);
};
const usage = { prompt_tokens: 3, completion_tokens: 5, total_tokens: 8 };
const responsesUsage = {
input_tokens: 3,
output_tokens: 5,
total_tokens: 8,
input_tokens_details: { cached_tokens: 0 },
output_tokens_details: { reasoning_tokens: 0 },
};
const response = {
id: 'resp_1',
object: 'response',
status: 'completed',
output: [
{
type: 'message',
id: 'msg_1',
role: 'assistant',
status: 'completed',
content: [
{ type: 'output_text', text: 'done', annotations: [] },
],
},
],
usage: responsesUsage,
};
const sse = (events: unknown[]) =>
events.map((event) => `data: ${JSON.stringify(event)}\n\n`).join('');
/**
* A complete upstream answer, with usage, in the shape the request asked
* for.
*/
const finishAnswer = (res: ServerResponse, body: string) => {
const stream = (JSON.parse(body) as { stream?: boolean }).stream;
const responses = res.req.url === '/v1/responses';
if (!stream) {
res.setHeader('Content-Type', 'application/json');
res.end(
JSON.stringify(
responses
? response
: {
id: 'chatcmpl_1',
object: 'chat.completion',
created: 0,
model: 'cancel-test',
choices: [
{
index: 0,
message: {
role: 'assistant',
content: 'done',
},
finish_reason: 'stop',
},
],
usage,
},
),
);
return;
}
if (!res.headersSent)
res.setHeader('Content-Type', 'text/event-stream');
res.end(
responses
? sse([
{ type: 'response.output_text.delta', delta: 'done' },
{ type: 'response.completed', response },
])
: `${sse([
{
id: 'chatcmpl_1',
object: 'chat.completion.chunk',
choices: [
{
index: 0,
delta: { content: 'done' },
finish_reason: 'stop',
},
],
},
{
id: 'chatcmpl_1',
object: 'chat.completion.chunk',
choices: [],
usage,
},
])}data: [DONE]\n\n`,
);
};
/**
* Whether a usage entry the upstream reported, not an estimate, was
* recorded.
*/
const meteredReportedUsage = (spy: ReturnType<typeof vi.spyOn>) =>
spy.mock.calls
.flatMap((call) => (call[1] as { usageType: string }[]) ?? [])
.some(
(u) =>
!u.usageType.includes('estimated_') &&
/completion|output_tokens/.test(u.usageType),
);
it.each(
routes.flatMap((route) =>
[false, true].map((stream) => ({ route, stream })),
),
)(
'cancels upstream while waiting for headers: $route (stream=$stream)',
'bills an upstream the caller left while waiting for headers: $route (stream=$stream)',
async ({ route, stream }) => {
let upstreamClosed = false;
let upstreamRes!: ServerResponse;
let upstreamBody = '';
let upstreamClosedEarly = false;
let received!: () => void;
const ready = new Promise<void>((resolve) => {
received = resolve;
});
onRequest = (res) => {
onRequest = (res, body) => {
upstreamRes = res;
upstreamBody = body;
res.once('close', () => {
upstreamClosed = true;
if (!res.writableFinished) upstreamClosedEarly = true;
});
received();
};
const metered = vi.spyOn(
env.server.services.metering,
'batchIncrementUsages',
);
const abort = new AbortController();
const request = call(route, abort.signal, stream).catch(
(error: unknown) => error,
@@ -176,36 +293,50 @@ describe('chat cancellation over HTTP', () => {
);
}),
]);
abort.abort();
expect(await request).toMatchObject({ name: 'AbortError' });
await new Promise((resolve) => setTimeout(resolve, 100));
expect(upstreamClosedEarly).toBe(false);
expect(
await env.server.services.metering.getOutstandingHolds(
actor,
),
).toBeGreaterThan(0);
abort.abort();
expect(await request).toMatchObject({ name: 'AbortError' });
await expect.poll(() => upstreamClosed).toBe(true);
finishAnswer(upstreamRes, upstreamBody);
await expect
.poll(() =>
env.server.services.metering.getOutstandingHolds(actor),
)
.toBe(0);
expect(upstreamClosedEarly).toBe(false);
expect(meteredReportedUsage(metered)).toBe(true);
} finally {
abort.abort();
metered.mockRestore();
upstream.closeAllConnections();
}
},
);
it.each(routes)(
'cancels an idle upstream after partial output: %s',
'bills an upstream the caller left after partial output: %s',
async (route) => {
let upstreamClosed = false;
onRequest = (res) => {
let upstreamRes!: ServerResponse;
let upstreamBody = '';
let upstreamClosedEarly = false;
onRequest = (res, body) => {
upstreamRes = res;
upstreamBody = body;
res.once('close', () => {
upstreamClosed = true;
if (!res.writableFinished) upstreamClosedEarly = true;
});
writeText(res);
};
const metered = vi.spyOn(
env.server.services.metering,
'batchIncrementUsages',
);
const abort = new AbortController();
try {
const response = await call(route, abort.signal);
@@ -219,14 +350,20 @@ describe('chat cancellation over HTTP', () => {
text += new TextDecoder().decode(chunk.value);
}
abort.abort();
await expect.poll(() => upstreamClosed).toBe(true);
await new Promise((resolve) => setTimeout(resolve, 100));
expect(upstreamClosedEarly).toBe(false);
finishAnswer(upstreamRes, upstreamBody);
await expect
.poll(() =>
env.server.services.metering.getOutstandingHolds(actor),
)
.toBe(0);
expect(upstreamClosedEarly).toBe(false);
expect(meteredReportedUsage(metered)).toBe(true);
} finally {
abort.abort();
metered.mockRestore();
upstream.closeAllConnections();
}
},
@@ -633,6 +633,40 @@ describe('ChatCompletionDriver exhausted-chain classification', () => {
expect(err.message).toBe('unsupported parameter: top_k');
});
it('maps a gateway 4xx with no upstream error body to 400 upstream_provider_unavailable', async () => {
const err = await errorFor({
status: 400,
message: '400 bad response status code 400',
error: {
type: 'upstream_error',
message: 'bad response status code 400',
code: 400,
},
});
expect(err).toMatchObject({
statusCode: 400,
legacyCode: 'upstream_provider_unavailable',
});
expect(err.message).toBe('AI provider unavailable');
});
it('keeps a gateway 4xx that carries the vendor message as the caller’s upstream_bad_request', async () => {
const err = await errorFor({
status: 400,
message:
'400 bad response status code 400, message: max_tokens too large, body: {}',
error: {
type: 'upstream_error',
message:
'bad response status code 400, message: max_tokens too large, body: {}',
},
});
expect(err).toMatchObject({
statusCode: 400,
legacyCode: 'upstream_bad_request',
});
});
it('maps a completion with no choices to a 400, not an internal 500', async () => {
vi.spyOn(FakeChatProvider.prototype, 'complete').mockImplementation(
() =>
@@ -809,6 +843,26 @@ describe('ChatCompletionDriver cross-provider fallback', () => {
expect(line).toContain('first 422');
});
it('falls back past a gateway 4xx with no upstream error body and marks the route unhealthy', async () => {
vi.spyOn(console, 'warn').mockImplementation(() => undefined);
vi.spyOn(AzureChatProvider.prototype, 'complete').mockRejectedValue(
Object.assign(new Error('400 bad response status code 400'), {
status: 400,
error: { type: 'upstream_error' },
}),
);
vi.spyOn(OpenAiChatProvider.prototype, 'complete').mockResolvedValue({
message: { role: 'assistant', content: 'from the fallback' },
usage: { prompt_tokens: 1, completion_tokens: 1 },
} as never);
const result = (await completeShared()) as {
message: { content: string };
};
expect(result.message.content).toBe('from the fallback');
expect(isRouteUnhealthy('azure-openai', SHARED_MODEL)).toBe(true);
});
it('surfaces the primary route-level 4xx after a non-strict fallback also fails', async () => {
vi.spyOn(AzureChatProvider.prototype, 'complete').mockRejectedValue(
Object.assign(new Error('unsupported parameter: frobnicate'), {
@@ -47,7 +47,8 @@ import { ChatCompletionDriver } from './ChatCompletionDriver.js';
import { clearUnhealthyRoutes, isRouteUnhealthy } from './utils/providerHealth.js';
import { FakeChatProvider } from './providers/FakeChatProvider.js';
import type { IChatCompleteResult } from './types.js';
import { type AIChatStream, ChatStreamAbortedError } from './utils/Streaming.js';
import { SDK_TIMEOUT_MS } from './utils/sdkClient.js';
import type { AIChatStream } from './utils/Streaming.js';
let server: PuterServer;
@@ -905,7 +906,7 @@ describe('ChatCompletionDriver when the caller hangs up', () => {
).toBe(true);
});
it('stops the generation and gives the hold back', async () => {
it('keeps generating after the hang-up and bills the reported usage', async () => {
const actor = freeUser() as never;
const metering = server.services.metering;
const abort = new AbortController();
@@ -914,6 +915,7 @@ describe('ChatCompletionDriver when the caller hangs up', () => {
resume = r;
});
let providerError: unknown;
let finished = false;
vi.spyOn(FakeChatProvider.prototype, 'complete').mockResolvedValueOnce(
streamOf(async ({ chatStream }) => {
@@ -921,8 +923,13 @@ describe('ChatCompletionDriver when the caller hangs up', () => {
block.addText('first');
await paused;
try {
// An upstream still generating: keeps writing until told.
for (let i = 0; i < 1000; i++) block.addText('more');
// An upstream still generating after the caller left.
for (let i = 0; i < 1000; i++) {
await chatStream.waitForDrain();
block.addText('more');
}
chatStream.end({ input_tokens: 3, output_tokens: 1001 });
finished = true;
} catch (e) {
providerError = e;
throw e;
@@ -930,6 +937,7 @@ describe('ChatCompletionDriver when the caller hangs up', () => {
}) as never,
);
const metered = vi.spyOn(metering, 'batchIncrementUsages');
const events = vi.spyOn(server.clients.event, 'emit');
const { stream } = (await withTestActor(() => {
Context.set('abortSignal', abort.signal);
@@ -950,15 +958,120 @@ describe('ChatCompletionDriver when the caller hangs up', () => {
}
await new Promise((r) => setTimeout(r, 20));
expect(providerError).toBeInstanceOf(ChatStreamAbortedError);
// Nobody is listening, so no error line is written for the abort.
expect(body).not.toContain('"type":"error"');
// The hold goes back once the usage write lands.
expect(providerError).toBeUndefined();
expect(finished).toBe(true);
// Nothing reaches a caller who has gone, including the usage line.
expect(body).not.toContain('more');
expect(body).not.toContain('"type":"usage"');
await vi.waitFor(async () =>
expect(await metering.getOutstandingHolds(actor)).toBe(0),
);
expect(
meteredUsages(metered).some((u) =>
u.usageType.includes('estimated_'),
),
).toBe(false);
expect(
events.mock.calls.some(
([key, data]) =>
key === 'ai.prompt.cost-calculated' &&
(data as { usage?: { output_tokens?: number } }).usage
?.output_tokens === 1001,
),
).toBe(true);
});
it('bills a stream whose caller left before the first token', async () => {
const actor = freeUser() as never;
const abort = new AbortController();
vi.spyOn(FakeChatProvider.prototype, 'complete').mockImplementationOnce(
async () => {
// The caller hangs up while the upstream is still answering.
abort.abort();
return streamOf(async ({ chatStream }) => {
chatStream
.message()
.contentBlock({ type: 'text' })
.addText('answer');
chatStream.end({ input_tokens: 3, output_tokens: 2 });
});
},
);
const events = vi.spyOn(server.clients.event, 'emit');
const { stream } = (await withTestActor(() => {
Context.set('abortSignal', abort.signal);
return driver.complete({
model: 'priced',
messages: [{ role: 'user', content: 'go' }],
stream: true,
});
}, actor)) as unknown as { stream: Readable };
await drain(stream);
expect(
events.mock.calls.some(
([key, data]) =>
key === 'ai.prompt.cost-calculated' &&
(data as { usage?: { output_tokens?: number } }).usage
?.output_tokens === 2,
),
).toBe(true);
await vi.waitFor(async () =>
expect(await server.services.metering.getOutstandingHolds(actor)).toBe(0),
);
});
it('cuts off an upstream that outlives its caller by the SDK timeout', async () => {
const actor = freeUser() as never;
const metering = server.services.metering;
const abort = new AbortController();
let upstreamAborted = false;
vi.spyOn(FakeChatProvider.prototype, 'complete').mockResolvedValueOnce(
streamOf(async ({ chatStream }) => {
chatStream
.message()
.contentBlock({ type: 'text' })
.addText('x'.repeat(400));
const signal = Context.get('upstreamSignal')!;
// A stalled upstream: no more chunks until it's cut off.
await new Promise((_resolve, reject) => {
signal.addEventListener('abort', () => {
upstreamAborted = true;
reject(signal.reason);
});
});
}) as never,
);
const metered = vi.spyOn(metering, 'batchIncrementUsages');
const timers = vi.spyOn(globalThis, 'setTimeout');
const { stream } = (await withTestActor(() => {
Context.set('abortSignal', abort.signal);
return driver.complete({
model: 'priced',
messages: [{ role: 'user', content: 'go' }],
stream: true,
});
}, actor)) as unknown as { stream: Readable };
abort.abort();
const limit = timers.mock.calls.find(
([, ms]) => ms === SDK_TIMEOUT_MS,
);
expect(limit).toBeDefined();
await new Promise((r) => setTimeout(r, 20));
expect(upstreamAborted).toBe(false);
(limit![0] as () => void)();
await drain(stream);
expect(upstreamAborted).toBe(true);
expect(
meteredUsages(metered).some((u) =>
u.usageType.endsWith('estimated_output_tokens'),
),
).toBe(true);
await vi.waitFor(async () =>
expect(await metering.getOutstandingHolds(actor)).toBe(0),
);
// What was generated before the hang-up is still charged.
expect(metered).toHaveBeenCalled();
});
});
@@ -116,6 +116,7 @@ import {
UNHEALTHY_TTL_SEC,
} from './utils/providerHealth.js';
import { CATALOG_TTL_MS } from './utils/cachedRemoteCatalog.js';
import { SDK_TIMEOUT_MS } from './utils/sdkClient.js';
import { fromFinishReason } from './utils/stopReason.js';
import { AIChatStream } from './utils/Streaming.js';
import {
@@ -323,8 +324,17 @@ const isAuthFailure = (a: ProviderAttempt) =>
a.status === 403 ||
/unauthorized|forbidden|invalid api key/i.test(a.error));
/**
* A new-api gateway's (Infron's) wrapper for an upstream that answered without
* a JSON error body. It mirrors the upstream's status, so the 4xx says nothing
* about the caller's request; a real vendor rejection carries its own message.
*/
const isOpaqueGatewayFailure = (a: ProviderAttempt) =>
/bad response status code \d+(?:,\s*body:|$)/i.test(a.error);
const isUpstream5xx = (a: ProviderAttempt) =>
(a.status !== undefined && a.status >= 500) ||
isOpaqueGatewayFailure(a) ||
/provider returned error|internal server error|service unavailable|bad gateway/i.test(
a.error,
);
@@ -671,6 +681,10 @@ export class ChatCompletionDriver extends PuterDriver {
throw new HttpError(401, 'Authentication required', {
legacyCode: 'unauthorized',
});
// The caller hanging up stops nothing that has reached an upstream: it
// would bill the request anyway, so it runs to its usage report.
const upstream = new AbortController();
Context.set('upstreamSignal', upstream.signal);
// AI charges recorded in this request before the completion started.
const priorCharges = requestAiCosts().length;
@@ -871,6 +885,8 @@ export class ChatCompletionDriver extends PuterDriver {
if (!useFakeProvider) {
await this.#resolvePuterPaths(attemptProvider, args, actor);
}
// Last point where a caller who left costs nothing.
abortSignal?.throwIfAborted();
return attemptProvider.complete(
{
...args,
@@ -1033,10 +1049,20 @@ export class ChatCompletionDriver extends PuterDriver {
const init = res.init_chat_stream;
const cleanup = res.finally_fn;
// Providers also receive this signal to cancel while waiting for output.
const abortStream = () => chatStream.abort();
if (abortSignal?.aborted) abortStream();
else abortSignal?.addEventListener('abort', abortStream);
// A caller who leaves stops getting output, but the upstream keeps
// going until it reports usage, with as long to finish as a
// non-streaming completion gets.
let detachedLimit: ReturnType<typeof setTimeout> | undefined;
const detachStream = () => {
chatStream.detach();
detachedLimit = setTimeout(
() => upstream.abort(),
SDK_TIMEOUT_MS,
);
detachedLimit.unref?.();
};
if (abortSignal?.aborted) detachStream();
else abortSignal?.addEventListener('abort', detachStream);
// Intercept `chatStream.end(usage)` to fire complete + cost events
// (mirrors the non-streaming branch). Clone usage so providers that
@@ -1060,9 +1086,9 @@ export class ChatCompletionDriver extends PuterDriver {
enrichedUsage.cost = charged();
}
}
// A cancelled stream isn't a completion, but whatever it
// metered still gets its cost row.
if (!chatStream.aborted) {
// A completion nobody received isn't announced, but it still
// gets its cost row.
if (!chatStream.detached) {
this.clients.event.emit(
'ai.prompt.complete',
{
@@ -1129,13 +1155,13 @@ export class ChatCompletionDriver extends PuterDriver {
(async () => {
try {
await init({ chatStream });
if (!chatStream.aborted && !passthrough.writableEnded) {
if (!passthrough.writableEnded) {
throw new Error('Stream ended before completion');
}
} catch (e) {
if (!chatStream.aborted) {
if (!chatStream.detached) {
const attempt = toAttempt(model.id, model.provider!, e);
passthrough.write(
chatStream.write(
`${JSON.stringify({
type: 'error',
message: sanitizeUpstreamMessage(
@@ -1151,7 +1177,8 @@ export class ChatCompletionDriver extends PuterDriver {
passthrough.end();
} finally {
clearInterval(renewHold);
abortSignal?.removeEventListener('abort', abortStream);
clearTimeout(detachedLimit);
abortSignal?.removeEventListener('abort', detachStream);
// Providers report usage the moment they meter it (see
// `AIChatStream.reportUsage`); a stream that never got
// there was never charged for.
@@ -25,10 +25,10 @@ import { AIChatStream } from '../utils/Streaming.js';
import { FakeChatProvider } from './FakeChatProvider.js';
describe('fake chat streaming lifecycle', () => {
it('cancels the simulated delay before writing output', async () => {
it('cancels the simulated delay when the upstream signal aborts', async () => {
await withTestActor(async () => {
const abort = new AbortController();
Context.set('abortSignal', abort.signal);
Context.set('upstreamSignal', abort.signal);
const result = await new FakeChatProvider().complete({
model: 'fake',
stream: true,
@@ -59,7 +59,7 @@ describe('fake chat streaming lifecycle', () => {
.map((line) => JSON.parse(line));
expect(events.map((event) => event.type)).toEqual(['text', 'usage']);
});
it('does not write after the chat stream was aborted independently', async () => {
it('finishes without writing after the caller detaches', async () => {
const result = await new FakeChatProvider().complete({
model: 'fake',
stream: true,
@@ -67,8 +67,9 @@ describe('fake chat streaming lifecycle', () => {
});
const stream = new PassThrough();
const chatStream = new AIChatStream({ stream });
chatStream.abort();
chatStream.detach();
await result.init_chat_stream!({ chatStream });
expect(stream.readableLength).toBe(0);
expect(stream.writableEnded).toBe(true);
});
});
@@ -101,12 +101,11 @@ export class FakeChatProvider implements IChatProvider {
chatStream: AIChatStream;
}) => {
await delay(500, undefined, {
signal: Context.get('abortSignal'),
signal: Context.get('upstreamSignal'),
});
const response = await resp;
if (chatStream.aborted) return;
await chatStream.waitForDrain();
chatStream.stream.write(
chatStream.write(
`${JSON.stringify({
type: 'text',
text: response.message.content[0].text,
@@ -175,7 +175,7 @@ export class OpenAICompatProvider implements IChatProvider {
}
}
const actor = Context.get('actor');
const signal = Context.get('abortSignal');
const signal = Context.get('upstreamSignal');
let messages = args.messages;
if (
@@ -311,7 +311,7 @@ export class AzureChatProvider implements IChatProvider {
const completion = await this.#openAi.chat.completions.create(
completionParams,
{ signal: Context.get('abortSignal') },
{ signal: Context.get('upstreamSignal') },
);
return OpenAiUtil.handle_completion_output({
@@ -326,7 +326,7 @@ export class AzureResponsesProvider implements IChatProvider {
const completion = await this.#openAi.responses.create(
completionParams,
{ signal: Context.get('abortSignal') },
{ signal: Context.get('upstreamSignal') },
);
return OpenAiUtil.handle_completion_output_responses_api({
usage_calculator: ({
@@ -320,7 +320,7 @@ describe('ClaudeProvider.complete request shape', () => {
};
it.each([false, true])(
'passes cancellation to the SDK with stream=%s',
'passes the upstream signal to the SDK with stream=%s',
async (stream) => {
const { provider } = makeProvider();
const abort = new AbortController();
@@ -328,7 +328,7 @@ describe('ClaudeProvider.complete request shape', () => {
messagesStreamMock.mockReturnValueOnce(makeStreamLike([]));
else messagesCreateMock.mockResolvedValueOnce(baseResponse);
await withTestActor(() => {
Context.set('abortSignal', abort.signal);
Context.set('upstreamSignal', abort.signal);
return provider.complete({
model: 'claude-sonnet-4-6',
messages: [{ role: 'user', content: 'hi' }],
@@ -389,10 +389,10 @@ export class ClaudeProvider implements IChatProvider {
if (args.stream) {
const completion = usesBeta
? this.anthropic.beta.messages.stream(sdkParams as never, {
signal: Context.get('abortSignal'),
signal: Context.get('upstreamSignal'),
})
: this.anthropic.messages.stream(sdkParams as never, {
signal: Context.get('abortSignal'),
signal: Context.get('upstreamSignal'),
});
// Subscribed before the request is awaited: the SDK only queues
// events for iterators that already exist.
@@ -432,10 +432,10 @@ export class ClaudeProvider implements IChatProvider {
try {
const msg = await (usesBeta
? this.anthropic.beta.messages.create(sdkParams as never, {
signal: Context.get('abortSignal'),
signal: Context.get('upstreamSignal'),
})
: this.anthropic.messages.create(sdkParams as never, {
signal: Context.get('abortSignal'),
signal: Context.get('upstreamSignal'),
}));
const { usage, costs, details, advisorModel } = this.#meter(
(msg as Message).usage,
@@ -337,11 +337,11 @@ describe('gemini-4-argon catalog and routing', () => {
});
});
it('does not call Google when the request was already cancelled', async () => {
it('does not call Google when the upstream signal already aborted', async () => {
const abort = new AbortController();
abort.abort();
const error = await withTestActor(() => {
Context.set('abortSignal', abort.signal);
Context.set('upstreamSignal', abort.signal);
return makeProvider().complete({
model: 'gemini-4-argon',
messages: [{ role: 'user', content: 'hi' }],
@@ -213,12 +213,12 @@ describe('GroqAIProvider.complete request shape', () => {
usage: { prompt_tokens: 1, completion_tokens: 1 },
};
it('passes the request cancellation signal to the SDK', async () => {
it('passes the upstream signal to the SDK', async () => {
const { provider } = makeProvider();
const abort = new AbortController();
createMock.mockResolvedValueOnce(baseCompletion);
await withTestActor(() => {
Context.set('abortSignal', abort.signal);
Context.set('upstreamSignal', abort.signal);
return provider.complete({
model: 'openai/gpt-oss-20b',
messages: [{ role: 'user', content: 'hi' }],
@@ -229,19 +229,19 @@ describe('MistralAIProvider.complete request shape', () => {
usage: { promptTokens: 1, completionTokens: 1 },
};
it('passes the request cancellation signal to the SDK', async () => {
it('passes the upstream signal to the SDK', async () => {
const { provider } = makeProvider();
const abort = new AbortController();
completeMock.mockResolvedValueOnce(baseCompletion);
await withTestActor(() => {
Context.set('abortSignal', abort.signal);
Context.set('upstreamSignal', abort.signal);
return provider.complete({
model: 'mistral-small-latest',
messages: [{ role: 'user', content: 'hi' }],
});
});
// Bounded by the SDK timeout as well, and still cancelled with the
// request.
// Bounded by the SDK timeout as well, and still aborted with the
// upstream signal.
const signal: AbortSignal = completeMock.mock.calls[0][1]?.signal;
expect(signal.aborted).toBe(false);
abort.abort();
@@ -243,8 +243,8 @@ export class MistralAIProvider implements IChatProvider {
// signal is passed; a stream is bounded by its caller instead.
{
signal: stream
? Context.get('abortSignal')
: withSdkTimeout(Context.get('abortSignal')),
? Context.get('upstreamSignal')
: withSdkTimeout(Context.get('upstreamSignal')),
},
);
} catch (e) {
@@ -927,7 +927,7 @@ describe('OpenAiChatProvider.complete error mapping', () => {
describe('provider request cancellation', () => {
it.each([false, true])(
'passes cancellation to the SDK with stream=%s',
'passes the upstream signal to the SDK with stream=%s',
async (stream) => {
const { provider } = makeProvider();
const abort = new AbortController();
@@ -945,7 +945,7 @@ describe('provider request cancellation', () => {
},
);
await withTestActor(() => {
Context.set('abortSignal', abort.signal);
Context.set('upstreamSignal', abort.signal);
return provider.complete({
model: 'gpt-4o-mini',
messages: [{ role: 'user', content: 'hi' }],
@@ -250,7 +250,7 @@ export class OpenAiChatProvider implements IChatProvider {
const completion = await this.#openAi.chat.completions.create(
completionParams,
{ signal: Context.get('abortSignal') },
{ signal: Context.get('upstreamSignal') },
);
return OpenAiUtil.handle_completion_output({
@@ -307,7 +307,7 @@ export class OpenAiResponsesChatProvider implements IChatProvider {
const completion = await this.#openAi.responses.create(
completionParams,
{ signal: Context.get('abortSignal') },
{ signal: Context.get('upstreamSignal') },
);
return OpenAiUtil.handle_completion_output_responses_api({
usage_calculator: ({ usage, webSearchCalls, setUsageCosts }) => {
@@ -97,10 +97,10 @@ export class XAIProvider implements IChatProvider {
}
: {}),
} as ChatCompletionCreateParams,
{ signal: Context.get('abortSignal') },
{ signal: Context.get('upstreamSignal') },
);
} catch (e) {
if (!Context.get('abortSignal')?.aborted) {
if (!Context.get('upstreamSignal')?.aborted) {
console.log('XAI AI process_input_messages error: ', e);
}
throw e;
@@ -651,8 +651,9 @@ export const create_chat_stream_handler =
}
}
Context.get('abortSignal')?.throwIfAborted();
if (!finish_reason && !last_usage) {
// Some SDKs end an aborted stream as if it were complete.
Context.get('upstreamSignal')?.throwIfAborted();
throw new Error('Stream ended before completion');
}
@@ -824,8 +825,11 @@ export const create_chat_stream_handler_responses_api =
}
}
Context.get('abortSignal')?.throwIfAborted();
if (!completed) throw new Error('Stream ended before completion');
if (!completed) {
// Some SDKs end an aborted stream as if it were complete.
Context.get('upstreamSignal')?.throwIfAborted();
throw new Error('Stream ended before completion');
}
// TODO DS: this is a bit too abstracted... this is basically just doing the metering now
// Missing usage is left undefined rather than fed to the calculator —
@@ -1919,7 +1919,7 @@ describe('cancelled upstream iterators', () => {
const harness = makeCapturingChatStream();
await expect(
withTestActor(() => {
Context.set('abortSignal', abort.signal);
Context.set('upstreamSignal', abort.signal);
return handler({
completion,
usage_calculator: usageCalculator,
@@ -1932,6 +1932,58 @@ describe('cancelled upstream iterators', () => {
expect(usageCalculator).not.toHaveBeenCalled();
},
);
it.each([
[
'chat completions',
create_chat_stream_handler,
[
{ choices: [{ delta: { content: 'partial' } }] },
{
choices: [],
usage: { prompt_tokens: 3, completion_tokens: 5 },
},
],
],
[
'responses',
create_chat_stream_handler_responses_api,
[
{ type: 'response.output_text.delta', delta: 'partial' },
{
type: 'response.completed',
response: {
status: 'completed',
usage: { input_tokens: 3, output_tokens: 5 },
},
},
],
],
])(
'meters %s usage that arrived before the caller left',
async (_name, handler, chunks) => {
const abort = new AbortController();
const usageCalculator = vi.fn(() => ({ output_tokens: 5 }));
const completion = {
async *[Symbol.asyncIterator]() {
yield* chunks;
abort.abort();
},
};
const harness = makeCapturingChatStream();
await withTestActor(() => {
Context.set('abortSignal', abort.signal);
return handler({
completion,
usage_calculator: usageCalculator,
})({ chatStream: harness.chatStream });
});
expect(usageCalculator).toHaveBeenCalledTimes(1);
expect(harness.chatStream.reportedUsage).toEqual({
output_tokens: 5,
});
},
);
});
+27 -47
View File
@@ -169,9 +169,9 @@ export class AIChatStream {
*/
reportedUsage = null;
/** Set once the caller has gone away; see `abort`. */
aborted = false;
abortController = new AbortController();
/** Set once the caller has gone away; see `detach`. */
detached = false;
#detachController = new AbortController();
/**
* Stop/usage/context-management state for the end-of-stream `usage` line,
@@ -199,28 +199,31 @@ export class AIChatStream {
}
/**
* Stop accepting output after cancellation; providers cancel their requests
* too.
* Stop writing output once the caller has gone away. The provider keeps
* reading its upstream, so the usage the upstream reports still gets
* metered.
*/
abort() {
this.aborted = true;
this.abortController.abort();
detach() {
this.detached = true;
this.#detachController.abort();
}
/** False once nothing will read the output. */
#writable() {
return !this.detached && !this.stream.destroyed;
}
async waitForDrain() {
this.#assertOpen();
if (!this.stream.writableNeedDrain) return;
if (!this.#writable() || !this.stream.writableNeedDrain) return;
await new Promise((resolve, reject) => {
const signal = this.#detachController.signal;
const cleanup = () => {
this.stream.removeListener('drain', onDrain);
this.stream.removeListener('close', onAbort);
this.stream.removeListener('drain', onDone);
this.stream.removeListener('close', onDone);
this.stream.removeListener('error', onError);
this.abortController.signal.removeEventListener(
'abort',
onAbort,
);
signal.removeEventListener('abort', onDone);
};
const onDrain = () => {
const onDone = () => {
cleanup();
resolve();
};
@@ -228,28 +231,16 @@ export class AIChatStream {
cleanup();
reject(error);
};
const onAbort = () => onError(new ChatStreamAbortedError());
this.stream.once('drain', onDrain);
this.stream.once('close', onAbort);
this.stream.once('drain', onDone);
this.stream.once('close', onDone);
this.stream.once('error', onError);
this.abortController.signal.addEventListener('abort', onAbort, {
once: true,
});
if (this.stream.destroyed || this.aborted) onAbort();
else if (!this.stream.writableNeedDrain) onDrain();
signal.addEventListener('abort', onDone, { once: true });
if (!this.#writable() || !this.stream.writableNeedDrain) onDone();
});
this.#assertOpen();
}
#assertOpen() {
if (this.aborted) throw new ChatStreamAbortedError();
}
/** @param {string} text */
countOutput(text) {
// Tool-call input arrives through here and is only written when the
// block ends, so a long tool call has to notice the abort here too.
this.#assertOpen();
if (typeof text === 'string') this.outputChars += text.length;
}
@@ -279,9 +270,8 @@ export class AIChatStream {
* @param {{ alreadyCounted?: boolean }} [opts]
*/
writeChunk(chunk, { alreadyCounted = false } = {}) {
this.#assertOpen();
if (!alreadyCounted) this.outputChars += payloadChars(chunk);
this.stream.write(`${JSON.stringify(chunk)}\n`);
if (this.#writable()) this.stream.write(`${JSON.stringify(chunk)}\n`);
}
/**
@@ -384,8 +374,7 @@ export class AIChatStream {
*/
end(usage, extra) {
this.reportUsage(usage);
// Usage still counts after an abort; only the line has no reader.
if (!this.aborted) {
if (this.#writable()) {
const stop = this.stop;
const finish_reason = stop?.reason
? toFinishReason(stop.reason)
@@ -436,16 +425,7 @@ export class AIChatStream {
return new AIChatMessageStream(this);
}
write(...args) {
this.#assertOpen();
return this.stream.write(...args);
}
}
/** Thrown into a provider's read loop once its caller has gone away. */
export class ChatStreamAbortedError extends Error {
constructor() {
super('Chat stream aborted: the caller disconnected');
this.name = 'ChatStreamAbortedError';
if (this.#writable()) this.stream.write(...args);
}
}
@@ -25,7 +25,6 @@ import Streaming, {
AIChatStream,
AIChatTextStream,
AIChatToolUseStream,
ChatStreamAbortedError,
} from './Streaming.js';
// AIChatStream + friends emit newline-delimited JSON to an underlying
@@ -189,26 +188,27 @@ describe('AIChatStream stop + usage-detail reporting', () => {
});
});
// ── Abort ──────────────────────────────────────────────────────────
// -- Detach --
describe('AIChatStream.abort', () => {
it('throws into the next text, reasoning or tool-call write', () => {
describe('AIChatStream.detach', () => {
it('stops writing but keeps counting output', () => {
const h = makeHarness();
const text = h.chatStream.message().contentBlock({ type: 'text' });
const tool = h.chatStream
.message()
.contentBlock({ type: 'tool_use', id: 't', name: 'f' });
h.chatStream.abort();
expect(() => text.addText('x')).toThrow(ChatStreamAbortedError);
expect(() => text.addReasoning('x')).toThrow(ChatStreamAbortedError);
expect(() => tool.addPartialJSON('{')).toThrow(ChatStreamAbortedError);
expect(() => h.chatStream.write('raw')).toThrow(ChatStreamAbortedError);
h.chatStream.detach();
text.addText('abc');
text.addReasoning('de');
tool.addPartialJSON('{}');
h.chatStream.write('raw');
expect(h.chatStream.outputChars).toBe(7);
expect(h.rawChunks()).toEqual([]);
});
it('still records usage and ends the stream, writing nothing', () => {
const h = makeHarness();
h.chatStream.abort();
h.chatStream.detach();
h.chatStream.end({ output_tokens: 3 });
expect(h.chatStream.reportedUsage).toEqual({ output_tokens: 3 });
expect(h.rawChunks()).toEqual([]);
@@ -448,16 +448,25 @@ describe('AIChatStream backpressure', () => {
sink.destroy();
});
it.each(['abort', 'close', 'error'])('releases a drain wait on %s', async (event) => {
it.each(['detach', 'close'])('releases a drain wait on %s', async (event) => {
const sink = new Writable({ highWaterMark: 1, write() {} });
const chatStream = new AIChatStream({ stream: sink });
chatStream.writeChunk({ type: 'text', text: 'hello' });
const waiting = chatStream.waitForDrain();
const rejected = expect(waiting).rejects.toThrow(event === 'error' ? 'failed' : 'Chat stream aborted');
if (event === 'abort') chatStream.abort();
else if (event === 'error') sink.destroy(new Error('failed'));
if (event === 'detach') chatStream.detach();
else sink.destroy();
await rejected;
await waiting;
expect(sink.listenerCount('drain')).toBe(0);
await chatStream.waitForDrain();
});
it('rejects a drain wait when the writable errors', async () => {
const sink = new Writable({ highWaterMark: 1, write() {} });
const chatStream = new AIChatStream({ stream: sink });
chatStream.writeChunk({ type: 'text', text: 'hello' });
const waiting = chatStream.waitForDrain();
sink.destroy(new Error('failed'));
await expect(waiting).rejects.toThrow('failed');
expect(sink.listenerCount('drain')).toBe(0);
});
});
@@ -40,10 +40,13 @@ import {
type MockInstance,
} from 'vitest';
import { v4 as uuidv4 } from 'uuid';
import { runWithContext } from '../../core/context.js';
import { SYSTEM_ACTOR } from '../../core/actor.js';
import { type Actor, SYSTEM_ACTOR } from '../../core/actor.js';
import { PuterServer } from '../../server.js';
import { setupTestServer } from '../../testUtil.js';
import { withAiCostFactorHook } from '../integrationTestUtil.js';
import { generateDefaultFsentries } from '../../util/userProvisioning.js';
import { CLOUDFLARE_IMAGE_GENERATION_MODELS } from './providers/cloudflare/models.js';
import cloudflareModelSearchFixture from './providers/cloudflare/fixtures/model-search.json';
import { GEMINI_IMAGE_GENERATION_MODELS } from './providers/gemini/models.js';
@@ -981,6 +984,56 @@ describe('ImageGenerationDriver.generate provider routing', () => {
expect(result).toBe('https://rp/img.png');
expect(createPredictionMock).toHaveBeenCalledTimes(1);
});
it('registers Kling from the shared `kling` key and routes its models there', async () => {
const klingDriver = new ImageGenerationDriver(
{
providers: {
'openai-image-generation': { apiKey: 'oai-key' },
kling: { apiKey: 'kl-key' },
},
} as never,
server.clients,
server.stores,
server.services,
);
await klingDriver.onServerStart();
const logged = () =>
eventEmitSpy.mock.calls.filter(([key]) => key === 'ai.log.image')
.at(-1)?.[1];
await withActor(() =>
klingDriver.generate({
provider: 'kling',
prompt: 'hi',
test_mode: true,
}),
);
expect(logged()).toMatchObject({
model_used: 'kling-v3',
service_used: 'kling-image-generation',
});
await withActor(() =>
klingDriver.generate({
model: 'kling:kling/kling-v3-omni',
prompt: 'hi',
test_mode: true,
}),
);
expect(logged()).toMatchObject({
model_used: 'kling-v3-omni',
service_used: 'kling-image-generation',
});
// Kling registers after OpenAI, so it never becomes the default.
await withActor(() =>
klingDriver.generate({ prompt: 'hi', test_mode: true }),
);
expect(logged()).toMatchObject({
service_used: 'openai-image-generation',
});
});
});
// ── Ratio normalization ────────────────────────────────────────────
@@ -1027,6 +1080,126 @@ describe('ImageGenerationDriver.generate ratio normalization', () => {
});
});
// ── Puter file image inputs ───────────────────────────────────────
describe('ImageGenerationDriver.generate Puter file inputs', () => {
const PNG_BASE64 =
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==';
const makeUser = async (): Promise<{ actor: Actor; userId: number }> => {
const username = `image-media-${Math.random().toString(36).slice(2, 10)}`;
const created = await server.stores.user.create({
username,
uuid: uuidv4(),
password: null,
email: `${username}@test.local`,
free_storage: 100 * 1024 * 1024,
requires_email_confirmation: false,
});
await generateDefaultFsentries(
server.clients.db,
server.stores.user,
created,
);
const user = (await server.stores.user.getById(created.id))!;
return {
userId: user.id,
actor: {
user: {
id: user.id,
uuid: user.uuid,
username: user.username,
email: user.email ?? null,
email_confirmed: true,
} as Actor['user'],
},
};
};
const writeImage = async (
{ actor, userId }: { actor: Actor; userId: number },
name: string,
) => {
const path = `/${actor.user.username}/${name}`;
const bytes = Buffer.from(PNG_BASE64, 'base64');
await server.services.fs.write(userId, {
fileMetadata: {
path,
size: bytes.byteLength,
contentType: 'application/octet-stream',
},
fileContent: bytes,
});
return path;
};
const generateAs = (actor: Actor, args: Record<string, unknown>) =>
Promise.resolve(
runWithContext({ actor }, () =>
driver.generate({
provider: 'gemini',
model: 'gemini-3-pro-image',
prompt: 'hi',
...args,
} as never),
),
);
beforeEach(() => {
vi.spyOn(server.services.metering, 'hasEnoughCredits').mockResolvedValue(
true,
);
});
it("sends Puter file images to the provider, leaving the caller's array and the audit log on the paths", async () => {
const user = await makeUser();
const path = await writeImage(user, 'input.tmp');
const inputImages = [path];
googleAIGenerateContentMock.mockResolvedValueOnce({
candidates: [
{
content: {
parts: [
{ inlineData: { data: 'aW1n', mimeType: 'image/png' } },
],
},
},
],
});
await generateAs(user.actor, { input_images: inputImages });
const sent = JSON.stringify(
googleAIGenerateContentMock.mock.calls[0]![0].contents,
);
expect(sent).toContain(PNG_BASE64);
expect(sent).not.toContain(path);
expect(inputImages).toEqual([path]);
const [, log] = eventEmitSpy.mock.calls.find(
([eventName]) => eventName === 'ai.log.image',
)!;
expect(
(log as { parameters: { input_images: string[] } }).parameters
.input_images,
).toEqual([path]);
expect(secureFetchMock).not.toHaveBeenCalled();
});
it("refuses another user's image before calling the provider", async () => {
const owner = await makeUser();
const intruder = await makeUser();
const path = await writeImage(owner, 'private.png');
const status = await generateAs(intruder.actor, {
input_image: path,
}).then(
() => null,
(err: { statusCode?: number }) => err.statusCode,
);
expect([403, 404]).toContain(status);
expect(googleAIGenerateContentMock).not.toHaveBeenCalled();
});
});
// ── Audit log ──────────────────────────────────────────────────────
describe('ImageGenerationDriver.generate audit log', () => {
@@ -1473,3 +1646,34 @@ it.each([undefined, null, '', ' ', 42, {}])(
});
},
);
describe('ImageGenerationDriver cost factor', () => {
it('holds and meters at the AI cost factor', async () => {
const reserveSpy = vi.spyOn(server.services.metering, 'reserveCredits');
const usageSpy = vi.spyOn(
server.services.metering,
'batchIncrementUsages',
);
const generate = async (charges: number) => {
openaiImagesGenerateMock.mockResolvedValueOnce({
data: [{ url: 'https://oai/img.png' }],
});
await withActor(() =>
driver.generate({ model: 'gpt-image-2', prompt: 'hi' }),
);
await vi.waitFor(() =>
expect(usageSpy).toHaveBeenCalledTimes(charges),
);
};
await generate(1);
await withAiCostFactorHook(server.clients.event, 2, () => generate(2));
const costs = (call: number) =>
usageSpy.mock.calls[call]![1].map((u) => u.costOverride!);
expect(costs(0).length).toBeGreaterThan(0);
expect(costs(1)).toEqual(costs(0).map((c) => Math.round(c * 2)));
const held = reserveSpy.mock.calls.map(([, amount]) => amount);
expect(held[0]).toBeGreaterThan(0);
expect(held).toEqual([held[0], Math.round(held[0]! * 2)]);
});
});
@@ -32,12 +32,14 @@ import {
resolveOutputPath,
saveGeneratedMediaToFS,
} from '../util/generatedMedia.js';
import { readPuterImageInputs } from '../util/mediaInput.js';
import { ModelCatalog } from '../util/modelCatalog.js';
import { CATALOG_TTL_MS } from '../ai-chat/utils/cachedRemoteCatalog.js';
import { readProviderKey } from '../util/providerRegistry.js';
import { BytePlusImageProvider } from './providers/byteplus/BytePlusImageProvider.js';
import { CloudflareImageProvider } from './providers/cloudflare/CloudflareImageProvider.js';
import { GeminiImageProvider } from './providers/gemini/GeminiImageProvider.js';
import { KlingImageProvider } from './providers/kling/KlingImageProvider.js';
import { OpenAiImageProvider } from './providers/openai/OpenAiImageProvider.js';
import { ReplicateImageGenerationProvider } from './providers/replicate/ReplicateImageGenerationProvider.js';
import { resolveImageSize } from './imageDimensions.js';
@@ -72,6 +74,7 @@ export class ImageGenerationDriver extends PuterDriver {
'xai-image-generation',
'replicate-image-generation',
'byteplus-image-generation',
'kling-image-generation',
];
readonly isDefault = true;
@@ -328,7 +331,12 @@ export class ImageGenerationDriver extends PuterDriver {
{},
);
const result = await provider.generate(request);
// After the audit log, which records the paths rather than the bytes.
const result = await provider.generate(
request.test_mode
? request
: await this.#withPuterImagesInlined(actor, request),
);
if (resolvedOutputPath) {
await saveGeneratedMediaToFS(
@@ -343,6 +351,34 @@ export class ImageGenerationDriver extends PuterDriver {
return result;
}
/**
* `request` with image inputs given as Puter file paths read within the
* caller's access and replaced by data URIs. `request` is left as is.
*/
async #withPuterImagesInlined(
actor: Actor,
request: IGenerateParams,
): Promise<IGenerateParams> {
const { input_image: inputImage, input_images: inputImages } = request;
const inlined = await readPuterImageInputs(
{ stores: this.stores, fs: this.services.fs },
actor,
[inputImage, request.maskImage, ...(inputImages ?? [])],
);
if (inlined.size === 0) return request;
const swap = <T>(value: T): T | string =>
(typeof value === 'string' && inlined.get(value)) || value;
return {
...request,
...(inputImage && { input_image: swap(inputImage) }),
...(inputImages && { input_images: inputImages.map(swap) }),
...(request.maskImage != null && {
maskImage: swap(request.maskImage),
}),
};
}
#registerProviders() {
const providers = this.config.providers ?? {};
const m = this.#aiMetering;
@@ -445,6 +481,20 @@ export class ImageGenerationDriver extends PuterDriver {
m,
);
}
// One `kling` key covers video, image and TTS.
const klingCfg = (providers['kling-image-generation'] ??
providers['kling']) as Record<string, unknown> | undefined;
const klingKey = readProviderKey(klingCfg);
if (klingKey) {
this.#providers['kling-image-generation'] = new KlingImageProvider(
{
apiKey: klingKey,
apiBaseUrl: klingCfg?.apiBaseUrl as string | undefined,
},
m,
);
}
}
/** Rebuilds the model map; overlapping callers share one rebuild. */
@@ -0,0 +1,63 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/**
* Integration test for the Kling image provider.
*
* Generates one 1K image on kling-v2-1 ($0.014/image). Skipped when
* `PUTER_TEST_AI_KLING_API_KEY` is unset.
*/
import { describe, expect, it } from 'vitest';
import {
INTEGRATION_TEST_TIMEOUT_MS,
makeMeteringStub,
optionalEnv,
skipUnlessEnv,
withTestActor,
} from '../../../integrationTestUtil.js';
import { KlingImageProvider } from './KlingImageProvider.js';
const ENV_VAR = 'PUTER_TEST_AI_KLING_API_KEY';
describe.skipIf(skipUnlessEnv(ENV_VAR))(
'KlingImageProvider (integration)',
() => {
it(
'generates an image URL from kling-v2-1',
{ timeout: INTEGRATION_TEST_TIMEOUT_MS },
async () => {
const provider = new KlingImageProvider(
{ apiKey: optionalEnv(ENV_VAR)! },
makeMeteringStub(),
);
const url = await withTestActor(() =>
provider.generate({
model: 'kling-v2-1',
prompt: 'a single red dot on a white background',
quality: '1k',
}),
);
expect(url.startsWith('https://')).toBe(true);
},
);
},
);
@@ -0,0 +1,351 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/**
* Offline unit tests for KlingImageProvider.
*
* Boots a real PuterServer and constructs the provider against the live wired
* `MeteringService`. Kling's task API has no SDK; the provider calls it through
* global `fetch`, which is stubbed as the network egress point.
*/
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
type MockInstance,
} from 'vitest';
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
import { PuterServer } from '../../../../server.js';
import { setupTestServer } from '../../../../testUtil.js';
import { withTestActor } from '../../../integrationTestUtil.js';
import { withAiCostFactor } from '../../../util/aiCostFactor.js';
import type { IGenerateParams } from '../../types.js';
import { KlingImageProvider } from './KlingImageProvider.js';
// -- Test harness ----------------------------------------------------
let server: PuterServer;
let fetchSpy: MockInstance<typeof fetch>;
let hasCreditsSpy: MockInstance<MeteringService['hasEnoughCredits']>;
let incrementUsageSpy: MockInstance<MeteringService['incrementUsage']>;
beforeAll(async () => {
server = await setupTestServer();
});
afterAll(async () => {
await server?.shutdown();
});
const makeProvider = () =>
new KlingImageProvider(
{ apiKey: 'test-key', pollIntervalMs: 1 },
withAiCostFactor(
server.services.metering,
server.clients.event,
'ai-image',
),
);
beforeEach(() => {
fetchSpy = vi.spyOn(globalThis, 'fetch') as MockInstance<typeof fetch>;
hasCreditsSpy = vi.spyOn(server.services.metering, 'hasEnoughCredits');
incrementUsageSpy = vi.spyOn(server.services.metering, 'incrementUsage');
incrementUsageSpy.mockResolvedValue({} as never);
});
afterEach(() => {
vi.restoreAllMocks();
});
const envelope = (data: unknown, code = 0, status = 200) =>
new Response(JSON.stringify({ code, message: 'msg', data }), {
status,
headers: { 'content-type': 'application/json' },
});
const task = (status: string, extra: Record<string, unknown> = {}) => ({
task_id: 'task-1',
task_status: status,
...extra,
});
const succeeded = task('succeed', {
task_result: { images: [{ index: 0, url: 'https://kling.example/1.png' }] },
});
/** Queue the create response, then each poll response. */
const mockFlow = (...polls: Response[]) => {
fetchSpy.mockResolvedValueOnce(envelope(task('submitted')));
for (const poll of polls) fetchSpy.mockResolvedValueOnce(poll);
};
const call = (i: number) => {
const [url, init] = fetchSpy.mock.calls[i]!;
return {
url: String(url),
method: (init as RequestInit).method,
body: (init as RequestInit).body
? JSON.parse((init as RequestInit).body as string)
: undefined,
};
};
const generate = (params: Partial<IGenerateParams>) =>
withTestActor(() =>
makeProvider().generate({ prompt: 'a red apple', ...params }),
);
// -- Construction ----------------------------------------------------
describe('KlingImageProvider construction', () => {
it('throws when no apiKey is supplied', () => {
expect(
() =>
new KlingImageProvider(
{ apiKey: '' },
withAiCostFactor(
server.services.metering,
server.clients.event,
'ai-image',
),
),
).toThrow(/API key/i);
});
it('defaults to Kling Image 3.0', () => {
expect(makeProvider().getDefaultModel()).toBe('kling-v3');
});
});
// -- Request shape ---------------------------------------------------
describe('KlingImageProvider request', () => {
it('creates a text-to-image task and returns the polled image URL', async () => {
mockFlow(envelope(task('processing')), envelope(succeeded));
const url = await generate({});
expect(url).toBe('https://kling.example/1.png');
const create = call(0);
expect(create.url).toBe(
'https://api-singapore.klingai.com/v1/images/generations',
);
expect(create.method).toBe('POST');
expect(create.body).toEqual({
model_name: 'kling-v3',
prompt: 'a red apple',
n: 1,
resolution: '1k',
aspect_ratio: '1:1',
});
expect(call(1).url).toBe(
'https://api-singapore.klingai.com/v1/images/generations/task-1',
);
expect(call(1).method).toBe('GET');
expect(fetchSpy).toHaveBeenCalledTimes(3);
const headers = (fetchSpy.mock.calls[0]![1] as RequestInit)
.headers as Record<string, string>;
expect(headers.Authorization).toBe('Bearer test-key');
});
it.each([
['high', '2k'],
['2K', '2k'],
['low', '1k'],
['ultra', '1k'],
])('maps quality %s to the %s tier', async (quality, tier) => {
mockFlow(envelope(succeeded));
await generate({ quality });
expect(call(0).body.resolution).toBe(tier);
});
it('takes 4k only on Image 3.0 Omni', async () => {
mockFlow(envelope(succeeded));
await generate({ model: 'kling-v3-omni', quality: '4k' });
expect(call(0).body.resolution).toBe('4k');
fetchSpy.mockReset();
mockFlow(envelope(succeeded));
await generate({ model: 'kling-v3', quality: '4k' });
expect(call(0).body.resolution).toBe('1k');
});
it('snaps the requested size to the closest Kling aspect ratio', async () => {
mockFlow(envelope(succeeded));
await generate({ imageSize: { w: 1920, h: 1000, kind: 'pixels' } });
expect(call(0).body.aspect_ratio).toBe('16:9');
fetchSpy.mockReset();
mockFlow(envelope(succeeded));
await generate({ imageSize: { w: 2, h: 3, kind: 'aspect' } });
expect(call(0).body.aspect_ratio).toBe('2:3');
});
it('sends a negative prompt only without a reference image', async () => {
mockFlow(envelope(succeeded));
await generate({ negative_prompt: 'blur' });
expect(call(0).body.negative_prompt).toBe('blur');
fetchSpy.mockReset();
mockFlow(envelope(succeeded));
await generate({
negative_prompt: 'blur',
input_image: 'https://example.com/a.png',
});
expect(call(0).body.negative_prompt).toBeUndefined();
expect(call(0).body.image).toBe('https://example.com/a.png');
});
it('sends a data URI reference image as raw base64', async () => {
mockFlow(envelope(succeeded));
await generate({ input_images: ['data:image/png;base64,iVBORw0KGgo'] });
expect(call(0).body.image).toBe('iVBORw0KGgo');
});
it.each(['kling-v3-omni', 'kling-image-o1'])(
'sends %s reference images to omni-image with an auto ratio',
async (model) => {
mockFlow(envelope(succeeded));
await generate({
model,
input_images: [
'https://example.com/a.png',
'data:image/jpeg;base64,/9j/4AAQ',
],
});
const create = call(0);
expect(create.url).toBe(
'https://api-singapore.klingai.com/v1/images/omni-image',
);
expect(create.body).toMatchObject({
model_name: model,
aspect_ratio: 'auto',
image_list: [
{ image: 'https://example.com/a.png' },
{ image: '/9j/4AAQ' },
],
});
expect(call(1).url).toBe(
'https://api-singapore.klingai.com/v1/images/omni-image/task-1',
);
},
);
it('rejects more input images than the model takes before any fetch', async () => {
await expect(
generate({
model: 'kling-v3',
input_images: ['https://e.com/a.png', 'https://e.com/b.png'],
}),
).rejects.toMatchObject({ statusCode: 400 });
expect(fetchSpy).not.toHaveBeenCalled();
});
it('returns the sample image in test mode without calling Kling', async () => {
const url = await generate({ test_mode: true });
expect(url).toMatch(/^https:\/\//);
expect(fetchSpy).not.toHaveBeenCalled();
});
it('retries a transient poll failure', async () => {
mockFlow(
envelope(undefined, 5000, 500),
envelope(task('processing')),
envelope(succeeded),
);
await expect(generate({})).resolves.toBe('https://kling.example/1.png');
expect(fetchSpy).toHaveBeenCalledTimes(4);
});
});
// -- Billing ---------------------------------------------------------
describe('KlingImageProvider billing', () => {
it('meters one image at the tier rate', async () => {
mockFlow(envelope(succeeded));
await generate({ model: 'kling-v3-omni', quality: '4k' });
expect(incrementUsageSpy).toHaveBeenCalledTimes(1);
const [, usageType, amount, cost] = incrementUsageSpy.mock.calls[0]!;
expect(usageType).toBe('kling-image-generation:kling-v3-omni:4k');
expect(amount).toBe(1);
expect(cost).toBe(5.6 * 1_000_000);
});
it('bills Image 2.1 image-to-image at its own rate', async () => {
mockFlow(envelope(succeeded));
await generate({
model: 'kling-v2-1',
input_image: 'https://example.com/a.png',
});
const [, usageType, , cost] = incrementUsageSpy.mock.calls[0]!;
expect(usageType).toBe(
'kling-image-generation:kling-v2-1:1k:image-to-image',
);
expect(cost).toBe(2.8 * 1_000_000);
incrementUsageSpy.mockClear();
fetchSpy.mockReset();
mockFlow(envelope(succeeded));
await generate({ model: 'kling-v2-1' });
const [, textUsage, , textCost] = incrementUsageSpy.mock.calls[0]!;
expect(textUsage).toBe('kling-image-generation:kling-v2-1:1k');
expect(textCost).toBe(1.4 * 1_000_000);
});
it('rejects before calling Kling when credits run short', async () => {
hasCreditsSpy.mockResolvedValueOnce(false);
await expect(generate({})).rejects.toMatchObject({ statusCode: 402 });
expect(fetchSpy).not.toHaveBeenCalled();
});
it('reports a content-policy failure as moderation_flagged, unbilled', async () => {
mockFlow(
envelope(
task('failed', {
task_status_msg:
'Triggering the content risk control of the platform',
}),
),
);
await expect(generate({})).rejects.toMatchObject({
statusCode: 400,
code: 'moderation_flagged',
});
expect(incrementUsageSpy).not.toHaveBeenCalled();
});
it('reports any other failed task as upstream_failed, unbilled', async () => {
mockFlow(
envelope(task('failed', { task_status_msg: 'internal error' })),
);
await expect(generate({})).rejects.toMatchObject({
statusCode: 502,
legacyCode: 'upstream_failed',
});
expect(incrementUsageSpy).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,305 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { Context } from '../../../../core/context.js';
import { HttpError } from '../../../../core/http/HttpError.js';
import { insufficientCreditsError } from '../../../../services/metering/enforcement.js';
import type { AiMeteringService } from '../../../util/aiCostFactor.js';
import { klingImageInput, klingRequest } from '../../../util/kling.js';
import {
CONTENT_FILTER_PATTERN,
isTransientUpstreamError,
sanitizeUpstreamMessage,
} from '../../../util/upstreamErrors.js';
import {
closestAspectRatio,
formatAspectRatio,
} from '../../imageDimensions.js';
import { assertImagePrompt } from '../../imageValidation.js';
import type { IGenerateParams, IImageProvider } from '../../types.js';
import {
KLING_IMAGE_GENERATION_MODELS,
type KlingImageModel,
} from './models.js';
const DEFAULT_MODEL = 'kling-v3';
const MAX_PROMPT_CHARS = 2500;
const POLL_INTERVAL_MS = 2_000;
const POLL_WINDOW_MS = 5 * 60 * 1000;
type KlingImageConfig = {
apiKey: string;
apiBaseUrl?: string;
/** Test hook: polling cadence for task status checks. */
pollIntervalMs?: number;
};
interface KlingImageTask {
task_id: string;
task_status: 'submitted' | 'processing' | 'succeed' | 'failed';
task_status_msg?: string;
task_result?: { images?: Array<{ url?: string }> };
}
const badRequest = (message: string) =>
new HttpError(400, message, { legacyCode: 'bad_request' });
/**
* Kling AI image generation (Image 3.0, 3.0 Omni, O1, 2.1).
*
* Kling's image API is task-based: the create call returns a task id, which is
* polled until it settles. Billed per image at the resolution tier's rate.
* https://kling.ai/document-api/api/image/3-0-omni/image-generation
*/
export class KlingImageProvider implements IImageProvider {
#apiKey: string;
#baseUrl: string | undefined;
#pollIntervalMs: number;
#meteringService: AiMeteringService;
constructor(config: KlingImageConfig, meteringService: AiMeteringService) {
if (!config.apiKey) {
throw new Error('Kling image generation requires an API key');
}
this.#apiKey = config.apiKey;
this.#baseUrl = config.apiBaseUrl?.replace(/\/$/, '');
this.#pollIntervalMs = config.pollIntervalMs ?? POLL_INTERVAL_MS;
this.#meteringService = meteringService;
}
models(): KlingImageModel[] {
return KLING_IMAGE_GENERATION_MODELS;
}
getDefaultModel(): string {
return DEFAULT_MODEL;
}
async generate(params: IGenerateParams): Promise<string> {
const {
prompt,
test_mode: testMode,
quality,
negative_prompt,
} = params;
const model = this.#getModel(params.model);
if (testMode) {
return 'https://puter-sample-data.puter.site/image_example.png';
}
assertImagePrompt(prompt);
if (prompt.length > MAX_PROMPT_CHARS) {
throw badRequest(
`Kling takes a prompt of at most ${MAX_PROMPT_CHARS} characters`,
);
}
const inputs = (
params.input_images?.length
? params.input_images
: params.input_image
? [params.input_image]
: []
).map(klingImageInput);
if (inputs.length > model.maxInputImages) {
throw badRequest(
`${model.id} accepts at most ${model.maxInputImages} input image(s)`,
);
}
const tier = this.#tier(quality, model);
const costKey =
inputs.length > 0 && model.costs[`${tier}:image-to-image`]
? `${tier}:image-to-image`
: tier;
const cents = model.costs[costKey];
const usageType = `kling-image-generation:${model.id}:${costKey}`;
const actor = Context.get('actor');
if (!actor) {
throw new HttpError(401, 'Authentication required', {
legacyCode: 'unauthorized',
});
}
const hold = await this.#meteringService.reserveAiCredits(
actor,
usageType,
Math.round(cents * 1_000_000),
);
if (!hold) throw insufficientCreditsError();
try {
const aspectRatio = params.imageSize
? formatAspectRatio(
closestAspectRatio(
params.imageSize,
model.allowedRatios!,
),
)
: inputs.length > 0 && model.autoRatio
? 'auto'
: '1:1';
const body: Record<string, unknown> = {
model_name: model.id,
prompt,
n: 1,
resolution: tier,
aspect_ratio: aspectRatio,
};
if (model.endpoint === 'omni-image') {
if (inputs.length > 0) {
body.image_list = inputs.map((image) => ({ image }));
}
} else if (inputs.length > 0) {
body.image = inputs[0];
} else if (
typeof negative_prompt === 'string' &&
negative_prompt.trim()
) {
// Kling takes a negative prompt only without a reference image.
body.negative_prompt = negative_prompt;
}
const path = `/v1/images/${model.endpoint}`;
const created = await klingRequest<KlingImageTask>(
this.#apiKey,
'POST',
path,
{ body, baseUrl: this.#baseUrl },
);
const task = await this.#pollUntilSettled(path, created);
if (task.task_status !== 'succeed') {
throw this.#taskFailure(task.task_status_msg);
}
const url = task.task_result?.images?.[0]?.url;
if (!url) {
throw new Error('Kling response did not include an image URL');
}
await this.#meteringService.incrementUsage(
actor,
usageType,
1,
Math.round(cents * 1_000_000),
);
return url;
} finally {
await hold.release();
}
}
async #pollUntilSettled(
path: string,
created: KlingImageTask,
): Promise<KlingImageTask> {
const signal = Context.get('abortSignal') as AbortSignal | undefined;
const deadline = Date.now() + POLL_WINDOW_MS;
let task = created;
while (
task.task_status === 'submitted' ||
task.task_status === 'processing'
) {
if (signal?.aborted) {
throw new HttpError(
400,
'Client disconnected before image generation finished',
{
legacyCode: 'client_aborted',
fields: { provider: 'kling' },
},
);
}
if (Date.now() > deadline) {
throw new HttpError(
504,
'Timed out waiting for Kling image generation to complete',
{
legacyCode: 'upstream_timeout',
fields: { provider: 'kling' },
},
);
}
await new Promise((resolve) =>
setTimeout(resolve, this.#pollIntervalMs),
);
try {
task = await klingRequest<KlingImageTask>(
this.#apiKey,
'GET',
`${path}/${encodeURIComponent(created.task_id)}`,
{ baseUrl: this.#baseUrl },
);
} catch (err) {
// A missed poll is not a failed task.
if (!isTransientUpstreamError(err)) throw err;
}
}
return task;
}
/**
* A content-policy refusal is the caller's to act on; anything else
* Kling's.
*/
#taskFailure(message: string | undefined): HttpError {
const detail =
sanitizeUpstreamMessage(message ?? '') ||
'Kling image generation failed';
const fields = { provider: 'kling' };
if (CONTENT_FILTER_PATTERN.test(detail)) {
return new HttpError(400, detail, {
legacyCode: 'bad_request',
code: 'moderation_flagged',
fields,
});
}
return new HttpError(502, detail, {
legacyCode: 'upstream_failed',
fields,
});
}
/** Kling's `1k` / `2k` / `4k`, accepting other providers' quality names. */
#tier(quality: string | undefined, model: KlingImageModel): string {
const allowed = model.allowedQualityLevels!;
const wanted = (quality ?? '').trim().toLowerCase();
const synonym: Record<string, string> = {
low: '1k',
medium: '1k',
high: '2k',
hd: '2k',
};
const tier = allowed.includes(wanted) ? wanted : synonym[wanted];
return tier && allowed.includes(tier) ? tier : allowed[0];
}
#getModel(model?: string): KlingImageModel {
const wanted = (model ?? '').trim().toLowerCase();
return (
KLING_IMAGE_GENERATION_MODELS.find(
(m) =>
m.id === wanted ||
m.puterId === wanted ||
m.aliases?.includes(wanted),
) ??
KLING_IMAGE_GENERATION_MODELS.find((m) => m.id === DEFAULT_MODEL)!
);
}
}
@@ -0,0 +1,115 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import type { IImageModel } from '../../types.js';
export type KlingImageModel = IImageModel & {
/** `generations` takes one reference image; `omni-image` takes several. */
endpoint: 'generations' | 'omni-image';
maxInputImages: number;
/** Whether `aspect_ratio: 'auto'` may be sent with input images. */
autoRatio: boolean;
};
const RATIOS = [
{ w: 16, h: 9 },
{ w: 9, h: 16 },
{ w: 1, h: 1 },
{ w: 4, h: 3 },
{ w: 3, h: 4 },
{ w: 3, h: 2 },
{ w: 2, h: 3 },
{ w: 21, h: 9 },
];
// usd-cents per image from https://kling.ai/document-api/pricing/base/image
// (1 unit = $0.0035). Cost keys are the output resolution tier; Image 2.1
// prices text-to-image and image-to-image apart. Capabilities per
// https://kling.ai/document-api/guides/capability-map/image.
export const KLING_IMAGE_GENERATION_MODELS: KlingImageModel[] = [
{
id: 'kling-v3',
puterId: 'kling:kling/kling-v3',
aliases: ['kling/kling-v3', 'kling-image-3.0'],
name: 'Kling Image 3.0',
version: '3.0',
costs_currency: 'usd-cents',
pricing_unit: 'per-tier',
index_cost_key: '1k',
costs: { '1k': 2.8, '2k': 2.8 },
allowedQualityLevels: ['1k', '2k'],
allowedRatios: RATIOS,
endpoint: 'generations',
maxInputImages: 1,
autoRatio: false,
},
{
id: 'kling-v3-omni',
puterId: 'kling:kling/kling-v3-omni',
aliases: ['kling/kling-v3-omni', 'kling-image-3.0-omni'],
name: 'Kling Image 3.0 Omni',
version: '3.0',
costs_currency: 'usd-cents',
pricing_unit: 'per-tier',
index_cost_key: '1k',
costs: { '1k': 2.8, '2k': 2.8, '4k': 5.6 },
allowedQualityLevels: ['1k', '2k', '4k'],
allowedRatios: RATIOS,
endpoint: 'omni-image',
maxInputImages: 10,
autoRatio: true,
},
{
id: 'kling-image-o1',
puterId: 'kling:kling/kling-image-o1',
aliases: ['kling/kling-image-o1'],
name: 'Kling Image O1',
version: 'o1',
costs_currency: 'usd-cents',
pricing_unit: 'per-tier',
index_cost_key: '1k',
costs: { '1k': 2.8, '2k': 2.8 },
allowedQualityLevels: ['1k', '2k'],
allowedRatios: RATIOS,
endpoint: 'omni-image',
maxInputImages: 10,
autoRatio: true,
},
{
id: 'kling-v2-1',
puterId: 'kling:kling/kling-v2-1',
aliases: ['kling/kling-v2-1', 'kling-image-2.1'],
name: 'Kling Image 2.1',
version: '2.1',
costs_currency: 'usd-cents',
pricing_unit: 'per-tier',
index_cost_key: '1k',
costs: {
'1k': 1.4,
'2k': 1.4,
'1k:image-to-image': 2.8,
'2k:image-to-image': 2.8,
},
allowedQualityLevels: ['1k', '2k'],
allowedRatios: RATIOS,
endpoint: 'generations',
maxInputImages: 1,
autoRatio: false,
},
];
+9 -14
View File
@@ -27,6 +27,10 @@ import { Mistral } from '@mistralai/mistralai';
import { Actor } from '../../core/actor.js';
import { Context } from '../../core/context.js';
import { HttpError } from '../../core/http/HttpError.js';
import {
awsCredentialsFrom,
type AwsCredentials,
} from '../../util/awsCredentials.js';
import { mimeFromName } from '../../util/fileSigning.js';
import {
assertActorHasCredits,
@@ -293,11 +297,7 @@ export class OCRDriver extends PuterDriver {
// Textract state — one client per region.
#textractClients: Record<string, TextractClient> = {};
#awsConfig: {
accessKeyId?: string;
secretAccessKey?: string;
region?: string;
} | null = null;
#awsConfig: { credentials: AwsCredentials; region: string } | null = null;
/** DetectDocumentText rates per region, from AWS's public price list. */
#textractRates = textractRates(OCR_COSTS[TEXTRACT_PAGE_USAGE_TYPE]);
@@ -314,16 +314,14 @@ export class OCRDriver extends PuterDriver {
Record<string, unknown> | undefined;
const textractAws = (textract?.aws ?? textract) as
Record<string, unknown> | undefined;
const textractAccessKey = textractAws?.access_key as string | undefined;
const textractSecretKey = textractAws?.secret_key as string | undefined;
const textractCredentials = awsCredentialsFrom(textractAws);
const textractRegion =
(textractAws?.region as string | undefined) ??
(textract?.region as string | undefined) ??
DEFAULT_TEXTRACT_REGION;
if (textractAccessKey && textractSecretKey) {
if (textractCredentials) {
this.#awsConfig = {
accessKeyId: textractAccessKey,
secretAccessKey: textractSecretKey,
credentials: textractCredentials,
region: textractRegion,
};
// Warm the configured region so the cost report has its rate.
@@ -460,10 +458,7 @@ export class OCRDriver extends PuterDriver {
const cached = this.#textractClients[region];
if (cached) return cached;
const client = new TextractClient({
credentials: {
accessKeyId: this.#awsConfig!.accessKeyId!,
secretAccessKey: this.#awsConfig!.secretAccessKey!,
},
credentials: this.#awsConfig!.credentials,
region,
});
this.#textractClients[region] = client;
@@ -48,7 +48,11 @@ import type { MeteringService } from '../../services/metering/MeteringService.js
import { setupTestServer } from '../../testUtil.js';
import { generateDefaultFsentries } from '../../util/userProvisioning.js';
import type { VoiceChangerDriver } from './VoiceChangerDriver.js';
import { callerError, readStreamResult } from '../integrationTestUtil.js';
import {
callerError,
readStreamResult,
withAiCostFactorHook,
} from '../integrationTestUtil.js';
import { VOICE_CHANGER_COSTS } from './costs.js';
const { webFetchMock } = vi.hoisted(() => ({ webFetchMock: vi.fn() }));
@@ -352,6 +356,35 @@ describe('VoiceChangerDriver.convert success path', () => {
expect(cost).toBe(perSecond * expectedSeconds);
});
it('holds and meters at the AI cost factor', async () => {
const { actor } = await makeUser();
const usageType = 'elevenlabs:eleven_multilingual_sts_v2:second';
const metered = () =>
incrementUsageSpy.mock.calls
.filter(([, type]) => type === usageType)
.map(([, , , cost]) => cost);
const convert = async (charges: number) => {
fetchSpy.mockResolvedValueOnce(
okResponse(new ArrayBuffer(8), 'audio/mpeg'),
);
await readStreamResult(
await withActor(actor, () =>
driver.convert({
audio: dataUrl(Buffer.alloc(32_000), 'audio/mpeg'),
}),
),
);
await vi.waitFor(() => expect(metered()).toHaveLength(charges));
};
await convert(1);
await withAiCostFactorHook(server.clients.event, 2, () => convert(2));
const [base, factored] = metered();
expect(factored).toBe(base! * 2);
const held = hasCreditsSpy.mock.calls.map(([, amount]) => amount);
expect(held).toEqual([base, base! * 2]);
});
it('meters nothing when ElevenLabs breaks off before any audio', async () => {
const { actor } = await makeUser();
fetchSpy.mockResolvedValueOnce(
@@ -49,6 +49,7 @@ import { PuterServer } from '../../server.js';
import type { MeteringService } from '../../services/metering/MeteringService.js';
import { setupTestServer } from '../../testUtil.js';
import { generateDefaultFsentries } from '../../util/userProvisioning.js';
import { withAiCostFactorHook } from '../integrationTestUtil.js';
import type { SpeechToTextDriver } from './SpeechToTextDriver.js';
import { SPEECH_TO_TEXT_COSTS } from './costs.js';
@@ -682,6 +683,34 @@ describe('SpeechToTextDriver metering', () => {
const creditCall = hasCreditsSpy.mock.calls[0]!;
expect(creditCall[1]).toBe(expected);
});
it('holds and meters at the AI cost factor', async () => {
const { actor } = await makeUser();
const usageType = 'openai:gpt-transcribe:second';
const metered = () =>
incrementUsageSpy.mock.calls
.filter(([, type]) => type === usageType)
.map(([, , , cost]) => cost);
const transcribe = async (charges: number) => {
transcriptionsCreateMock.mockResolvedValueOnce({ text: 'x' });
await withActor(actor, () =>
driver.transcribe({
file: dataUrl(Buffer.alloc(32000, 0), 'audio/mp3'),
model: 'gpt-transcribe',
}),
);
await vi.waitFor(() => expect(metered()).toHaveLength(charges));
};
await transcribe(1);
await withAiCostFactorHook(server.clients.event, 2, () =>
transcribe(2),
);
const [base, factored] = metered();
expect(factored).toBe(base! * 2);
const held = hasCreditsSpy.mock.calls.map(([, amount]) => amount);
expect(held).toEqual([base, base! * 2]);
});
});
// ── Error paths ─────────────────────────────────────────────────────
@@ -44,6 +44,10 @@ import { runWithContext } from '../../core/context.js';
import { SYSTEM_ACTOR } from '../../core/actor.js';
import { PuterServer } from '../../server.js';
import { setupTestServer } from '../../testUtil.js';
import {
readStreamResult,
withAiCostFactorHook,
} from '../integrationTestUtil.js';
import type { TTSDriver } from './TTSDriver.js';
import pollyPriceList from './providers/awsPolly/fixtures/us-west-2.json';
@@ -130,6 +134,7 @@ beforeAll(async () => {
// from; TTS reads the same ones.
xai: { secret_key: 'xai-key' },
speechify: { apiKey: 'speechify-key' },
kling: { apiKey: 'kling-key' },
},
} as never);
driver = server.drivers.aiTts as unknown as TTSDriver;
@@ -200,6 +205,7 @@ describe('TTSDriver provider registration', () => {
'aws-polly',
'elevenlabs',
'gemini',
'kling',
'openai',
'speechify',
'xai',
@@ -330,6 +336,42 @@ describe('TTSDriver.synthesize provider routing', () => {
},
);
it.each(['kling', 'kling-tts', 'kling-ai'])(
'resolves the %s alias to the kling provider',
async (provider) => {
fetchSpy
.mockResolvedValueOnce(
Response.json({
code: 0,
data: {
task_status: 'succeed',
task_result: {
audios: [
{ url: 'https://cdn.kling.example/a.mp3' },
],
},
},
}),
)
.mockResolvedValueOnce(new Response('audio'));
await withActor(() => driver.synthesize({ text: 'hi', provider }));
expect(String(fetchSpy.mock.calls[0]![0])).toBe(
'https://api-singapore.klingai.com/v1/audio/tts',
);
expect(openaiSpeechCreateMock).not.toHaveBeenCalled();
},
);
it('routes via legacy driverAlias (kling-tts → kling)', async () => {
const voices = await withDriverName('kling-tts', () =>
driver.list_voices(),
);
expect(voices.length).toBeGreaterThan(0);
expect(voices.every((v) => v.provider === 'kling')).toBe(true);
});
it('resolves provider aliases', async () => {
fetchSpy.mockResolvedValueOnce(
new Response('audio', {
@@ -581,6 +623,7 @@ describe('TTSDriver list_voices / list_engines', () => {
'gemini-3.8-flash-tts', // gemini
'xai-tts',
'simba-3.2', // speechify
'kling-tts',
'standard', // aws-polly
]),
);
@@ -611,6 +654,7 @@ describe('TTSDriver.getReportedCosts', () => {
'driver:aiTts/gemini',
'driver:aiTts/xai',
'driver:aiTts/speechify',
'driver:aiTts/kling',
]),
);
});
@@ -648,3 +692,40 @@ describe('TTSDriver.synthesize error mapping', () => {
expect(incrementUsageSpy).not.toHaveBeenCalled();
});
});
describe('TTSDriver cost factor', () => {
it('holds and meters at the AI cost factor', async () => {
const incrementUsageSpy = vi.spyOn(
server.services.metering,
'incrementUsage',
);
const hasCreditsSpy = vi.spyOn(
server.services.metering,
'hasEnoughCredits',
);
const metered = () =>
incrementUsageSpy.mock.calls.map(([, , , cost]) => cost!);
const synthesize = async (charges: number) => {
openaiSpeechCreateMock.mockResolvedValueOnce(openaiAudioResponse());
await readStreamResult(
await withActor(() =>
driver.synthesize({
text: 'hello there',
provider: 'openai',
}),
),
);
await vi.waitFor(() => expect(metered()).toHaveLength(charges));
};
await synthesize(1);
await withAiCostFactorHook(server.clients.event, 2, () =>
synthesize(2),
);
const [base, factored] = metered();
expect(base).toBeGreaterThan(0);
expect(factored).toBe(Math.round(base! * 2));
const held = hasCreditsSpy.mock.calls.map(([, amount]) => amount);
expect(held).toEqual([base, Math.round(base! * 2)]);
});
});
+17 -5
View File
@@ -19,6 +19,7 @@
import { Context } from '../../core/context.js';
import { HttpError } from '../../core/http/HttpError.js';
import { awsCredentialsFrom } from '../../util/awsCredentials.js';
import type { DriverStreamResult } from '../meta.js';
import { PuterDriver } from '../types.js';
import {
@@ -32,6 +33,7 @@ import { TTS_CATALOG, TTS_DRIVER_ALIASES } from './providerAliases.js';
import { AWSPollyTTSProvider } from './providers/awsPolly/AWSPollyTTSProvider.js';
import { ElevenLabsTTSProvider } from './providers/elevenlabs/ElevenLabsTTSProvider.js';
import { GeminiTTSProvider } from './providers/gemini/GeminiTTSProvider.js';
import { KlingTTSProvider } from './providers/kling/KlingTTSProvider.js';
import { OpenAITTSProvider } from './providers/openai/OpenAITTSProvider.js';
import { SpeechifyTTSProvider } from './providers/speechify/SpeechifyTTSProvider.js';
import { XAITTSProvider } from './providers/xai/XAITTSProvider.js';
@@ -240,15 +242,13 @@ export class TTSDriver extends PuterDriver {
const polly = providers['aws-polly'];
const pollyAws = (polly?.aws ?? polly) as
Record<string, unknown> | undefined;
const pollyAccessKey = pollyAws?.access_key as string | undefined;
const pollySecretKey = pollyAws?.secret_key as string | undefined;
if (pollyAccessKey && pollySecretKey) {
const pollyCredentials = awsCredentialsFrom(pollyAws);
if (pollyCredentials) {
register(
'aws-polly',
() =>
new AWSPollyTTSProvider(m, {
access_key: pollyAccessKey,
secret_key: pollySecretKey,
credentials: pollyCredentials,
region: (pollyAws?.region ?? polly?.region) as
string | undefined,
}),
@@ -279,5 +279,17 @@ export class TTSDriver extends PuterDriver {
providers['speechify-tts'],
),
);
// One `kling` key covers video, image and TTS.
register('kling', () =>
keyed(
(apiKey) =>
new KlingTTSProvider(m, {
apiKey,
apiBaseUrl: providers['kling']?.apiBaseUrl as
string | undefined,
}),
providers['kling'],
),
);
}
}
@@ -32,6 +32,7 @@ export const TTS_PROVIDERS = [
'gemini',
'xai',
'speechify',
'kling',
] as const;
export type TTSProviderName = (typeof TTS_PROVIDERS)[number];
@@ -50,6 +51,7 @@ export const TTS_DRIVER_ALIASES = [
'gemini-tts',
'xai-tts',
'speechify-tts',
'kling-tts',
] as const;
const PROVIDER_BY_ALIAS: Record<string, TTSProviderName> = {
@@ -76,6 +78,9 @@ const PROVIDER_BY_ALIAS: Record<string, TTSProviderName> = {
simba: 'speechify',
speechify: 'speechify',
'speechify-tts': 'speechify',
kling: 'kling',
'kling-ai': 'kling',
'kling-tts': 'kling',
};
export const TTS_CATALOG: ProviderCatalog = {
@@ -101,8 +101,7 @@ const aiMetering = () =>
const makeProvider = (region?: string) =>
new AWSPollyTTSProvider(aiMetering(), {
access_key: 'AKIA-TEST',
secret_key: 'secret',
credentials: { accessKeyId: 'AKIA-TEST', secretAccessKey: 'secret' },
region,
});
@@ -27,6 +27,7 @@ import {
} from '@aws-sdk/client-polly';
import { HttpError } from '../../../../core/http/HttpError.js';
import { Context } from '../../../../core/context.js';
import type { AwsCredentials } from '../../../../util/awsCredentials.js';
import type { AiMeteringService } from '../../../util/aiCostFactor.js';
import type { DriverStreamResult } from '../../../meta.js';
import { SAMPLE_AUDIO_URL } from '../../../util/testMode.js';
@@ -83,8 +84,7 @@ export class AWSPollyTTSProvider implements ITTSProvider {
constructor(
private readonly meteringService: AiMeteringService,
private readonly config: {
access_key: string;
secret_key: string;
credentials: AwsCredentials;
region?: string;
},
) {
@@ -115,10 +115,7 @@ export class AWSPollyTTSProvider implements ITTSProvider {
}
this.clients[resolvedRegion] = new PollyClient({
credentials: {
accessKeyId: cfg.access_key,
secretAccessKey: cfg.secret_key,
},
credentials: cfg.credentials,
region: resolvedRegion,
});
@@ -0,0 +1,59 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/**
* Integration test for the Kling TTS provider ($0.007/request). Skipped when
* `PUTER_TEST_AI_KLING_API_KEY` is unset.
*/
import { describe, expect, it } from 'vitest';
import {
INTEGRATION_TEST_TIMEOUT_MS,
makeMeteringStub,
optionalEnv,
readStreamResult,
skipUnlessEnv,
withTestActor,
} from '../../../integrationTestUtil.js';
import { KlingTTSProvider } from './KlingTTSProvider.js';
const ENV_VAR = 'PUTER_TEST_AI_KLING_API_KEY';
describe.skipIf(skipUnlessEnv(ENV_VAR))(
'KlingTTSProvider (integration)',
() => {
it(
'returns MP3 audio from kling-tts',
{ timeout: INTEGRATION_TEST_TIMEOUT_MS },
async () => {
const provider = new KlingTTSProvider(makeMeteringStub(), {
apiKey: optionalEnv(ENV_VAR)!,
});
const result = await withTestActor(() =>
provider.synthesize({ text: 'hi', model: 'kling-tts' }),
);
const audio = await readStreamResult(result);
expect(audio.length).toBeGreaterThan(0);
expect(audio.subarray(0, 3).toString()).toBe('ID3');
},
);
},
);
@@ -0,0 +1,359 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/**
* Offline unit tests for KlingTTSProvider.
*
* Boots a real PuterServer and constructs the provider against the live wired
* `MeteringService`. Kling has no SDK, so global `fetch` is the network
* boundary: the first call creates the TTS task, the second downloads the MP3
* it points at.
*/
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
type MockInstance,
} from 'vitest';
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
import { PuterServer } from '../../../../server.js';
import { setupTestServer } from '../../../../testUtil.js';
import { withAiCostFactor } from '../../../util/aiCostFactor.js';
import {
readStreamResult,
withTestActor,
} from '../../../integrationTestUtil.js';
import { SAMPLE_AUDIO_URL } from '../../../util/testMode.js';
import {
KLING_TTS_UCENTS_PER_REQUEST,
KlingTTSProvider,
} from './KlingTTSProvider.js';
// -- Test harness --
const AUDIO_URL = 'https://cdn.kling.example/audio.mp3';
let server: PuterServer;
let fetchSpy: MockInstance<typeof fetch>;
let incrementUsageSpy: MockInstance<MeteringService['incrementUsage']>;
beforeAll(async () => {
server = await setupTestServer();
});
afterAll(async () => {
await server?.shutdown();
});
const aiMetering = () =>
withAiCostFactor(server.services.metering, server.clients.event, 'ai-tts');
const makeProvider = () =>
new KlingTTSProvider(aiMetering(), { apiKey: 'test-key' });
const klingTask = (overrides: Record<string, unknown> = {}) =>
Response.json({
code: 0,
message: 'SUCCEED',
data: {
task_id: 't1',
task_status: 'succeed',
task_result: { audios: [{ id: 'a1', url: AUDIO_URL }] },
...overrides,
},
});
const mp3 = (body = 'ID3-mp3-bytes') =>
new Response(body, {
status: 200,
headers: { 'content-type': 'audio/mpeg' },
});
const sentBody = () =>
JSON.parse((fetchSpy.mock.calls[0]![1] as RequestInit).body as string);
beforeEach(() => {
fetchSpy = vi.spyOn(globalThis, 'fetch') as MockInstance<typeof fetch>;
incrementUsageSpy = vi.spyOn(server.services.metering, 'incrementUsage');
});
afterEach(() => {
vi.restoreAllMocks();
});
// -- Construction and catalog --
describe('KlingTTSProvider construction and catalog', () => {
it('throws when no apiKey is supplied', () => {
expect(
() => new KlingTTSProvider(aiMetering(), { apiKey: '' }),
).toThrow(/API key/i);
});
it('lists the 44 voices Kling accepts, labelled with their languages', async () => {
const voices = await makeProvider().listVoices();
expect(voices).toHaveLength(44);
for (const voice of voices) {
expect(voice.provider).toBe('kling');
expect(voice.supported_models).toEqual(['kling-tts']);
expect(voice.labels!.languages.split(',')[0]).toBe(
voice.language!.code,
);
}
expect(voices.find((v) => v.id === 'oversea_male1')).toMatchObject({
language: { code: 'en', name: 'English' },
labels: { languages: 'en' },
});
expect(voices.find((v) => v.id === 'genshin_vindi2')).toMatchObject({
labels: { languages: 'en,zh' },
});
expect(voices.find((v) => v.id === 'mengwa-v1')).toMatchObject({
language: { code: 'zh', name: 'Chinese' },
});
});
it('lists one kling-tts engine', async () => {
expect(await makeProvider().listEngines()).toEqual([
{ id: 'kling-tts', name: 'Kling TTS', provider: 'kling' },
]);
});
it('reports a per-request cost', () => {
expect(makeProvider().getReportedCosts()).toEqual([
{
usageType: 'kling:kling-tts:request',
ucentsPerUnit: 700_000,
unit: 'request',
source: 'driver:aiTts/kling',
},
]);
});
});
// -- test_mode --
describe('KlingTTSProvider.synthesize test_mode', () => {
it('returns the sample audio without calling Kling', async () => {
const result = await withTestActor(() =>
makeProvider().synthesize({ text: 'hi', test_mode: true }),
);
expect(result).toEqual({
url: SAMPLE_AUDIO_URL,
content_type: 'audio',
});
expect(fetchSpy).not.toHaveBeenCalled();
});
});
// -- Request shape --
describe('KlingTTSProvider.synthesize request shape', () => {
it('POSTs /v1/audio/tts with Bearer auth and the default voice', async () => {
fetchSpy
.mockResolvedValueOnce(klingTask())
.mockResolvedValueOnce(mp3());
await readStreamResult(
await withTestActor(() =>
makeProvider().synthesize({ text: 'hello' }),
),
);
const [url, init] = fetchSpy.mock.calls[0]!;
expect(String(url)).toBe(
'https://api-singapore.klingai.com/v1/audio/tts',
);
expect((init as RequestInit).method).toBe('POST');
expect(
((init as RequestInit).headers as Record<string, string>)
.Authorization,
).toBe('Bearer test-key');
expect(sentBody()).toEqual({
text: 'hello',
voice_id: 'oversea_male1',
voice_language: 'en',
});
expect(String(fetchSpy.mock.calls[1]![0])).toBe(AUDIO_URL);
});
it('reads a regional language code as its base language', async () => {
fetchSpy
.mockResolvedValueOnce(klingTask())
.mockResolvedValueOnce(mp3());
await withTestActor(() =>
makeProvider().synthesize({
text: 'hi',
voice: 'genshin_vindi2',
language: 'en-US',
}),
);
expect(sentBody()).toMatchObject({
voice_id: 'genshin_vindi2',
voice_language: 'en',
});
});
it("defaults a Chinese-only voice to 'zh'", async () => {
fetchSpy
.mockResolvedValueOnce(klingTask())
.mockResolvedValueOnce(mp3());
await withTestActor(() =>
makeProvider().synthesize({ text: 'hi', voice: 'mengwa-v1' }),
);
expect(sentBody().voice_language).toBe('zh');
});
it('sends voice_settings.speed as voice_speed', async () => {
fetchSpy
.mockResolvedValueOnce(klingTask())
.mockResolvedValueOnce(mp3());
await withTestActor(() =>
makeProvider().synthesize({
text: 'hi',
voice_settings: { speed: 1.5 },
}),
);
expect(sentBody().voice_speed).toBe(1.5);
});
it('streams the MP3 back as audio/mpeg', async () => {
fetchSpy
.mockResolvedValueOnce(klingTask())
.mockResolvedValueOnce(mp3('AAA-BBB'));
const result = await withTestActor(() =>
makeProvider().synthesize({ text: 'hi' }),
);
expect(result).toMatchObject({
dataType: 'stream',
content_type: 'audio/mpeg',
chunked: true,
});
expect((await readStreamResult(result)).toString()).toBe('AAA-BBB');
});
});
// -- Metering --
describe('KlingTTSProvider.synthesize metering', () => {
it('meters one request once the audio arrives', async () => {
fetchSpy
.mockResolvedValueOnce(klingTask())
.mockResolvedValueOnce(mp3());
await readStreamResult(
await withTestActor(() =>
makeProvider().synthesize({ text: 'hi' }),
),
);
await vi.waitFor(() =>
expect(incrementUsageSpy).toHaveBeenCalledTimes(1),
);
const [, usageType, count, cost] = incrementUsageSpy.mock.calls[0]!;
expect(usageType).toBe('kling:kling-tts:request');
expect(count).toBe(1);
expect(cost).toBe(KLING_TTS_UCENTS_PER_REQUEST);
});
it.each([
[
'a Kling error code',
() =>
Response.json(
{ code: 1201, message: 'Voice id not found' },
{ status: 400 },
),
],
[
'a failed task',
() =>
klingTask({
task_status: 'failed',
task_status_msg: 'failed',
task_result: {},
}),
],
['a task with no audio URL', () => klingTask({ task_result: {} })],
])('meters nothing for %s', async (_label, reply) => {
fetchSpy.mockResolvedValueOnce(reply());
await expect(
withTestActor(() => makeProvider().synthesize({ text: 'hi' })),
).rejects.toBeTruthy();
expect(fetchSpy).toHaveBeenCalledTimes(1);
expect(incrementUsageSpy).not.toHaveBeenCalled();
});
});
// -- Validation --
describe('KlingTTSProvider.synthesize validation', () => {
it.each([
['empty text', { text: '' }, undefined],
[
'text over 1000 characters',
{ text: 'x'.repeat(1001) },
'input_too_large',
],
['an unknown voice', { text: 'hi', voice: 'nope' }, 'field_invalid'],
['an unknown model', { text: 'hi', model: 'tts-1' }, 'field_invalid'],
[
'a language the voice lacks',
{ text: 'hi', voice: 'oversea_male1', language: 'zh' },
'field_invalid',
],
[
'a speed below 0.8',
{ text: 'hi', voice_settings: { speed: 0.5 } },
'field_invalid',
],
[
'a speed above 2',
{ text: 'hi', voice_settings: { speed: 2.5 } },
'field_invalid',
],
])(
'rejects %s with 400 before calling Kling',
async (_label, args, legacyCode) => {
await expect(
withTestActor(() => makeProvider().synthesize(args)),
).rejects.toMatchObject({
statusCode: 400,
...(legacyCode ? { legacyCode } : {}),
});
expect(fetchSpy).not.toHaveBeenCalled();
},
);
});
@@ -0,0 +1,277 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { Context } from '../../../../core/context.js';
import { HttpError } from '../../../../core/http/HttpError.js';
import type { DriverStreamResult } from '../../../meta.js';
import {
type AiMeteringService,
withStreamedAiCharge,
} from '../../../util/aiCostFactor.js';
import { klingRequest } from '../../../util/kling.js';
import { SAMPLE_AUDIO_URL } from '../../../util/testMode.js';
import {
upstreamBodyStream,
upstreamFetch,
} from '../../../util/upstreamErrors.js';
import type {
ISynthesizeArgs,
ITTSEngine,
ITTSProvider,
ITTSVoice,
} from '../../types.js';
import { assertMaxChars, TTS_UPSTREAM_TIMEOUT_MS } from '../common.js';
const MODEL = 'kling-tts';
const DEFAULT_VOICE = 'oversea_male1';
const MAX_TEXT_CHARS = 1000;
/** $0.007 per request, https://kling.ai/document-api/pricing/base/video. */
export const KLING_TTS_UCENTS_PER_REQUEST = 700_000;
const LANGUAGE_NAMES: Record<string, string> = {
en: 'English',
zh: 'Chinese',
};
// Voice ids Kling accepted on 2026-10-20, with the languages each answered
// to; Kling's voice guide is not published as data. Requesting a voice in a
// language it lacks fails upstream with "Voice id not found".
const VOICES: Record<string, readonly string[]> = {
genshin_vindi2: ['en', 'zh'],
zhinen_xuesheng: ['en', 'zh'],
AOT: ['en'],
ai_shatang: ['en', 'zh'],
genshin_klee2: ['en', 'zh'],
genshin_kirara: ['en', 'zh'],
ai_kaiya: ['en', 'zh'],
oversea_male1: ['en'],
ai_chenjiahao_712: ['en', 'zh'],
girlfriend_4_speech02: ['en'],
'chat1_female_new-3': ['en', 'zh'],
'chat_0407_5-1': ['en'],
'cartoon-boy-07': ['en', 'zh'],
uk_boy1: ['en'],
'cartoon-girl-01': ['en', 'zh'],
PeppaPig_platform: ['en'],
ai_huangzhong_712: ['en'],
ai_huangyaoshi_712: ['en', 'zh'],
ai_laoguowang_712: ['en', 'zh'],
chengshu_jiejie: ['en', 'zh'],
you_pingjing: ['en', 'zh'],
calm_story1: ['en'],
uk_man2: ['en'],
laopopo_speech02: ['en', 'zh'],
heainainai_speech02: ['en', 'zh'],
tiyuxi_xuedi: ['zh'],
tiexin_nanyou: ['zh'],
girlfriend_1_speech02: ['zh'],
girlfriend_2_speech02: ['zh'],
zhuxi_speech02: ['zh'],
uk_oldman3: ['zh'],
dongbeilaotie_speech02: ['zh'],
chongqingxiaohuo_speech02: ['zh'],
chuanmeizi_speech02: ['zh'],
chaoshandashu_speech02: ['zh'],
ai_taiwan_man2_speech02: ['zh'],
xianzhanggui_speech02: ['zh'],
tianjinjiejie_speech02: ['zh'],
'diyinnansang_DB_CN_M_04-v2': ['zh'],
'yizhipiannan-v1': ['zh'],
'guanxiaofang-v2': ['zh'],
'tianmeixuemei-v1': ['zh'],
'daopianyansang-v1': ['zh'],
'mengwa-v1': ['zh'],
};
interface KlingTTSTask {
task_status?: string;
task_status_msg?: string;
task_result?: { audios?: Array<{ url?: string }> };
}
const fieldInvalid = (key: string, message: string) =>
new HttpError(400, message, {
legacyCode: 'field_invalid',
fields: { key },
});
/**
* Kling AI text-to-speech. `/v1/audio/tts` answers synchronously with a hosted
* MP3, which is streamed back to the caller. Billed per request.
* https://kling.ai/document-api/api/video/avatar/text-to-speech
*/
export class KlingTTSProvider implements ITTSProvider {
readonly providerName = 'kling';
#apiKey: string;
#apiBaseUrl: string | undefined;
constructor(
private readonly meteringService: AiMeteringService,
config: { apiKey: string; apiBaseUrl?: string },
) {
if (!config.apiKey) {
throw new Error('Kling TTS requires an API key');
}
this.#apiKey = config.apiKey;
this.#apiBaseUrl = config.apiBaseUrl;
}
async listVoices(): Promise<ITTSVoice[]> {
return Object.entries(VOICES).map(([id, languages]) => ({
id,
name: id,
language: {
name: LANGUAGE_NAMES[languages[0]],
code: languages[0],
},
labels: { languages: languages.join(',') },
provider: 'kling',
supported_models: [MODEL],
}));
}
async listEngines(): Promise<ITTSEngine[]> {
return [{ id: MODEL, name: 'Kling TTS', provider: 'kling' }];
}
getReportedCosts(): Record<string, unknown>[] {
return [
{
usageType: `kling:${MODEL}:request`,
ucentsPerUnit: KLING_TTS_UCENTS_PER_REQUEST,
unit: 'request',
source: 'driver:aiTts/kling',
},
];
}
async synthesize(
args: ISynthesizeArgs,
): Promise<DriverStreamResult | { url: string; content_type: string }> {
const { text, voice, model, language, test_mode } = args;
const speed = (args.voice_settings ?? args.voiceSettings)?.speed;
if (test_mode) {
return { url: SAMPLE_AUDIO_URL, content_type: 'audio' };
}
if (typeof text !== 'string' || !text.trim()) {
throw new HttpError(400, 'Missing required field: text', {
legacyCode: 'field_required',
fields: { key: 'text' },
});
}
assertMaxChars('text', text, MAX_TEXT_CHARS);
if (model !== undefined && model !== MODEL) {
throw fieldInvalid(
'model',
`Invalid model: ${model}. Expected: ${MODEL}`,
);
}
const voiceId = voice || DEFAULT_VOICE;
const languages = VOICES[voiceId];
if (!languages) {
throw fieldInvalid('voice', `Invalid Kling voice: ${voiceId}`);
}
// `en-US` and `zh-CN` name the same languages as `en` and `zh`.
const wanted =
typeof language === 'string'
? language.trim().slice(0, 2).toLowerCase()
: undefined;
if (wanted && !languages.includes(wanted)) {
throw fieldInvalid(
'language',
`Kling voice ${voiceId} speaks ${languages.join(' or ')}, not ${language}`,
);
}
if (
speed !== undefined &&
(typeof speed !== 'number' || speed < 0.8 || speed > 2)
) {
throw fieldInvalid(
'voice_settings.speed',
'voice_settings.speed must be a number from 0.8 to 2.0',
);
}
const actor = Context.get('actor')!;
const usageType = `kling:${MODEL}:request`;
return withStreamedAiCharge(
this.meteringService,
actor,
usageType,
KLING_TTS_UCENTS_PER_REQUEST,
async () => {
const task = await klingRequest<KlingTTSTask>(
this.#apiKey,
'POST',
'/v1/audio/tts',
{
baseUrl: this.#apiBaseUrl,
timeoutMs: TTS_UPSTREAM_TIMEOUT_MS,
body: {
text,
voice_id: voiceId,
voice_language: wanted ?? languages[0],
...(speed !== undefined
? { voice_speed: speed }
: {}),
},
},
);
const url = task.task_result?.audios?.[0]?.url;
if (task.task_status !== 'succeed' || !url) {
throw new HttpError(
502,
task.task_status_msg || 'Kling TTS returned no audio',
{
legacyCode: 'upstream_failed',
fields: { provider: 'kling' },
},
);
}
const response = await upstreamFetch(
'kling',
url,
{ method: 'GET' },
{
providerLabel: 'Kling TTS',
timeoutMs: TTS_UPSTREAM_TIMEOUT_MS,
stream: true,
},
);
return {
dataType: 'stream',
content_type: 'audio/mpeg',
chunked: true,
stream: upstreamBodyStream(response),
};
},
() =>
this.meteringService.incrementUsage(
actor,
usageType,
1,
KLING_TTS_UCENTS_PER_REQUEST,
),
);
}
}
@@ -40,11 +40,14 @@ import {
type MockInstance,
} from 'vitest';
import { v4 as uuidv4 } from 'uuid';
import { runWithContext } from '../../core/context.js';
import { SYSTEM_ACTOR } from '../../core/actor.js';
import { type Actor, SYSTEM_ACTOR } from '../../core/actor.js';
import { PuterServer } from '../../server.js';
import type { MeteringService } from '../../services/metering/MeteringService.js';
import { setupTestServer } from '../../testUtil.js';
import { withAiCostFactorHook } from '../integrationTestUtil.js';
import { generateDefaultFsentries } from '../../util/userProvisioning.js';
import { TOGETHER_VIDEO_GENERATION_MODELS } from './providers/together/models.js';
import type { VideoGenerationDriver } from './VideoGenerationDriver.js';
@@ -99,6 +102,12 @@ beforeAll(async () => {
// Configured on purpose: Gemini video reads the shared `gemini`
// block, so this one alone must not register a provider.
'gemini-video-generation': { apiKey: 'gem-key' },
kling: { apiKey: 'kling-key' },
// The chat block's base URL ends in `/v1`; video must drop it.
minimax: {
apiKey: 'mm-key',
apiBaseUrl: 'https://api.minimax.io/v1',
},
},
} as never);
driver = server.drivers.aiVideo as unknown as VideoGenerationDriver;
@@ -165,6 +174,161 @@ describe('VideoGenerationDriver.generate argument validation', () => {
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('refuses a source video for a model without video input before calling it', async () => {
await expect(
withActor(() =>
driver.generate({
prompt: 'Make this dog a dinosaur.',
model: DEFAULT_MODEL,
input_video: 'https://example.com/dog.mp4',
}),
),
).rejects.toMatchObject({
statusCode: 400,
message: `${DEFAULT_MODEL} does not accept a source video`,
});
expect(togetherVideosCreateMock).not.toHaveBeenCalled();
expect(secureFetchMock).not.toHaveBeenCalled();
});
});
describe('VideoGenerationDriver.generate media inputs', () => {
const PNG = Buffer.from(
'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==',
'base64',
);
const pngDataUri = `data:image/png;base64,${PNG.toString('base64')}`;
const makeUser = async (): Promise<{ actor: Actor; userId: number }> => {
const username = `video-media-${Math.random().toString(36).slice(2, 10)}`;
const created = await server.stores.user.create({
username,
uuid: uuidv4(),
password: null,
email: `${username}@test.local`,
free_storage: 100 * 1024 * 1024,
requires_email_confirmation: false,
});
await generateDefaultFsentries(
server.clients.db,
server.stores.user,
created,
);
const user = (await server.stores.user.getById(created.id))!;
return {
userId: user.id,
actor: {
user: {
id: user.id,
uuid: user.uuid,
username: user.username,
email: user.email ?? null,
email_confirmed: true,
} as Actor['user'],
},
};
};
const writeImage = async (
{ actor, userId }: { actor: Actor; userId: number },
name: string,
) => {
const path = `/${actor.user.username}/${name}`;
await server.services.fs.write(userId, {
fileMetadata: {
path,
size: PNG.byteLength,
contentType: 'application/octet-stream',
},
fileContent: PNG,
});
return path;
};
const generateAs = (actor: Actor, args: Record<string, unknown>) =>
Promise.resolve(
runWithContext({ actor }, () =>
driver.generate({
prompt: 'A dinosaur.',
model: DEFAULT_MODEL,
...args,
} as never),
),
);
it("sends Puter file image inputs as data URIs without changing the caller's arrays", async () => {
const user = await makeUser();
const first = await writeImage(user, 'first.tmp');
const reference = await writeImage(user, 'reference.tmp');
const referenceImages = [reference, 'https://example.com/b.png'];
const frameImages = [{ input_image: first, frame: 'first' }];
completeTogetherJob();
await generateAs(user.actor, {
reference_images: referenceImages,
frame_images: frameImages,
});
expect(togetherSent().reference_images).toEqual([
pngDataUri,
'https://example.com/b.png',
]);
expect(togetherSent().frame_images).toEqual([
{ input_image: pngDataUri, frame: 'first' },
]);
expect(referenceImages[0]).toBe(reference);
expect(frameImages[0]!.input_image).toBe(first);
completeTogetherJob();
await generateAs(user.actor, {
input_reference: first,
last_frame: 'https://example.com/last.png',
});
expect(togetherSent(1).frame_images).toEqual([
{ input_image: pngDataUri, frame: 'first' },
{ input_image: 'https://example.com/last.png', frame: 'last' },
]);
expect(secureFetchMock).not.toHaveBeenCalled();
});
it("refuses another user's image before calling the provider", async () => {
const owner = await makeUser();
const intruder = await makeUser();
const path = await writeImage(owner, 'private.png');
const status = await generateAs(intruder.actor, {
input_reference: path,
}).then(
() => null,
(err: { statusCode?: number }) => err.statusCode,
);
expect([403, 404]).toContain(status);
expect(togetherVideosCreateMock).not.toHaveBeenCalled();
});
it('reads no file in test mode', async () => {
const { actor } = await makeUser();
await expect(
generateAs(actor, {
input_reference: '/nobody/missing.png',
test_mode: true,
}),
).resolves.toBeTruthy();
});
it('refuses a data URI source video over 1 MiB with 413', async () => {
const { actor } = await makeUser();
const video = Buffer.alloc(1024 * 1024 + 1).toString('base64');
await expect(
generateAs(actor, {
model: 'togetherai:black-forest-labs/flux-3',
input_video: `data:video/mp4;base64,${video}`,
}),
).rejects.toMatchObject({
statusCode: 413,
legacyCode: 'input_too_large',
});
expect(togetherVideosCreateMock).not.toHaveBeenCalled();
});
});
// ── Catalog & list ──────────────────────────────────────────────────
@@ -289,6 +453,113 @@ describe('VideoGenerationDriver.generate provider routing', () => {
expect(togetherVideosCreateMock).not.toHaveBeenCalled();
});
it.each(['kling-3.0', 'kling-v3', 'kling:kling/kling-3.0'])(
'routes %s to the Kling provider from the shared kling key',
async (model) => {
const fetchSpy = vi.spyOn(globalThis, 'fetch');
fetchSpy.mockResolvedValueOnce(
new Response(
JSON.stringify({
code: 0,
data: { id: 'kl-task', status: 'submitted' },
}),
),
);
fetchSpy.mockResolvedValueOnce(
new Response(
JSON.stringify({
code: 0,
data: [
{
id: 'kl-task',
status: 'succeeded',
outputs: [
{
type: 'video',
url: 'https://kling/out.mp4',
duration: '5',
},
],
},
],
}),
),
);
const result = await withActor(() =>
driver.generate({ prompt: 'hi', model } as never),
);
expect(result).toBe('https://kling/out.mp4');
expect(String(fetchSpy.mock.calls[0]![0])).toBe(
'https://api-singapore.klingai.com/text-to-video/kling-3.0',
);
expect(togetherVideosCreateMock).not.toHaveBeenCalled();
},
);
it('lists the Kling models under the kling-video-generation provider', async () => {
const kling = (await driver.models()).filter(
(m) => m.provider === 'kling-video-generation',
);
expect(kling.map((m) => m.id).sort()).toEqual([
'kling-2.5-turbo',
'kling-2.6',
'kling-3.0',
'kling-3.0-omni',
'kling-3.0-turbo',
'kling-o1',
]);
});
it.each([
'minimax-h3',
'minimax/minimax-h3',
'minimax:minimax/minimax-h3',
])('routes %s to the MiniMax provider from the shared minimax key', async (model) => {
const fetchSpy = vi.spyOn(globalThis, 'fetch');
fetchSpy.mockResolvedValueOnce(
new Response(JSON.stringify({ task_id: 'mm-task' })),
);
fetchSpy.mockResolvedValueOnce(
new Response(
JSON.stringify({
task: {
id: 'mm-task',
status: 'succeeded',
content: { url: 'https://minimax/out.mp4' },
usage: { output_seconds: 5 },
},
}),
),
);
const result = await withActor(() =>
driver.generate({ prompt: 'hi', model } as never),
);
expect(result).toBe('https://minimax/out.mp4');
expect(String(fetchSpy.mock.calls[0]![0])).toBe(
'https://api.minimax.io/v2/video_generation',
);
expect(togetherVideosCreateMock).not.toHaveBeenCalled();
});
it('lists the MiniMax models under the minimax-video-generation provider', async () => {
const minimax = (await driver.models()).filter(
(m) => m.provider === 'minimax-video-generation',
);
expect(minimax.map((m) => m.id).sort()).toEqual([
'minimax-h3',
'minimax-h3-highspeed',
'minimax-h3-lite',
'minimax-h3-max',
'minimax-hailuo-02',
'minimax-hailuo-2.3',
'minimax-hailuo-2.3-fast',
]);
});
it('routes a known togetherai:minimax/video-01-director id to the Together provider', async () => {
togetherVideosCreateMock.mockResolvedValueOnce({ id: 'tg-job' });
togetherVideosRetrieveMock.mockResolvedValueOnce({
@@ -730,6 +1001,49 @@ describe('VideoGenerationDriver.generate puter_output_path', () => {
// ── Size unification ───────────────────────────────────────────────
describe('VideoGenerationDriver.generate size unification', () => {
it.each(['912x512', '512x912'])(
'selects and bills Hailuo 02 at 512p for %s',
async (size) => {
const fetchSpy = vi.spyOn(globalThis, 'fetch');
const incrementUsageSpy = vi.spyOn(
server.services.metering,
'incrementUsage',
);
fetchSpy.mockResolvedValueOnce(
new Response(JSON.stringify({ task_id: 'mm-task' })),
);
fetchSpy.mockResolvedValueOnce(
new Response(JSON.stringify({
status: 'Success',
file_id: 'file-1',
})),
);
fetchSpy.mockResolvedValueOnce(
new Response(JSON.stringify({
file: { download_url: 'https://minimax/out.mp4' },
})),
);
await withActor(() => driver.generate({
prompt: 'a ball rolls',
model: 'minimax-hailuo-02',
size,
input_reference: 'https://example.com/first.png',
} as never));
const body = JSON.parse(
fetchSpy.mock.calls[0]![1]!.body as string,
);
expect(body.resolution).toBe('512P');
expect(incrementUsageSpy).toHaveBeenCalledWith(
SYSTEM_ACTOR,
'minimax-video-generation:minimax-hailuo-02:per-video:512p:6',
1,
10 * 1_000_000,
);
},
);
it('maps a WIDTHxHEIGHT size onto a tier plus aspect ratio for tier-based models', async () => {
completeTogetherJob();
@@ -763,6 +1077,77 @@ describe('VideoGenerationDriver.generate size unification', () => {
expect('ratio' in sent).toBe(false);
});
it('treats a catalog with a 2k or 4k tier as tier-based', async () => {
const fetchSpy = vi.spyOn(globalThis, 'fetch');
fetchSpy.mockResolvedValueOnce(
new Response(
JSON.stringify({
code: 0,
data: { id: 'kl-task', status: 'submitted' },
}),
),
);
fetchSpy.mockResolvedValueOnce(
new Response(
JSON.stringify({
code: 0,
data: [
{
id: 'kl-task',
status: 'succeeded',
outputs: [
{
type: 'video',
url: 'https://kling/out.mp4',
duration: '5',
},
],
},
],
}),
),
);
await withActor(() =>
driver.generate({
prompt: 'hi',
model: 'kling-3.0',
size: '1080x1920',
} as never),
);
const sent = JSON.parse(
(fetchSpy.mock.calls[0]![1] as RequestInit).body as string,
);
expect(sent.settings.resolution).toBe('1080p');
expect(sent.settings.aspect_ratio).toBe('9:16');
fetchSpy.mockResolvedValueOnce(
new Response(JSON.stringify({ task_id: 'mm-task' })),
);
fetchSpy.mockResolvedValueOnce(
new Response(
JSON.stringify({
task: {
id: 'mm-task',
status: 'succeeded',
content: { url: 'https://minimax/out.mp4' },
},
}),
),
);
await withActor(() =>
driver.generate({
prompt: 'hi',
model: 'minimax-h3-lite',
size: '854x480',
} as never),
);
const sentToMiniMax = JSON.parse(
(fetchSpy.mock.calls[2]![1] as RequestInit).body as string,
);
expect(sentToMiniMax.resolution).toBe('480P');
expect(sentToMiniMax.ratio).toBe('16:9');
});
it('fills width/height from a WIDTHxHEIGHT size for pixel-sized models', async () => {
completeTogetherJob();
@@ -808,3 +1193,31 @@ describe('VideoGenerationDriver.generate size unification', () => {
expect('height' in second).toBe(false);
});
});
describe('VideoGenerationDriver cost factor', () => {
it('holds and meters at the AI cost factor', async () => {
const incrementUsageSpy = vi.spyOn(
server.services.metering,
'incrementUsage',
);
const reserveSpy = vi.spyOn(server.services.metering, 'reserveCredits');
const metered = () =>
incrementUsageSpy.mock.calls.map(([, , , cost]) => cost!);
const generate = async (charges: number) => {
completeTogetherJob();
await withActor(() =>
driver.generate({ prompt: 'hi', model: DEFAULT_MODEL } as never),
);
await vi.waitFor(() => expect(metered()).toHaveLength(charges));
};
await generate(1);
await withAiCostFactorHook(server.clients.event, 2, () => generate(2));
const [base, factored] = metered();
expect(base).toBeGreaterThan(0);
expect(factored).toBe(Math.round(base! * 2));
const held = reserveSpy.mock.calls.map(([, amount]) => amount);
expect(held[0]).toBeGreaterThan(0);
expect(held).toEqual([held[0], Math.round(held[0]! * 2)]);
});
});
@@ -31,10 +31,17 @@ import {
saveGeneratedMediaToFS,
} from '../util/generatedMedia.js';
import { usesGoogleGeminiEndpoint } from '../util/geminiRest.js';
import {
dataUriByteLength,
MAX_INLINE_VIDEO_BYTES,
readPuterImageInputs,
} from '../util/mediaInput.js';
import { ModelCatalog } from '../util/modelCatalog.js';
import { readProviderKey } from '../util/providerRegistry.js';
import { BytePlusVideoProvider } from './providers/byteplus/BytePlusVideoProvider.js';
import { GeminiVideoProvider } from './providers/gemini/GeminiVideoProvider.js';
import { KlingVideoProvider } from './providers/kling/KlingVideoProvider.js';
import { MiniMaxVideoProvider } from './providers/minimax/MiniMaxVideoProvider.js';
import { TogetherVideoProvider } from './providers/together/TogetherVideoProvider.js';
import type {
IGenerateVideoParams,
@@ -44,7 +51,9 @@ import type {
const DEFAULT_PROVIDER = 'together-video-generation';
const isResolutionTier = (value: string): boolean => /^\d{3,4}p$/i.test(value);
/** A resolution tier such as `720p`, `2k` or `4k`, as opposed to pixels. */
const isResolutionTier = (value: string): boolean =>
/^(\d{3,4}p|\dk)$/i.test(value);
const parsePixelSize = (
value: string | undefined,
@@ -57,14 +66,15 @@ const parsePixelSize = (
};
/** Resolution tier of the shorter side, e.g. 1920x1080 and 1080x1920 → 1080p. */
const tierForPixels = ({
width,
height,
}: {
width: number;
height: number;
}) => {
const tierForPixels = (
{ width, height }: { width: number; height: number },
dimensions: readonly string[],
) => {
const shortSide = Math.min(width, height);
const exactTier = dimensions.find(
(tier) => tier.toLowerCase() === `${shortSide}p`,
);
if (exactTier) return exactTier;
if (shortSide <= 480) return '480p';
if (shortSide <= 720) return '720p';
if (shortSide <= 1080) return '1080p';
@@ -74,10 +84,10 @@ const tierForPixels = ({
/**
* Driver implementing the `puter-video-generation` interface.
*
* Manages multiple upstream providers (Together, BytePlus/Seedance, Gemini)
* and handles model resolution, provider routing, and parameter normalisation. Each
* provider is a plain `IVideoProvider` -- the driver instantiates them from
* config on boot.
* Manages multiple upstream providers (Together, BytePlus/Seedance, Gemini,
* Kling, MiniMax) and handles model resolution, provider routing, and parameter
* normalisation. Each provider is a plain `IVideoProvider` -- the driver
* instantiates them from config on boot.
*
* Providers handle their own metering internally.
*/
@@ -91,6 +101,8 @@ export class VideoGenerationDriver extends PuterDriver {
'together-video-generation',
'byteplus-video-generation',
'gemini-video-generation',
'kling-video-generation',
'minimax-video-generation',
];
readonly isDefault = true;
@@ -194,9 +206,13 @@ export class VideoGenerationDriver extends PuterDriver {
if (intendedProvider && !this.#providers[intendedProvider]) {
if (intendedProvider === 'gemini-video-generation') {
throw new HttpError(400, 'Gemini video generation is not configured', {
legacyCode: 'bad_request',
});
throw new HttpError(
400,
'Gemini video generation is not configured',
{
legacyCode: 'bad_request',
},
);
}
intendedProvider = fallbackProvider;
}
@@ -224,6 +240,29 @@ export class VideoGenerationDriver extends PuterDriver {
);
}
// Providers ignore options they don't know, so a source video sent to
// a model that can't edit would be dropped and the clip still billed.
if (request.input_video != null && !model.videoInput) {
throw new HttpError(
400,
`${model.id} does not accept a source video`,
{ legacyCode: 'bad_request' },
);
}
// A larger video comes as a URL or a Puter file, so it is never
// parsed out of the request body or held as one string.
if (
typeof request.input_video === 'string' &&
request.input_video.startsWith('data:') &&
dataUriByteLength(request.input_video) > MAX_INLINE_VIDEO_BYTES
) {
throw new HttpError(
413,
`A data URI input_video can be at most ${MAX_INLINE_VIDEO_BYTES} bytes; pass a URL or Puter file path instead`,
{ legacyCode: 'input_too_large' },
);
}
// Validate / normalise duration
if (model.durationSeconds?.length) {
const requestedSeconds = request.seconds ?? request.duration;
@@ -259,7 +298,7 @@ export class VideoGenerationDriver extends PuterDriver {
const catalogIsTiers = model.dimensions.every(isResolutionTier);
const wanted =
catalogIsTiers && requestedPixels
? tierForPixels(requestedPixels)
? tierForPixels(requestedPixels, model.dimensions)
: requestedResolution;
// Case-insensitive so '4K' matches a catalog entry spelled '4k';
@@ -289,7 +328,9 @@ export class VideoGenerationDriver extends PuterDriver {
}
const result = await provider.generate({
...request,
...(request.test_mode
? request
: await this.#withPuterImagesInlined(actor, request)),
model: model.id,
provider: model.provider,
});
@@ -307,6 +348,57 @@ export class VideoGenerationDriver extends PuterDriver {
return result;
}
/**
* `request` with image inputs given as Puter file paths read within the
* caller's access and replaced by data URIs. `request` is left as is.
*/
async #withPuterImagesInlined(
actor: Actor,
request: IGenerateVideoParams,
): Promise<IGenerateVideoParams> {
const references = Array.isArray(request.reference_images)
? request.reference_images
: undefined;
const frames = Array.isArray(request.frame_images)
? request.frame_images
: undefined;
const frameImage = (frame: object): unknown =>
frame && typeof frame === 'object'
? (frame as { input_image?: unknown }).input_image
: undefined;
const inlined = await readPuterImageInputs(
{ stores: this.stores, fs: this.services.fs },
actor,
[
request.input_reference,
request.last_frame,
...(references ?? []),
...(frames ?? []).map(frameImage),
],
);
if (inlined.size === 0) return request;
const swap = <T>(value: T): T | string =>
(typeof value === 'string' && inlined.get(value)) || value;
return {
...request,
...(request.input_reference != null && {
input_reference: swap(request.input_reference),
}),
...(request.last_frame && { last_frame: swap(request.last_frame) }),
...(references && { reference_images: references.map(swap) }),
...(frames && {
frame_images: frames.map((frame) => {
const image = frameImage(frame);
return typeof image === 'string' && inlined.has(image)
? { ...frame, input_image: inlined.get(image) }
: frame;
}),
}),
};
}
// -- Provider registration -----------------------------------------------
#registerProviders() {
@@ -321,7 +413,10 @@ export class VideoGenerationDriver extends PuterDriver {
);
if (togetherKey) {
this.#providers['together-video-generation'] =
new TogetherVideoProvider({ apiKey: togetherKey }, m);
new TogetherVideoProvider({ apiKey: togetherKey }, m, {
stores: this.stores,
fs: this.services.fs,
});
}
// Falls back to the shared `byteplus` (ai-chat) key; `apiBaseUrl`
@@ -363,6 +458,41 @@ export class VideoGenerationDriver extends PuterDriver {
{ stores: this.stores, fs: this.services.fs, metering: m },
);
}
// One `kling` key covers video, image and TTS.
const klingCfg = (providers['kling-video-generation'] ??
providers['kling']) as Record<string, unknown> | undefined;
const klingKey = readProviderKey(klingCfg);
if (klingKey) {
this.#providers['kling-video-generation'] = new KlingVideoProvider(
{
apiKey: klingKey,
apiBaseUrl: klingCfg?.apiBaseUrl as string | undefined,
},
m,
{ stores: this.stores, fs: this.services.fs },
);
}
// Falls back to the shared `minimax` (ai-chat) key. The chat block's
// `apiBaseUrl` names the chat API under `/v1`; the provider keeps its
// host, since the video API lives beside it.
const minimaxVideoCfg = providers['minimax-video-generation'] as
Record<string, unknown> | undefined;
const minimaxSharedCfg = providers['minimax'] as
Record<string, unknown> | undefined;
const minimaxKey = readProviderKey(minimaxVideoCfg, minimaxSharedCfg);
if (minimaxKey) {
this.#providers['minimax-video-generation'] =
new MiniMaxVideoProvider(
{
apiKey: minimaxKey,
apiBaseUrl: (minimaxVideoCfg?.apiBaseUrl ??
minimaxSharedCfg?.apiBaseUrl) as string | undefined,
},
m,
);
}
}
// -- Model map -----------------------------------------------------------
@@ -477,7 +607,9 @@ export class VideoGenerationDriver extends PuterDriver {
);
if (exactPuterIdMatch) return exactPuterIdMatch;
return models.find((m) => m.provider === provider) ??
(provider === 'gemini-video-generation' ? null : models[0]);
return (
models.find((m) => m.provider === provider) ??
(provider === 'gemini-video-generation' ? null : models[0])
);
}
}
@@ -56,6 +56,7 @@ import {
GEMINI_OMNI_COUNT_TOKENS_MAX_RESPONSE_BYTES,
GEMINI_OMNI_MAX_IMAGE_BYTES,
GEMINI_OMNI_MAX_RESPONSE_BYTES,
GEMINI_OMNI_MAX_VIDEO_BYTES,
} from './GeminiVideoProvider.js';
const { secureFetchMock } = vi.hoisted(() => ({ secureFetchMock: vi.fn() }));
@@ -240,23 +241,38 @@ const interaction = (
...('usage' in opts ? { usage: opts.usage } : { usage: USAGE_720P }),
});
/** Request bodies sent as a stream, read as Gemini would read them. */
const streamedBodies = new WeakMap<object, string>();
/** Answers with `body` once the request body, streamed or not, is read. */
const answer =
(body: unknown) =>
async (_url: unknown, init?: RequestInit): Promise<Response> => {
if (init?.body instanceof ReadableStream) {
streamedBodies.set(init, await new Response(init.body).text());
}
return Response.json(body);
};
/** Queues the countTokens reply, then the Interactions reply. */
const reply = (
body: unknown = interaction(),
count: unknown = { totalTokens: COUNTED_TOKENS },
) => {
fetchSpy
.mockResolvedValueOnce(Response.json(count))
.mockResolvedValueOnce(Response.json(body));
.mockImplementationOnce(answer(count))
.mockImplementationOnce(answer(body));
};
const sent = (call: number) => {
const [url, init] = fetchSpy.mock.calls[call]!;
const text = streamedBodies.get(init!) ?? String(init?.body);
return {
url: String(url),
method: init?.method,
headers: init?.headers as Record<string, string>,
body: JSON.parse(String(init?.body)),
text,
body: JSON.parse(text),
};
};
@@ -879,7 +895,259 @@ describe('GeminiVideoProvider request shape', () => {
reply();
await withActor(actor, () => generate());
expect(timeoutSpy).toHaveBeenCalledWith(30_000);
expect(timeoutSpy).toHaveBeenCalledWith(600_000);
expect(timeoutSpy).toHaveBeenCalledWith(840_000);
});
});
// -- Source video --
describe('GeminiVideoProvider source video', () => {
const mp4DataUri = `data:video/mp4;base64,${MP4.toString('base64')}`;
const VIDEO_INPUT_ALLOWANCE = 16000;
/** Queues only the countTokens reply, for calls that stop before generating. */
const countOnly = () =>
fetchSpy.mockImplementationOnce(
answer({ totalTokens: COUNTED_TOKENS }),
);
const writeVideo = async (
{ actor, userId }: { actor: Actor; userId: number },
name: string,
bytes: Buffer,
) => {
const path = `/${actor.user.username}/${name}`;
await server.services.fs.write(userId, {
fileMetadata: {
path,
size: bytes.byteLength,
contentType: 'application/octet-stream',
},
fileContent: bytes,
});
return path;
};
it('validates the video input shape in test mode without loading it', async () => {
expect(await testMode({ input_video: mp4DataUri })).toBe(SAMPLE_URL);
expect(secureFetchMock).not.toHaveBeenCalled();
for (const input_video of [42, ' ']) {
await expect(testMode({ input_video })).rejects.toMatchObject({
statusCode: 400,
message:
'input_video must be a video URL, data URI or Puter file path',
});
}
await expect(
testMode({ input_video: mp4DataUri, input_reference: pngDataUri }),
).rejects.toMatchObject({
statusCode: 400,
message: 'input_reference cannot be combined with input_video',
});
expect(fetchSpy).not.toHaveBeenCalled();
});
it('streams the video with no task, counts only the prompt, and holds the video input allowance', async () => {
const { actor } = await makeUser();
const { held } = trackHolds();
reply();
await withActor(actor, () =>
generate({ input_video: mp4DataUri, size: '360x640' }),
);
expect(sent(0).body).toEqual({
contents: [{ parts: [{ text: PROMPT }] }],
});
const request = sent(1);
expect(request.body.input).toEqual([
{
type: 'video',
mime_type: 'video/mp4',
data: MP4.toString('base64'),
},
{ type: 'text', text: PROMPT },
]);
expect(request.body.generation_config).toEqual({
max_output_tokens: 65536,
});
expect(request.headers['Content-Length']).toBe(
String(Buffer.byteLength(request.text)),
);
expect(held[0]?.amount).toBe(
holdFor(COUNTED_TOKENS + VIDEO_INPUT_ALLOWANCE),
);
});
it("streams a video URL and the caller's own Puter file at their exact length", async () => {
const user = await makeUser();
const clip = Buffer.concat([MP4, Buffer.alloc(4096, 1)]);
const timeoutSpy = vi.spyOn(AbortSignal, 'timeout');
secureFetchMock.mockResolvedValueOnce(
new Response(clip, {
headers: { 'content-length': String(clip.byteLength) },
}),
);
reply();
await withActor(user.actor, () =>
generate({ input_video: 'https://example.com/dog.mp4' }),
);
expect(secureFetchMock).toHaveBeenCalledWith(
'https://example.com/dog.mp4',
{ signal: expect.any(AbortSignal) },
);
expect(timeoutSpy).toHaveBeenCalledWith(60_000);
expect(sent(1).body.input[0].data).toBe(clip.toString('base64'));
expect(sent(1).headers['Content-Length']).toBe(
String(Buffer.byteLength(sent(1).text)),
);
const path = await writeVideo(user, 'dog.tmp', clip);
reply();
await withActor(user.actor, () => generate({ input_video: path }));
expect(sent(3).body.input[0]).toEqual({
type: 'video',
mime_type: 'video/mp4',
data: clip.toString('base64'),
});
});
it('reads the source video only after the credit hold, and not at all without credit', async () => {
const user = await makeUser();
const order: string[] = [];
const { held } = trackHolds(order);
secureFetchMock.mockImplementationOnce(async () => {
order.push(held.length ? 'fetch after hold' : 'fetch before hold');
return new Response(MP4, {
headers: { 'content-length': String(MP4.byteLength) },
});
});
reply();
await withActor(user.actor, () =>
generate({ input_video: 'https://example.com/dog.mp4' }),
);
expect(order).toEqual(['fetch after hold', 'release']);
const path = await writeVideo(user, 'dog.mp4', MP4);
const read = vi.spyOn(server.stores.s3Object, 'getObjectStream');
hasCreditsSpy.mockResolvedValue(false);
countOnly();
await expect(
withActor(user.actor, () => generate({ input_video: path })),
).rejects.toMatchObject({ statusCode: 402 });
expect(read).not.toHaveBeenCalled();
expect(secureFetchMock).toHaveBeenCalledTimes(1);
});
it.each([false, true])(
'releases the hold when the caller cancels while the source loads (pre-aborted %s)',
async (preAborted) => {
const { actor } = await makeUser();
const { held, released } = trackHolds();
const abort = new AbortController();
if (preAborted) abort.abort();
secureFetchMock.mockImplementationOnce(
(_url: string, init: { signal: AbortSignal }) =>
new Promise((_resolve, reject) => {
init.signal.addEventListener(
'abort',
() => reject(init.signal.reason),
{ once: true },
);
abort.abort();
}),
);
countOnly();
await expect(
withActor(actor, () => {
Context.set('abortSignal', abort.signal);
return generate({
input_video: 'https://example.com/dog.mp4',
});
}),
).rejects.toMatchObject({ name: 'AbortError' });
expect(secureFetchMock).toHaveBeenCalledTimes(preAborted ? 0 : 1);
expect(fetchSpy).toHaveBeenCalledTimes(1);
expect(held).toHaveLength(1);
expect(released).toHaveBeenCalledOnce();
expect(batchSpy).not.toHaveBeenCalled();
},
);
it("reports a source that fails while streaming as the caller's input", async () => {
const { actor } = await makeUser();
const { released } = trackHolds();
// Fails past what reading ahead to sniff the type pulls in.
const chunks = [
MP4,
...Array.from({ length: 8 }, () => Buffer.alloc(32 * 1024)),
];
let pulls = 0;
secureFetchMock.mockResolvedValueOnce(
new Response(
new ReadableStream({
pull(controller) {
const chunk = chunks[pulls++];
if (chunk) controller.enqueue(new Uint8Array(chunk));
else controller.error(new Error('connection reset'));
},
}),
{ headers: { 'content-length': String(1024 * 1024) } },
),
);
reply();
await expect(
withActor(actor, () =>
generate({ input_video: 'https://example.com/dog.mp4' }),
),
).rejects.toMatchObject({
statusCode: 400,
message: 'Failed to read input_video',
});
expect(released).toHaveBeenCalledOnce();
expect(batchSpy).not.toHaveBeenCalled();
});
it('refuses an image as the source video without generating', async () => {
const user = await makeUser();
const { released } = trackHolds();
countOnly();
await expect(
withActor(user.actor, () => generate({ input_video: pngDataUri })),
).rejects.toMatchObject({
statusCode: 400,
message: 'input_video must be an MP4, QuickTime or WebM video',
});
expect(fetchSpy).toHaveBeenCalledTimes(1);
expect(released).toHaveBeenCalledOnce();
});
it('refuses a data URI over 1 MiB and a file over 32 MiB with 413', async () => {
const user = await makeUser();
const inline = Buffer.alloc(1024 * 1024 + 1);
MP4.copy(inline);
await expect(
withActor(user.actor, () =>
generate({
input_video: `data:video/mp4;base64,${inline.toString('base64')}`,
}),
),
).rejects.toMatchObject({
statusCode: 413,
legacyCode: 'input_too_large',
});
expect(fetchSpy).not.toHaveBeenCalled();
const big = Buffer.alloc(GEMINI_OMNI_MAX_VIDEO_BYTES + 1);
MP4.copy(big);
const path = await writeVideo(user, 'big.mp4', big);
countOnly();
await expect(
withActor(user.actor, () => generate({ input_video: path })),
).rejects.toMatchObject({
statusCode: 413,
legacyCode: 'input_too_large',
});
expect(fetchSpy).toHaveBeenCalledTimes(1);
});
});
@@ -1332,7 +1600,7 @@ describe('GeminiVideoProvider credit hold', () => {
// Input plus verified 4K video usage and a separate text allowance.
expect(HOLD).toBe(363_065_100);
expect(held).toEqual([{ amount: HOLD, ttlMs: 11 * 60 * 1000 }]);
expect(held).toEqual([{ amount: HOLD, ttlMs: 15 * 60 * 1000 }]);
expect(heldDuringCall).toBe(HOLD);
await vi.waitFor(async () =>
expect(
Loaded 100 of 496 files, more files were not shown because too many files have changed in this diff. Show more