* fix: charge stat's return_shares against the share-listing budget
`return_shares` on /fs/stat and the legacy /stat runs the same work as
GET /share/shares, but was only metered under fs:stat's far more
generous limit — and the two scopes stacked instead of sharing one
counter.
Adds consumeRouteRateLimit(req, spec), an imperative charge that
resolves the key and per-subscription limit exactly as rateLimitGate
does, so a handler can conditionally spend a second scope when a
request flag makes the route expensive. Both stat handlers now charge
share:list before doing the listing work; SHARE_LIST_LIMIT moves to a
shared share/limits.ts so all callers pin the same spec.
Closes PUT-1597.
* feat: consumeRouteRateLimit takes the array spec form too
Review follow-up on #3870: a multi-window spec passed whole would have
read an undefined window and silently never pruned. Charge each window
in order instead, refusing on the first refusal, matching the gate.
Emits ai.cost.multiplier.<driver>.<provider>:<model> before recording AI
usage, so what a model costs to charge is policy an extension owns rather
than a number in core. Nothing listening records the provider cost.
MeteringService.withAiCostMultiplier(driver) returns a view of the service
whose recording paths scale costOverride by the hook's answer; every AI
driver hands that view to its providers, so all of them are covered without
touching provider code.
* feat: one recipient field in the sharing dialogs, with suggestions
Both sharing dialogs asked twice: a text field for a person, and a
separate select, label, note and button for a team. Which control to use
was a fact about the API — a bare string is read as an email or a
username, so a team could not be typed — not something a user should
have to know.
Now there is one field. Clicking it offers who this account shared with
before, the teams it belongs to, and the people in them; typing narrows
the list. Choosing an offer locks the field to that recipient, so a team
picked by name still goes out by uid, and the note about a team grant
reaching everyone in it appears at the moment it applies. Anyone already
on the access list is left out of the offers, and a typed address still
works untouched.
Past recipients are kept in the account's key-value store, so they
follow the user between browsers; a typed address that turned out to
belong to an account is filed under the username the backend resolved.
Colleagues are read one page per team, cached for five minutes, and only
looked up once the field is actually used.
The picker is one helper shared by both dialogs so the two can't drift,
styled through `share-suggest-*` tokens each host restates in its own
palette. It opens in the flow rather than floating, which a scrolling
modal body and a mobile bottom sheet would otherwise clip.
* fix: float the recipient suggestions instead of resizing the dialog
The list opened in the flow, which pushed the Share button and everything
under it down and back up as it opened and closed — a dialog that resizes
under the cursor. It now hangs off the recipient row, out of the flow, and
floats over what follows it.
It goes inside the row rather than after it, so the row is its containing
block; as a sibling it resolved against whatever was positioned further up
and landed at the bottom of the scrolling body. Being out of flow it can
be cut off by that scroll container rather than scrolling itself, so on
open it measures the room between the row and the nearest clipping
ancestor, caps the list to it, and opens upwards where below is too tight.
* fix: keep the recipient field focused when the picker is wired onto it
Both sharing dialogs focus the recipient field as they open, and the
picker then moves that field into its wrapper. Moving an element takes
it out of the document for an instant, which drops its focus, so every
dialog opened with the caret in the field opened with the caret on
<body> instead.
* fix: keep the recipient suggestions inside the screen
The list sized itself against the nearest clipping ancestor alone. That
ancestor is the dialog's own scrolling body, which reaches past the
bottom of a short screen — or of any screen once the dialog has been
dragged low — so the list was placed below the fold and the user saw
nothing at all. The viewport bounds it too.
* fix: stop the suggestions flashing a loading box over the dialog
Clicking the recipient field put up a panel saying it was loading before
it knew there was anything to load. For the many accounts with no teams
and nobody shared with before, that panel covered the Share button for
as long as the round trip took and then vanished again. It now waits a
beat before saying anything, and leaves whatever is already listed in
place while a reload is in flight. A list still on its way also no
longer swallows the Escape that closes the dialog.
* fix: do not double-encode the recipient field's placeholder
`i18n()` encodes what it returns, which is right for a string dropped
into markup and wrong for one handed to `.attr()` — the entities show up
as themselves. Nothing is lost today because only English carries the
key, but the next translation with an apostrophe in it would read
`l'équipe`.
* chore: drop the styles for the team picker the field replaced
The desktop dialog's separate team control went with the one recipient
field; its rules stayed behind. The dashboard's equivalents were already
removed with its own markup.
* fix: let Enter share from the desktop dialog's recipient field
Typing a name and pressing Enter did nothing there: the field is in no
form, so the only way to send was to reach the button. The Dashboard's
dialog has always submitted on Enter, and the field now takes Enter to
choose a suggestion, which makes a second press that does nothing read
as a dead key. The picker still gets the press first while it is
choosing a row.
The billing view now names each seat's status; the console passes it
through to the picker so a cancel-pending plan can be kept, plus the
two strings that flow renders.
* fix: list a link share only while the owner's plan covers it
* fix: fs limits for signed urls
* feat: a paid plan counts as a verified card for the sharing gate
#3874 added 0084_share-anyone-with-link.sql, taking the sqlite chain's
target user_version to 80, but CURRENT_SCHEMA_VERSION stayed at 79 —
and the test workflow only runs on PRs, so main broke silently and every
open PR's 'test (base)' job now fails on it.
* feat(email): inline cid attachments and Puter mailbox delivery for sendTransactional
EmailAttachment gains cid/contentDisposition so the transactional driver can send inline images. The SDK's EmailAttachment typedef now comes from types.js, which already carried cid. Docs describe delivery to <username>@puter.email recipients and the not_found code.
* feat: share with anyone with the link (PUT-1580); gate sharing on a verified phone or card
Review follow-up on #3871: the flush-level rule — shared_app on the
button only when every entry came through one app — had no direct
coverage. An app share and a plain share folded into one digest now
prove the line and item link keep their attribution while the button
stays clean.
Uploads stream from the page, so closing the tab loses a nearly-finished
one with no warning. The beforeunload handler only existed behind
prompt_user_when_navigation_away_from_puter, which is off by default.
Install it unconditionally and have it consult window.active_uploads,
the registry the progress code already maintains. The feature flag keeps
its open-window behavior.
Uploads now register in active_uploads before the first await in the
init callback: an upload failing while the progress window was still
opening used to run its delete ahead of the insert, leaving a phantom
entry that would have made the tab unclosable.
feat: the team seat experience — no email required, forced password change, team label, and plan-based limits (PUT-1792)
Note: Bypassing the code owners rule, since there are couple approvals in place for this.
`gui_params.teams_ui` stays the deployment-wide switch; with it off, the
tab now also renders for a signed-in user who passes the email-domain
allowlist (membership included, so seats see their roster). Anonymous
renders hide it, and the API keeps deciding real access either way.
`teams_allowed_email_domains` limits who may enter the teams surface;
unset keeps today's behavior. Gated on the two routes that constitute
entry — creating a team, and the listing that shows the tab — with the
same 404 a teams-off deployment answers, so the GUI needs no change and
a staged rollout is indistinguishable from the feature being off.
Members of an existing team always pass, whatever their domain: an
allowed owner brought them in, and the surface follows the team.
Deleting a team suspends every provisioned seat, but every member route
resolved live teams only — so the suspended seats could never be
deleted afterwards, stranding the accounts and, on the billing side,
their paused subscriptions. deleteMember now resolves the team
soft-deleted or not, matching the audit reader.
It also gains the guard enableMember always had: only the team's own
suspension qualifies. A platform-suspended seat could previously be
cascade-deleted by the owner, destroying an account Puter had frozen.
When an app shares on a user's behalf, the mail now says so — the
digest line reads 'alice shared report.txt via Mail App', preferring
the app's title, then its name, then its index_url host. Both the
per-item links and the 'Open Puter' button carry a new shared_app
query param (the app's name, or its uid) so the GUI can match the
share back to the app; the button only carries it when the whole
digest came from one app. Plain shares are byte-for-byte unchanged.
Also drops SHARE_EMAIL_BATCH_SECONDS from 30 to 5: the window only
has to fold one gesture's worth of calls, and every second there is
a second the common single-share email arrives late.
Closes PUT-1798.
Every team route except the directory refuses app and API-token callers —
administration belongs to the account console. Publishing provisioning,
credentials, suspension and audit in the app-developer docs invited
integrations that would 403 on their first request, so those thirteen
pages are gone and the overview is rewritten around what an app can do:
detect team context with list(), look up colleagues with listDirectory(),
and share with a whole team.
listDirectory — the one app-callable method — was also the only one with
no page; it has one now, leading with its consent gate. The team
recipient joins the share() docs, where the integration actually happens.
From the adversarial review of this PR.
The forced password-change gate could deadlock: it POSTed to the
cookie-only route with a bare fetch, and both initgui call sites open it
before update_auth_data mints the session cookie — a fresh browser with a
token URL 401'd every submit inside a non-dismissible loop. It uses the
session-cookie retry wrapper now, taking the caller's token because
window.auth_token does not exist yet on that path. It also gains the
logout footer its sibling gates have; a lost temporary password was a
hard lock with devtools as the only exit.
Password recovery refused for seats: the address is admin-supplied and
never verified, so whoever holds that inbox could take the seat over at
any later time. A seat's recovery channel is its admin's reset.
change-email gets the same seat guard as change-username and deletion —
the address is where admin-issued credentials go.
The login response now carries `team` alongside requires_password_change:
no-reload logins store that payload as window.user verbatim, and every
seat restriction keys on it.
Smaller: the team-badge tooltip no longer double-encodes; the create-token
hint for an emailless account stops pointing at a verification it can
never perform; the quotas doc records the halved org_seat_free allowance;
the config template tells upgrading operators how to keep the old flat
cap; the SDK suite covers emailless provisioning and the owner-only uuid.
Review catch by @Salazareo: `org_seat_free` reached `FREE_SUBSCRIPTION_IDS`
and so the `requireSubscription` gate, but two other surfaces decide on
plan and neither consults that set.
`bySubscription` maps name `user_free` and `temp_free`. A plan that
matches no key fell through to the top-level `limit` -- the paid cap --
so a seat outranked an ordinary free account: 240 event listings a minute
against their 120, and the same shape across the kv, notification,
subdomain and worker drivers. Both resolvers now fall back to the
`user_free` entry for anything in the free set, which covers every driver
at once and any free plan added later.
`subscriberOnly` compared against the two named ids, so a seat could
reach a paid-only model. It asks the set now.
A paid plan that names no cap of its own still takes the base, and an
unresolved plan still takes the base; there are tests for both so the
fallback cannot widen into "free by default".
* Dashboard Files: keep rows in sorted order when icons resolve late
renderDirectory appended each row as soon as its icon resolved. Icons for
weblinks and .app files are read from the file itself, so those rows always
landed at the end of the listing regardless of the sort column or direction.
Resolve every icon first, then append the rows in sorted order; renderItem
takes the pre-resolved icon and still looks it up itself for single-row
callers (socket adds, instant folder creation).
* Rename: store the new item name raw in data attributes and the editor
rename_file wrote html_encode(new_name) into data-name, title and the
name editor's value. jQuery's attr()/val() store strings literally, so a
file renamed to "a&b.txt" carried data-name "a&b.txt": the dashboard
Files tab, which re-reads data-name for column truncation, then showed the
entity on screen, type-to-select and sorting compared the encoded string,
and the editor reopened on the encoded name. The initial render already
stores these raw; make the rename path match.
* Dashboard Files: Enter with several folders selected enters only the first
The Enter handler called pushNavHistory + renderDirectory for every selected
folder. Only the first render runs (renderDirectory drops calls while one is
in flight), but every folder was pushed onto the navigation history, so after
selecting two folders and pressing Enter, Back led to the folder already on
screen and Forward to one that was never opened. Enter now navigates into the
first selected folder only; selected files still open in their apps.
* Dashboard Files: don't offer to move items the user can't move
Trashing or cutting sends an item to its owner's trash or a new folder, which
a shared root or a read-only share can't do — the server answers Forbidden and
the user gets an error alert for an action that should not have been offered.
The single-item context menu already leaves Delete out for such rows; the
Delete key, Ctrl/Cmd+X, the multi-selection menu and the mobile selection bar
did not check. Route all of them through can_restructure, skipping rows that
can't move and hiding Cut/Delete when the whole selection can't.
* Dashboard Files: hide New Folder and Upload in the Shared view
Shared is a query over other people's items, not a directory. Both buttons
stayed visible there: New Folder did nothing, and Upload opened the OS file
picker and then silently dropped whatever the user picked. Hide them the same
way Trash already does.
* Dashboard Files: let the same file be picked again after a failed upload
The upload input was only cleared in the success callback, so after an upload
that failed, was cancelled, or was blocked (Shared view), choosing the same
file again fired no change event and nothing happened. Snapshot the picked
files and clear the input as soon as the change fires. This also drops the
document.querySelector('form').reset() that reset whichever form came first
in the document rather than this one.
* Dashboard Files: read saved preferences in parallel and re-sort immediately
init awaited four independent kv reads one after another before the first
listing could start, and handleSort awaited two kv writes before re-rendering,
so a sort click cost two round-trips before anything moved. Issue the reads
together and let the writes settle in the background. While here, set
$el_window before the first await (renderDirectory reached through a route
change or socket event during init threw and left renderingDirectory stuck),
and tolerate a corrupt saved column_widths value instead of failing init.
* Dashboard Files: show the loading spinner on the first directory load
showSpinner keyed off a loading flag, but clearing the listing at the start of
a navigation removed the overlay without resetting it: init's spinner was
wiped by the first render's clear, and the render's own showSpinner call then
no-oped, so the initial load ran with no indicator. Guard on the overlay's
presence instead.
* puter_signed_upload_error_correction
* docs: update signed upload behavior
* fix(puter-js): complete a bodiless response in the XHR shim
A response with no body at all (`fetch` gives `null`) threw inside the
shim's own `then`, the same way a missing content-type did: no 'load',
no 'error', the request hangs forever. Signed storage PUTs answer in
exactly that shape.
Adds the regression tests the content-type fix was missing — all three
hang against the shim as it was.
* refactor(puter-js): drop the signed batch-write env allowlist
With `nodejs`, `service-worker` and `web-worker` added, the list held
every value `puter.env` can take, so the gate decided nothing — and a
new env would have been silently routed to the legacy path.
The backend capability check (`signedBatchWriteSupported`, set from a
404/405/501 on `startBatchWrite`) is the one that still does work.
* test: run the directory-upload suite on node and workers
Both were pinned to browser because the legacy `/batch` fallback cannot
create a directory tree. Now that every platform takes the signed path
they are the regression test for it, and the stale comment goes with
them.
---------
Co-authored-by: Daniel Salazar <daniel.salazar@puter.com>
- PUT-1800: gate `createWorkerSessionToken` on actor type, so an app or an
access token can no longer mint an app-less, root-shaped worker session;
`WorkerDriver` binds on `effectiveApp` instead of `app`.
- PUT-1799: add `isAccountContext` and read it where "no app" was being read
as "the account" — handler publish, events-worker listing, kv handle
mint/revoke/list. A scoped API token is no longer an account session.
- PUT-1802: re-authorize a durable row before its backlog drains, settling it
permanently when the grant is gone. Covers an ancestor-level unshare, which
the revoke settle deliberately leaves to the delivery re-check.
- PUT-1803: mask the owner's absolute path out of deliveries and subscription
anchors on a foreign node, the way every FS surface already does.
- PUT-1804: let a revoke reach rows already suspended for a resumable reason,
re-stamping them so a resume cannot hand over the held backlog.
- PUT-1805: apply the subscribe path's audience gate to `/events/fetch` before
the query, so a cursor can no longer count and name invisible notifications.
- PUT-1807: refuse `mode: 'manage'` from any actor holding an app — inside its
own AppData the ACL short-circuit would otherwise supply the reach.
- PUT-1808: take the sending peer from the verified signature header rather
than the request body.
- PUT-1810: re-base a kv share-handle row's stored match filter on the handle,
so the owner's absolute key prefix stays hidden.
- PUT-1814: escape LIKE wildcards and anchor the issuer-prefix queries on a
segment; anchor `manage:` stripping; reject a backslash in a share prefix;
assert a resolved actor in `subscribeDurable`.
- PUT-1815: bound the char/varchar columns behind `event_subscriptions` and
`kv_share_handles` at the store layer.