`ExecService.launchApp`, reachable from `puter.ui.launchApp`, launched any
named app with no check on the target. Gate it: a godmode target may only
be launched by a godmode caller. Desktop launches (tile, double-click,
URL) call `launch_app` directly and are unaffected; `connectToInstance`
already had its own allowlist.
A root was free to be renamed or moved, so it could be parked on a
username no account held yet. The next account to take that name
provisioned a second root at the same path, and from then on either row
could answer a lookup of it — new entries inherit their owner from
whichever row resolved as the parent, so one account's files were
created owned by the other, and renaming the parked root dragged the
other account's subtree along with it.
Rename and move now refuse a root, every username claim checks the home
path before the user row is written, and renameUserHome refuses to heal
onto a path another account holds.
- ProfileService keeps each profile as /system/profiles/<uuid>.profile,
admin-owned and served by the protected puter-profiles subdomain
- GET/POST /profile; puter.auth.getProfile/updateProfile, with
getProfilePicture reading through them
- another user's profile is served only while its owner is on a paid
plan (profileGate.enabled kill switch); the hosted file is gated
through the new site.access.check hook the hosting middleware asks
before streaming any file
- SubdomainStore.create takes isProtected
- the GUI reads and writes the profile through the SDK
- docs: getProfile, updateProfile, UserProfile, limits
PUT-1859 PUT-1860 PUT-1861 PUT-1862
The clickjacking guard in #3912 pinned `event.source` to `messageTarget`,
which is only assigned when `env === 'app'`. On a third-party site it is
undefined, so the UI listener dropped every message and
showOpenFilePicker, showSaveFilePicker and showDirectoryPicker never
settled.
Split the two environments. `app` still pins the host frame, whose origin
is whatever the deployment is served from. `web` has no host frame — the
picker popups post back directly — so pin the GUI origin we opened them
on, plus the set of popups we opened. Origin pinning is safe there,
unlike the parent-frame case, because we chose the URL, so self-hosted
and local deployments keep working.
Falls back to the origin alone when `event.source` is null: the picker
calls window.close() right after posting and a discarded browsing
context can drop the source.
PUT-1867
* fix(ai): send stable, non-sequential user identifiers to AI providers
A precedence bug in the AI providers' identifier expression made every
request send `user: ":undefined"` (the ternary bound the app-uid suffix to
the whole `actor.user.id + actor.app?.uid` sum instead of just the suffix),
or read `actor.user.id` on a missing user. The same expression also shipped
the sequential internal user id, letting AI vendors correlate a single
account across apps and sessions.
All eight OpenAI-, Azure-, xAI-, Meta- and ZAI-style providers now build
the identifier through one shared helper, `aiUserIdentifier()`:
- `puter-<user-uuid>[-<app-token>]`: the random user UUID is always
preserved in full; `maxLength` constrains only the app-bearing form
- app attribution reads `effectiveApp`, so access-token requests name the
issuing app instead of looking like direct user traffic
- the app token is truncated to fit the budget, and omitted entirely when
the remaining budget is below 8 chars, where a truncation could collide
with another app's uid
- nothing is sent for the system actor
- Meta and ZAI keep a caller-supplied `safety_identifier` / `user_id`
override, applied before the helper result
`user` is deprecated by OpenAI; the SDK types direct callers to
`safety_identifier` (abuse detection) and `prompt_cache_key` (cache-hit
bucketing). The four OpenAI/Azure chat providers and MetaProvider now send
`prompt_cache_key` as well, defaulting it to the same per-user identifier
unless the caller supplies one; Azure's Grok branch drops both fields,
matching its rejection of unknown args. The cap comment cites only verified
limits: OpenAI's 64 for `safety_identifier` (from the SDK types) and Z.AI's
6-128 for `user_id` (from Z.AI's docs); Meta and xAI document none, so none
is claimed.
The xAI image `#edit` path now carries the identifier like generation, and
takes a named-options param so `user` cannot be transposed with the
adjacent same-typed `aspectRatio`.
Tests share a four-actor matrix (`user` / `user+app` / `access token` /
`system`) with `assertActorMatrixIdentifiers()` across the six
OpenAI-style suites; the helper has exact-string and boundary coverage
(size caps, zero-budget and sub-base cases, no dangling separator, UUID
never truncated, collision guard); the Azure Grok assertions run under a
real user actor so they cannot pass vacuously. 212 provider-suite tests
pass; typecheck and ESLint are clean.
* fix(ai): lock the vendor identifier down and keep vitest out of the test util
Meta and Z.AI no longer let `custom` override the abuse identifier; it is
Puter's attribution, not the caller's. The shared test util exposes pure
field pickers instead of importing vitest into a file the production
tsconfig compiles. The helper's length-cap comment now matches vendor docs
(Meta does cap `safety_identifier` at 64), the redundant budget branch and
the unused export are gone, and the per-user `prompt_cache_key` trade-off is
stated once in the helper instead of five times in providers.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
---------
Co-authored-by: 404oops <me@404oops.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
* fix(ai-image): sync image catalogs, unify request shape, document models
Model sync against every vendor listing and a live generation sweep:
Gemini stable ids replace the retired preview spellings (2.5 Flash Image
delisted ahead of its 2026-10-02 shutdown), OpenAI gpt-image-1/-1-mini/-1.5
are delisted but routable until their shutdown dates with gpt-image-2 as
the default, xAI gains grok-imagine-image-2.0 with its quality tiers,
BytePlus gains the 3K/4K tiers and per-model pixel bounds, Cloudflare
gains SDXL Lightning/Base and SD 1.5 Inpainting, Replicate gains a
schema-driven catalog of 88 additional models with version-pinned
community predictions, and every Together image route is excluded for
the third-party data-sharing requirement.
Request normalization: the driver collapses ratio/width/height/aspect_ratio
into one imageSize with aspect-versus-pixel intent, validates prompt,
quality and resolution once, resolves provider hints (short names or
full driver ids) and aliases with exact ids winning over resellers, and
hands each provider an immutable copy of the caller's args. Providers
share prompt validation, aspect snapping, and content-sniffed data URIs
so responses carry the right MIME type. Replicate predictions are created
once, cancelled on abort or deadline, and bounded in input fan-out.
SDK: txt2img copies caller options, rejects blank prompts with
prompt_required in every call form, and documents that normalize has no
effect because the image return shape is already uniform.
Docs: txt2img options per provider, provider defaults and discovery,
availability notes, a new image model catalog and pricing page, and the
image-generation limits in the quotas page.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* fix(ai-image): sniff SVG outputs correctly, reject hex/exponent dimensions
imageDataUri only decoded the first 24 base64 chars (18 bytes) before
sniffImageMime, which cannot see an <svg> root behind an XML prolog -
SVG outputs from recraft-v*-svg models were being labeled image/png.
Decode enough of the payload to cover the 8 KB SVG sniff window.
dimension() accepted string number literals ('0x10', '1e3') as if they
were decimal dimensions; restrict to plain decimal notation.
* docs(ai): link to the model directory instead of a static catalog
The image model catalog and pricing page duplicated the always up to date
model directory at developer.puter.com/ai/models. Link to the directory
from txt2img-related docs and keep the Together data-sharing exclusion
note self-contained.
---------
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Chat and video drivers keyed their provider and model maps on plain objects,
so `model` or `provider` values such as `__proto__` reached Object.prototype
and surfaced as 500s. Both maps are now null-prototype objects and reject
those names as ordinary unknown models.
txt2vid now rejects blank or non-string prompts with `prompt_required` before
any request, tolerates `null` in either argument slot, and copies the caller's
options before resolving the `duration` alias and output path so frozen
option objects work and caller objects are never mutated.
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
`validateUserPerms` started the linked SQL traversal beside the flat read
and returned on a flat hit without awaiting it. The hit is the common
case, so nearly every user-permission check left a floating promise: with
`keep_alive_on_uncaught` unset no `unhandledRejection` listener is
registered, so a DB wobble inside the traversal exits the process.
Run it only when the flat read misses. That also stops a SQL walk the
cache had already answered.
A present-but-empty `app_uid` fell through to checking the user, where
every `fs:` scope on their own file answers `true` — a prompt settled by
a question nobody answered. It is a 400 now.
The launch-path check also sat outside any catch, so a rejecting read
would have taken the whole launch down instead of falling through to the
prompt. Renamed the seam it is injected through to match the function it
defaults to, since wiring the token-based sibling in its place would send
an app uid as a bearer token and silently always prompt.
Opening `/app/<name>?file=<path>` asked for consent on every launch: the
gate prompted unconditionally and never read the `fs:<uid>:write` grant
its own Allow had written.
`/auth/check-permissions` takes an optional `app_uid` so the account can
ask what one of its apps holds, and the launch gate skips the dialog when
the answer is yes. Sessions only — an app or a scoped token asking would
be a window onto its neighbours' grants.
* Redesign the Teams tab in the dashboard
Hero card with a lettered tile, role pill, handle and headcount; the
directory setting becomes a settings row with the shared toggle switch;
the add-account form gets labels, Enter to submit, and a copy button on
the one-time password. The accounts and record tables drop the grid for
hairlines, avatar tiles and status pills, and stack into labelled rows
on phones so actions stay reachable. Loading, empty and error states
get a skeleton, a create call to action, and a retry.
Also fixes audit dates showing 1970 (the audit routes send unix
seconds), the actions cell breaking the row border by being a flex
table cell, and the "0 suspended" clause on a healthy team.
* Use the dashboard's overlay dialog in the Teams tab, not UIAlert
Adds UIDashboardDialog, a confirm / alert / prompt card built like the
share and properties modals: mounted in the dashboard window, revealed
after a frame, dismissed by Escape, the close button or a backdrop
press-and-release, with a focus trap and focus restored on close. On
phones it docks to the bottom as a sheet. The Teams tab's confirms,
error alerts, seat-limit warning and rename/create prompts all go
through it; UIAlert and UIPrompt are no longer imported there.
A local `npm install` (needed to pick up main's new `postal-mime`) wrote
`"extraneous": true` onto a bundled `@clack/prompts` dependency. It was the
branch's only lockfile change and it does not belong to this PR.
CI caught three HTTP-level tests the earlier merge left behind, and they
were right to fail: dropping this branch's `manage` gate in favour of
main's row filter lost a case main's filter does not cover.
Main bounds an app to the rows it issued. A *scoped* access token is not
an app, so it was falling through unbounded — `/fs/stat` with
`return_shares` handed a `fs:<uid>:list` token the owner's whole share
list. Addresses stayed withheld, but who else can reach a file is no
more a narrow token's business than the addresses are.
So the filter is generalised rather than the gate restored: a scoped
token is bounded to nothing, since it issues nothing under its own name.
Filtering it by a null app would have been worse than not filtering —
that matches the owner's own rows. Apps and sessions behave exactly as
they do on main, and a full-access token still holds the account's reach.
The three tests now assert the answer instead of a refusal, including
the one whose name had always promised a refusal its body never checked.
Two textual conflicts, both additive on each side: `isAccountContext`
(here) and `isPlainUserActor` (main) are both imported and both used,
and the `stat()` note keeps both sentences — this branch's on who may
read an invite address, main's on the share-read limit it spends.
The rest is adapting to main, which grew its own answer to half of what
this branch was for. `listSharesOf` there bounds an app to the rows it
issued itself; this branch instead required the credential to hold
`manage` and refused it otherwise. Main's is the better mechanism — it
answers the app rather than turning it away, and it hides other
issuers' rows outright rather than redacting a field on them — so the
`manage` gate goes, and with it the two tests that asserted the
refusal. They are replaced by tests that hold main's line: an app sees
none of the invites it did not send, with or without `manage`.
What this branch still carries is the gap main does not close. Its row
filter only applies to apps, so a plain manage delegate still reads the
owner's invite addresses; `#maySeeInviteAddress` is what withholds
those, and its delegate tests pass unchanged. The `stat()` note is
corrected to describe main's behaviour rather than the removed gate.
Review call from the ticket's author: a team share is the team's, not
one colleague's to withhold from another, so a personal block should not
hide it. This drops the enforcement added earlier on the branch — the
listing, its total and the fs-event fan-out no longer filter team rows
by the recipient's block list, and the SQL fragment that did it goes
with them.
What a block still does for a team share is suppress the notification,
which was already true before this branch: it stops the interruption
without pretending to stop the access. Leaving the team is what ends
that. Said so in the block API docs, the settings copy and the code,
since the mismatch between the two was the original complaint.
The unshare sweep is untouched — that half of the ticket stands.
* 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.
A provisioned account read as a plain "Free" plan with an "Upgrade for
more features" badge: nothing said the account belongs to a team, and
the prompt asked for an upgrade only its owner can buy. Its free tier is
not the free plan either — org_seat_free is half the allowance.
The card now names the team on both paths: a paid seat keeps its tier
and status and gains "Managed by <team>", and a seat with no tier reads
"Team account" instead of an upgrade pitch.
The Home tab read /marketplace/subscriptions/current and folded every
failure into the free state: a non-OK response left `subscription` null,
and the catch said so explicitly. So a refusal rendered as a confident
"Free" with an Upgrade badge.
That is reachable: verification gates are default-on for authenticated
routes, and a team seat that still owes its password change is refused
there — it then reads Free while its team pays for a tier. A rate limit
or a blip does the same to anyone.
The plan card moves into its own method so a failed read can return
without touching it, leaving the card as it was rather than naming a
plan the account does not have. A 200 carrying no subscription still
reads Free, which is the one case that actually means it.
- A full-access token was denied the address while `shared-by-me` still
handed it the same rows, so the clause bought no privacy and cost an
API client the address `unshare()` takes. `isAccountContext` is the
boundary the rest of the codebase already uses for 'acting as the
account': plain session or full-access token, never a scoped one.
- The Dashboard share modal dropped a withheld invite entirely, since
its aggregate keys a pending row on the address — so a delegate saw no
sign of an outstanding invite and accessCount under-reported who could
reach the item. It is now kept, keyed on the share uid, labelled, and
without the controls that would need a recipient to address.
getShares (and stat's return_shares, which runs the same listing) gated
on #assertCanManage's default 'see' mode, so any credential that could
see the node got every unclaimed invite's raw email — including an app
handed one file by the picker, a list-scoped token on the stat surface,
and a manage delegate reading the owner's invitees.
Two bounds, matching the invariant clientShare.ts already claimed:
- An invite's address goes only to the item's owner and to whoever sent
it, and never to an app or token. A delegate can revoke only what they
issued, so withholding costs them nothing they could act on.
- An app or token must hold manage reach of its own to read the listing
at all; it answers for the ancestors too, which is not what being
handed one file grants. tryListSharesOf turns that into an empty
shares array, so stat itself keeps working.
The share dialog names an unattributable invite rather than rendering a
blank row with a dead revoke button.
Closes PUT-1806.
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.