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
* 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.
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.
- 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.
* 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-ups on #3872: deleteActiveGroup ran even when every
canManagePermission check was denied, deleting the index row while the
team's grant survived — unlisted live access, the class this PR exists
to remove. And memberIdsAmong's join left deleted_at unqualified, which
only works while jct_user_group has no such column; #live now takes an
alias.
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.
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.
Code review on the previous commit confirmed three ways the block filter
inside readUserGroupPerms turned a reversible block into permanent loss:
canManagePermission reads the same rows, so a block-suspended grant
looked revoked to #revokeDownstream's cascade, to unshare's authority
gate, and to invite claiming — deleting re-shares and invites that were
supposed to come back on unblock. The scan is now deliberately blind to
blocks; a block suspends delivery only (listing, count, fan-out,
telling), which the share store filters itself through one shared
notBlockedSql fragment owned by UserBlockStore.
The team-unshare sweep also now covers what it claimed to: a member's
re-share *to another team* is revoked with them (group rows swept in
#revokeDownstream, user path included), and the sweep is skipped
entirely while another issuer's grant still backs the team — members'
re-shares rest on that authority and revoking them would orphan live
access. The blanket block-all note in settings now says team shares
still arrive, matching what the code deliberately does.
A sender a member blocked could still land items in that member's
shared-with-me — and grant them real access — by sharing with a common
team. The group grant is one row and cannot exclude a member, so the
block is now enforced where delivery is derived per member: the
permission scan, the inbound listing and its count, and the fs-event
fan-out all skip team rows whose issuer the member blocked. Nothing is
revoked, so unblocking restores everything. Blocking now also bumps the
blocker's permission cache so it bites immediately. Direct shares keep
their contract: existing ones stand.
#unshareTeam swept re-shares by listing members with a 200 cap and
never following the cursor, so members past the cap kept their orphaned
rows. It now walks the share rows in the subtree — the set that can
actually need sweeping — and filters those issuers to members, which
has no cap by construction.
`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.