* feat: let the account's own API token reach the routes it should
A privileged app is launched with the GUI session token today. Running one on a
full-access personal access token instead costs much less when it leaks, but
several routes those apps depend on refuse every access token, so the swap would
break them.
Admitted, each a read or a write the token's own HTTP reach already covers:
- the realtime socket handshake, which refused access tokens outright; FS
live updates and notifications stop without it
- GET /get-dev-profile and POST /profile, both puter.js surface
- GET /auth/list-permissions — a read of what was granted, not a grant
- GET /share/shared-by-me/apps and GET /share/audit, over grants
/share/shared-by-me already lists for the same credential
- POST /rao, which puter.js calls on every setAuthToken
Gated on `isAccountContext`, not the `full_access` claim alone, so a token with
an app anywhere in its chain is still refused and a socket for one never joins
the user room.
Left session-only: mint, session, 2FA, grant, team and billing, plus two the
list named. /share/blocks is a personal safety control whose only caller is the
desktop's Blocked Senders window. /app-feedback is reachable only from our own
GUI pages, and being unsubmittable programmatically on a user's behalf is the
property it was built with.
The new HTTP suite drives a real server with a minted token and fails on all six
admissions without the change; the block routes' existing metadata check already
pins their refusal.
* test: sort the two token shapes at the events handshake
* fix: carry the account-context invariant into the HTTP gate
`requireUserActorGate` admitted on the raw `full_access` claim while the socket
and /rao use `isAccountContext`, so one shape — a token carrying the claim with
an app in its chain, or an actor built without `makeActor` — would have been
refused in two places and admitted in the third. Unreachable today, since the
mint and the auth-time read both drop the claim when `app_uid` is present, but
it is the invariant this change states and it should hold at every enforcement
point.
The payout address goes back behind a plain session: `/get-dev-profile` is open
to the account's own token now, and the rest of what it answers is a developer's
own name and incentive-program status.
Also drops a cross-reference in EventsService to the socket posture this change
relaxed, and rewords the block-routes comment, which read as if access tokens
were admitted there rather than refused.
* fix: drop a revoked token's sockets, and read the dev-profile columns
Two gaps the review found, both reachable only because this branch lets the
account's own token hold a socket and call /get-dev-profile.
`#revokeAccessTokenTail` soft-revoked the session row and told no one, so a
revoked token kept its connection — and the account's `outer.gui.*` fan with it
— until the five-minute reauth sweep. Sockets now join a per-token room and a
new `auth.access-token.revoked` event drops exactly that room, so the account's
other tabs stay up. Session revoke keeps its account-wide eviction.
`/get-dev-profile` read `first_name`, `last_name`, `paypal` and the two
incentive flags off the user row, which carries none of them: the columns have
always been `dev_`-prefixed, so the endpoint answered nulls to everyone and
`puter.apps.getDeveloperProfile()` has never returned anything. Reads the real
columns, keeping the unprefixed name as a fallback for a deployment carrying
both. The payout address stays behind a plain session, so opening the route to
the account's token hands over a name and incentive status and nothing else.
Both fail without the fix: the socket test waits five seconds on a connection
that should already be gone, and the profile test reads back a seeded row.
* fix: close /open_item to tokens, and stop the node runner leaking sockets
`/open_item` carried `allowFullAccessToken`, but it grants an app a permission
on the user's behalf and mints the app token to go with it — a grant and a mint,
which stay session-only. Pre-existing, and missed by the first sweep here, which
only asked whether routes lacking the flag should gain it and never re-read the
ones that already had it. Refused at the top of the handler too, so a credential
that gets past the gate cannot leave an ACL row behind.
The node runner loads each SDK into a vm context and never closed it, so every
test leaked a socket, and every socket holds a per-(user, origin) connection
slot. Admitting the account's own token to the handshake pushed that past the
cap, and the whole events block failed with `events_connection_failed` while the
browser and workerd runners — which do not leak — passed. Closing the FS socket
and the events channel after each test gives the slots back.
* fix: announce only the revokes that can have a socket to drop
Every access-token revoke broadcast an eviction cluster-wide, including the
read-URL tokens `revokeReadUrl` retires constantly. A scoped token is refused at
the handshake, so none of those broadcasts could ever reach a connection.
The announcement now goes out only for a token the handshake would have
admitted: full access, no app in the chain. `revokeOwnAccessToken` refuses
full-access tokens outright, so that whole path is silent; a revoke by raw uuid
says nothing about the token and still announces, which costs a no-op broadcast
rather than leaving a revoked socket up.
Fails without it: the test revokes a scoped token and a full-access one through
the same entry point and counts the announcements.
* test: pin the mint route against the account's own token
`/auth/create-access-token` carries only `requireAuth`, so it never showed up
in the sweep over `requireUserActor` routes — the refusal lives in
`AuthService.createAccessToken`, which turns away any access-token actor
outright. That holds today, and a leaked full-access token minting itself a
sibling that survives revoking the original is exactly what the token swap is
meant to rule out, so it is worth a test rather than a reading.
* fix: let the recommended-apps test follow the list it tests
Two commits on main rewrote `RECOMMENDED_APP_NAMES` without touching the test,
which seeds `editor` and `camera` and asserts the result contains them. Neither
is on the list any more, so the resolved set came back empty and `test (base)`
has failed on every PR since.
The test now seeds the first two names off the list itself and asserts on those,
so the next edit to it cannot strand the test: prepending two unknown apps keeps
all four cases green.
* feat: let an extension waive a plan gate for one route or driver method
A plan gate about to refuse a caller now emits
`subscription.gate.<surface>` through `emitAndWait` first:
`route.<method>.<path>` for a route, `driver.<iface>.<method>` for a driver
method, `share.anyone` for link shares. The payload carries the actor, the
request and the requirement, and a listener that sets `allow` waives the plan
for that call. Callers the plan already covers never reach the hook, and a
listener that throws leaves the refusal in place.
* feat: mint godmode apps their own full-access token
`/auth/get-user-app-token` answers a desktop launch of a godmode app (the
`godmode` column) with a full-access token instead of an app token. Origin
lookups, the sign-in popups for pages outside the desktop, keep getting
ordinary app tokens, and only a web session may mint one.
The token carries the app in `godmode_app_uid`, read into
`accessToken.godmodeApp` for attribution only: `effectiveApp` stays null, so
it keeps the account's reach and passes the same gates as a personal access
token, including the vendor-compatible AI routes. It is refused once the app
is gone or no longer godmode.
Its row is an `access_token` session parented to the web session that asked,
with `app_uid` set, so signing out, revoking that session or revoking all
sessions takes it along, and the sessions list shows it under the app. Each
mint re-signs onto the same row with a 12h `exp` and moves the row's expiry
out, so the desktop keeps an open app alive while a copy taken earlier still
expires on schedule.
A full-access token may now mint scoped tokens (read URLs), parented to its
own row; it still may not mint full access. Revoking a full-access token
cascades to the tokens it minted.
An access token whose session row has a parent now authenticates only while
that parent is live, so a read URL a godmode app minted lapses with the app's
token, and through it with the desktop session.
* feat: launch godmode apps on their own token and renew it
The desktop asks for a godmode app's token at launch and blocks the launch if
it can't get one, as it does for other apps; a godmode app never runs on the
desktop's session token. While the window is open the desktop re-mints the
token once less than 6h of its 12h remain, checking on a timer and when the
tab becomes visible, and answers the app's `reauth_required` with a fresh one,
posted to the app's origin.
puter.js keeps a godmode token in sessionStorage like a session token, never
in localStorage. In app mode a 401 on a godmode token, background requests
included, asks the desktop for a new token and replays, and a `puter.token`
message is only taken from the embedding frame.
The sessions list shows a godmode token row with its app's title and icon.
* fix: keep a godmode token the same across renewals
Apps hand their launch token to code that never sees a replacement (the
terminal's Drive mount, a CLI's API key), so a token re-signed every renewal
broke those copies once the first one expired. The JWT now carries no `exp`
and a renewal moves only its row's expiry, so every copy keeps working while
the desktop keeps the app alive.
What still bounds it:
- only the desktop's web session can extend it; using the token never does,
and signing out or revoking that session revokes it;
- it lapses 12h after the desktop last asked, and a lapsed row is never
revived: the next launch gets a new token;
- however often it is renewed, a token is replaced after 7 days, and the old
one is revoked on the spot along with the tokens it minted and its sockets.
The desktop no longer re-posts a renewal that kept the same token, so the
app's sockets aren't rebuilt every few hours; an app that reports its token
stopped working is always answered.
* fix: reserve the feedback and no-reply mailbox names again
Signup checks names against `util/reservedUsernames`, but `fbl`, `noreply` and
`no-reply` were added to a second copy of the list in AuthController that
nothing reads any more, so they became claimable. Move them into the live list
and drop the copy.
---------
Co-authored-by: Daniel Salazar <daniel.salazar@puter.com>
* docs(agents): no credits or bug-report mentions in commits and PRs
* fix(ai): chat credit gate ceiling, in-flight holds and disconnects
- Cap output at min(max_tokens, context - prompt). The gate used
max_tokens - prompt, so any prompt longer than a model's output limit
was refused with 402 regardless of balance. Gemini 2.5 Pro's output
limit corrected to 65,536.
- Abort the upstream stream when the caller disconnects (driver calls and
the OpenAI/Anthropic-compatible routes), which ends the credit hold.
- A request refused only because the account's own in-flight requests
hold its balance gets 429 `credits_reserved` instead of 402; puter.js
retries it with exponential backoff (1s to 30s, about 2 minutes).
- When the balance caps a completion and it uses the whole cap, the
response carries `usage_limited` (non-stream `metadata`, stream `usage`
line), which puter.js already turns into the upgrade prompt.
* perf(ai): read the balance and its holds once per credit gate
The gate re-read holds, and the whole balance when any were held, to tell
spent from committed. getUsageHeadroom returns both halves from the same
reads getRemainingUsage already did.
A video job that outlived its poll window, an SDK request that timed out,
or a Veo operation that finished with an error all reached the HTTP error
handler as plain Errors. Each became an unhandled 500 with critical
severity and paged on-call for what is the provider's pace or the
provider's fault.
Video providers now share one poll loop that gives up with a 504
`upstream_timeout`, treats a transient poll failure (timeout, dropped
connection, 408/429/5xx) as a missed poll rather than a failed job, and
stops polling with a 400 `client_aborted` when the caller disconnects, so
nothing is metered for a clip nobody will receive. The driver controller
exposes the disconnect as an `abortSignal` on the request context. The
window is ten minutes for every provider; Together and BytePlus move up
from five.
Failed jobs are classified: content-filter refusals become a 400
`bad_request` with `errorCode: moderation_flagged`, rejected parameters a
400 `upstream_bad_request`, and anything else a 502 `upstream_failed`,
each carrying the provider's own code. Veo's filtered output keeps
`disallowed_value` and gains the same `errorCode`. The sanitizer and
content-filter pattern move from the Replicate provider into a shared
util so image and video agree.
Status-less SDK connection timeouts are translated to a 504
`upstream_timeout` at the driver boundary, and the chat driver records
them per attempt so an all-timeout chain is a 504 and a mixed chain is
`upstream_failed` instead of an `internal_error` 500. The Together chat
client gets the same ten-minute request timeout as the other providers.
The OpenAI video provider is left alone beyond an import path: its API is
scheduled to shut down on 2026-09-24.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
* feat: add BytePlus ModelArk chat provider
Adds BytePlus ModelArk as a provider for the puter-chat-completion
driver, following the MiniMax/ZAI providers as reference per
doc/contributing-apis.md.
- OpenAI-compatible endpoint at ark.ap-southeast.bytepluses.com/api/v3
(apiBaseUrl config selects the region)
- Static catalog of 16 chat models (Seed 2.x/1.x incl. vision, GLM,
DeepSeek, GPT-OSS) with limits and per-token pricing from the
official docs
- Passes Ark's thinking/response_format/stop params through custom;
normalizes reasoning_content to reasoning
- Bare deepseek-v4-* names stay with the first-party DeepSeek
provider; BytePlus only claims prefixed aliases
- Offline unit tests (mocked SDK against a real test server) plus an
env-gated integration test
* feat: add BytePlus image and video providers
Extends the BytePlus ModelArk integration to the puter-image-generation
and puter-video-generation drivers, reusing the same services.byteplus
API key and regional apiBaseUrl as the chat provider.
Image (Seedream/SeedEdit via OpenAI-compatible /images/generations):
- dola-seedream-5-0-pro (pixel-tier pricing + billed input images from
the 2nd on), seedream-5-0-lite, 4-5, 4-0, and seededit-3-0-i2i
- quality tiers 1K/1.5K/2K; aspect ratios resolve to Ark's documented
pixel sizes; explicit WxH passes through with Ark's bounds enforced
Video (Seedance via Ark's async /contents/generations/tasks + polling):
- Seedance 2.0 / 2.0 Fast / 2.0 Mini / 1.5 Pro / 1.0 Pro / 1.0 Pro Fast
(2.5 is priced but its API isn't live yet, so it's excluded)
- per-video-token billing from usage.completion_tokens, with per-second
estimates feeding the credit cap; audio vs silent rates for 1.5 Pro
- first/last frame and reference-image inputs; generate_audio param
added to IGenerateVideoParams
Pricing and capabilities hardcoded from the official docs (ModelArk
pages 1544106, 1330310, 1520757, 1521309, 1541523). Offline unit tests
mock the SDK / global fetch; integration tests are env-gated on
PUTER_TEST_AI_BYTEPLUS_API_KEY.
* fix: correct BytePlus catalogs and validation against the live API
Verified the three BytePlus providers against ModelArk with a real key;
these are the mismatches that surfaced.
- Drop seededit-3-0-i2i-250628. Ark reports it as Shutdown and every
request 404s. Its now-unreachable image-to-image branches in the
provider go with it.
- seedream-4-5 and the 5.0 series enforce a 3,686,400 pixel minimum, so
they only accept the 2K tier. Mark them 2k-only and snap an
unsupported tier up to the nearest allowed one, which also keeps the
aspect-ratio table from mapping to a sub-minimum size.
- glm-4-7 has a 204,800 token context, not 256K.
- Guard the actor in the image provider like the video provider does.
- Round a sub-minimum video duration up to the shortest supported clip
instead of reporting it as insufficient funds.
- Gate video resolution on the model's own dimensions; the dims table is
shared across a family and accepts more than any one model does.
* Tighten BytePlus AI provider handling
Extract shared reasoning-content normalization for OpenAI-style chat providers, and harden BytePlus image/video behavior. This updates image tier and size validation, normalizes aspect ratios and input image refs, prevents mismatched BytePlus key/base URL fallback config, makes video resolution matching case-insensitive, and rejects excess reference images instead of silently truncating them. Tests were expanded to cover the new BytePlus request and validation paths.
- declare rate + concurrency limits on every route and driver that lacked one
- add acquireConcurrent for websocket connections and the DAV mount
- bucket AI models by identity key only; keep resold duplicates of any vendor
- skip recently-failed provider routes; cap the fallback chain at 3 attempts
- let full-access access tokens bind a worker to an app their own user owns
- cache resolved subscriptions so tiered limits don't add a round trip
- decrease ai related alerts and make errors not 500
- decreased bad fs controller checks to be 404s instead of 500s
- force bucket regions for now
- validate webdav perms for locking/unlocking