sse.ts and testUtil.ts keep both sides. The route concurrency gate keeps its CallSlot, which already holds the response's single close listener, and CallSlot imports that helper from its new folder.
A deployment can list origins of its own pages in
`first_party_session_origins`. When such a page asks the GUI to sign it in,
the sign-in hands it a GUI token on a `web` session of its own, parented to
the user's sign-in, instead of an app token.
The session is minted by POST /auth/first-party-session, which only the GUI's
own page script may call, and only as the user's sign-in itself: app tokens,
access tokens and a session minted this way are refused. It never gets a
session cookie, ends with the sign-in it was handed out under (revoking either
takes the tokens minted under the page along), is not counted as a sign-in,
and stays up with its sign-in when all other sessions are revoked.
The sessions list shows it under its sign-in, titled with the page's host and
badged as a site session.
A tab the desktop opened for an app boots the SDK as a top-level page, where
a session-type token was kept in memory only. Once the desktop hands such a
tab its launch token, the SDK now keeps it in sessionStorage, as a framed app
does, and a reload of that tab reads it back before any stored app token.
A page that wasn't handed a token this way still persists no session token.
With trust proxy on, express derives req.hostname and req.subdomains from
X-Forwarded-Host, which any client can send, while host validation reads the
raw Host. A request could serve one hosted site's files on the main domain,
or reach the API on a hosting host. The header is now dropped before routing.
Fixes PUT-2272
Co-authored-by: Claude Opus 5.5 <[email protected]>
Adds a `debug_app` session kind and AuthService.createDebugAppToken, which
mints an app-under-user token on its own session row instead of the user's
app session. The row has a hard expiry that activity never slides, its calls
record no user activity, it is hidden from the user's session list, and a
password reset revokes it. SessionStore.deleteExpiredDebugAppSessions purges
expired rows. Migrations add the kind to the sessions constraint on sqlite,
mysql and postgres.
`tlsFingerprintHeader` names a request header that a TLS-terminating proxy
sets to the client's TLS fingerprint. When set, the value is exposed as
`req.tlsFingerprint`, stored on the new `user.signup_tls_fingerprint`
column, passed to `puter.signup.validate`, and forwarded to the phone
verification provider when it is a JA4.
* fix(webdav): GET pipes the file without stacking close listeners on the response
stream.pipeline put seven close listeners on the response. With metering,
compression, the concurrency gate and request tracing on top, every WebDAV
download crossed Node's ten-listener warning. GET now uses pipeToResponse,
like the other file download routes.
* fix(drivers): a streamed result stops its source on unpipe instead of its own close listener
pipe already unpipes when the response finishes, closes or fails, so the
extra close listener only spent part of the response's listener budget. The
routes that hold a response open now have a test that keeps each within that
budget.
* refactor(http): everything that waits on a response's close shares one listener
Egress metering, the concurrency gate, the large-body budget, route
lifecycle events, driver call slots, disconnect aborts and SSE writers each
put their own close listener on the response. They now register callbacks
with onResponseClose, which keeps one listener per response and runs them in
the order added, so a streamed response holds three close listeners instead
of five or six and request tracing has room for its own.
* test(webdav): handle streamed response errors in test sink
Co-authored-by: Salazareo <[email protected]>
* test(webdav): a storage failure mid-download is covered against a real response
---------
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: Salazareo <[email protected]>
The catalog listed `mistral-large-4` with a 1,000,000-token output
limit, equal to its context window. Mistral rejects a request for more
than 262,144 completion tokens, so a request that left `max_tokens`
unset, or set it higher, failed with a 400 whenever the account's
credit covered more than 262,144 output tokens.
- `max_tokens` for `mistral-large-4` is 262,144. The context window
stays at 1,000,000 tokens.
- A test sends `mistral-large-4` and `mistral-large-latest` through the
chat driver with ample credit and checks the cap that reaches
Mistral.
The `reasoning_effort` row in chat.md listed the models that read the
option. Model-specific details belong under Model-specific features,
which already gives each family's accepted values.
- The row no longer names models.
- The Gemini and Moonshot Kimi sections say that their other models
ignore the option.
Google released `gemini-4-hydrogen` on 2026-11-12. It uses the same
native request shape as Carbon and Argon.
- Hydrogen is served natively at $1.50 input, $7.50 output and $0.075
cached input per million tokens, with fast service at 1.8 times
those rates. It accepts `low`, `medium`, `high` and `xhigh` reasoning
effort, ignores temperature, and takes text and images.
- A Carbon response that isn't streamed is capped at 32,768 output
tokens, as Argon's already is, and the credit hold is sized to that
cap. Google reports this limit for both models. The cap is now a
field of the native model options instead of a check on Argon's id.
- chat.md and rate-limits-and-quotas.md list Hydrogen's reasoning
levels and limits and Carbon's unary cap.
Mistral made Large 4 generally available on 2026-11-05, moved
`mistral-large-latest` to it, and deprecated Large 3 and Medium 3.5,
which both retire on 2027-04-30.
- `mistral-large-latest`, `mistral-large` and
`mistralai/mistral-large-latest` resolve to `mistral-large-4`. They
resolved to `mistral-large-2512`, which stays callable by its own id
until Mistral retires it.
- Large 4 uses Mistral's current rates: $0.70 input, $0.07 cached input
and $2.10 output per million tokens, in place of the preview rates.
It is marked open-weight.
- The Large 3 and Medium 3.5 entries carry their retirement date.
Kling 4.0 and 4.0 Flash take 26,000 characters of English but 11,000
of other languages, with mixed text sharing the limit in proportion.
The video page and the quotas page said 26,000 for every prompt.
- txt2vid.md and rate-limits-and-quotas.md give both limits, the rule
for mixed text, and the error each one returns.
- Puter still rejects only a prompt over 26,000 characters. Kling
enforces the lower limit, and its refusal reaches the caller as
`upstream_bad_request` with nothing charged. A test covers that path.
A chat completion keeps running for up to 10 minutes after the caller
hangs up, so the upstream can report usage. It now counts against the
caller's concurrency cap for as long as the upstream runs, instead of
until the response closes.
- The chat driver registers its upstream run with the request's
concurrency slot, so `/drivers/call` keeps the slot until the
upstream is done. The cleanup after it does not hold the slot.
- The route concurrency gate now uses the same CallSlot as
`/drivers/call`: the slot is held until the response is done and the
work the handler registered has settled. The OpenAI- and
Anthropic-compatible chat routes register the completion, so a
non-streaming call counts until the upstream answers too.
- CallSlot moves to core/http, since the route gate below the
controllers uses it. The context key is renamed keepConcurrencySlot.
- rate-limits-and-quotas.md says a completion counts until the model
finishes it, even after a disconnect.
The default owner line read `- @Salazareo`, which only matches a file
named `-`, so files without a specific owner had none. Three patterns
had their `*` escaped, which GitHub rejects as invalid, so their lines
assigned no owner.
Two seeded suites send malformed input through the real drivers and
providers. The vendors are played at the HTTP boundary, so each
provider's request and response code runs.
- ChatCompletionDriver.fuzz.test.ts covers the 17 chat providers that
have a static catalog. Requests with a few values changed must
resolve or reject below 500 without marking a route unhealthy.
Valid requests against wrong statuses, bodies of the wrong shape and
broken event streams must end with an answer or a classified error.
A stream only has to end; the error line it carries is not checked.
- mediaDrivers.fuzz.test.ts covers the image, video, speech and OCR
drivers, called as a user with unlimited metering so a request gets
past file input and the credit hold. Every vendor refuses the
request, and every call must end with a classified error.
shared/fuzzTestUtil.ts holds the seeded random source, the hostile JSON
values, the mutation, and the stand-in for fetch, undici and axios. An
SDK that calls https.request itself fails the suite. PUTER_FUZZ_CASES
raises the case count for a longer run. doc/ai-drivers.md describes the
suites.
A value that used to end in an unclassified error, in a request or in
an upstream answer, now gets a classified one before provider code runs
on it. A value a driver or provider ignored or refused before is
handled as it was.
Every driver:
- shared/scalarOptions.ts answers 400 for a value that cannot be
converted to text, such as { "toString": null }, in the options a
driver reads as text, a number or a flag. The chat, video, text to
speech, speech to text, voice changer and OCR drivers call it first.
- An unknown provider name that is not text gets the same 400 as any
other unknown name.
Chat requests:
- A falsy `tools` means no tools on every provider.
- A tool call id or a content block type that cannot be converted to
text is a 400.
- A tool result that is an object is sent as its JSON, like the content
of a `tool` message.
- `include` that is not an array is a 400.
- `anthropicBetas` that is not a list of names is dropped.
- Gemini's native route answers 400 when no user or assistant message
is left to send.
- The prompt estimate reads a compaction block whose content is not a
string.
Image, video, speech and OCR requests:
- The image driver checks `input_image_mime_type` with `quality` and
`resolution`.
- The video driver answers 400 for a model that is not a string.
- The voice changer answers 400 for an `output_format` that is not a
string.
- Mistral OCR answers 400 when the SDK refuses the options before
sending them, as Mistral chat does.
Upstream answers to chat:
- A choice without a message is the existing 400 bad_response, which
still falls back.
- A usage count that is not a number is reported as zero in the cost
event, as pricing already skips it.
Some community models check the generated image and fail the
prediction with "Try running it again" for a share of seeds, whatever
the prompt. adirik/realvisxl-v3.0-turbo failed 5 of 24 runs of "a small
red cube on a white table" that way.
A prediction that fails with that message is now run again, up to two
more times, inside the same deadline and credit hold. A request that
pins a seed is not rerun, because the same seed is rejected again.
Only the prediction that succeeds is metered, as before.
Every image provider implements IImageProvider directly; nothing extended
or imported the abstract class in interfaces/image/ImageProvider.ts.
The guide's import rules now say that only an interface's driver file
imports providers, which holds since the tool helpers stopped importing
the Claude policy.
FunctionCalling.js and compaction.js each held Puter-facing parsing next
to OpenAI and Anthropic request shapes. Each dialect now owns its mapping:
interfaces/chat/utils normalize_tools_object, readCompaction and the
other dialect-neutral checks
protocols/openaiCompatible/openaiParams.ts
toolsToWire (was make_openai_tools) and
toOpenAiContextManagement
providers/claude/chat/contextManagement.ts
toAnthropicContextManagement
toolsToWire takes the dialect as its second argument, like the other
mappers in that module, and takes an array; every caller already guarded
the empty case.
make_claude_tools is removed. Nothing but its tests called it: the Claude
provider uses claudeToolPolicy directly. The three cases only those tests
had are added to the claudeToolPolicy tests. The chat interface helpers no
longer import from providers/claude.
drivers/integrationTestUtil.ts held helpers for AI provider tests, but a
store test and the realtime relay test imported from it too.
drivers/ai/shared/integrationTestUtil.ts the provider test helpers
testEnv.ts optionalEnv and skipUnlessEnv,
for any env-gated suite
testUtil.ts withAiCostFactorHook, next to
the server it hooks
No test under services/ or stores/ imports from the driver layer's AI
code now. The typecheck baseline entry for the moved file is renamed, not
regenerated.
shared/aiUserIdentifier.ts re-exported upstreamUserIdentifier under a
second name, and only its own test imported it. The alias is gone; its
tests, which cover truncation and actor shapes the other file didn't,
replace the four cases in upstreamIdentifier.test.ts.
OpenAIRealtimeRelayService is a service, and it imported the cost factor,
the provider key reader and a transcription price from drivers/ai. Those
now live in src/backend/util, which both layers can import:
util/aiCostFactor.ts withAiCostFactor, withAiCreditHold and the
request cost helpers, unchanged
util/aiProviderKey.ts readProviderKey, cut from providerRegistry.ts
util/aiPrices.ts the gpt-transcribe per-second price, read by the
OpenAI speech-to-text cost table and the relay
withStreamedAiCharge returns a DriverStreamResult, so it stays in the
driver layer as drivers/ai/shared/streamedAiCharge.ts with its tests.
MeteringService is untouched. No prices or billing behavior change.
VoiceChangerDriver built the ElevenLabs request, measured the converted
audio and metered it, and imported the output formats from the ElevenLabs
text-to-speech provider. That code is now in providers/elevenlabs:
outputFormats.ts formats both speech interfaces use
speechToSpeech/ElevenLabsSpeechToSpeechProvider.ts
speechToSpeech/measuredOutput.ts WAV, Opus and byte-rate timing
speechToSpeech/costs.ts
The driver keeps provider resolution, test mode and the auth check. With
one provider it holds the class directly; there is no provider interface.
No behavior change: same validation order, error messages, usage keys,
holds and charges.
OCRDriver held both vendors' clients, request building, response
normalizing and metering. That now lives in TextractOcrProvider
(providers/awsTextract/ocr) and MistralOcrProvider (providers/mistral/ocr),
both implementing IOcrProvider. The driver keeps the provider catalog,
model resolution, auth and credit gates, and input resolution.
The page estimate, document detection and credit hold both providers use
are in interfaces/ocr/common.ts.
No behavior change: same validation order, error messages, usage keys,
holds and charges.
Chat providers and the Cloudflare and xAI image catalogs all parse vendor
prices with catalogPrice, so it lives in shared/ instead of the chat
interface's pricing helpers. The image catalogs no longer import from
the chat interface.
doc/ai-drivers.md explains what interfaces, providers, protocols and
shared each own, the import rules between them, where a new file goes,
and the steps for adding a provider or an AI interface. It also lists
the three places that don't follow the layout yet.
doc/architecture.md and AGENTS.md link to it.
The seven drivers/ai-* folders and the AI-only helpers in drivers/util
become one tree:
drivers/ai/interfaces/<interface>/ the Puter-facing drivers
drivers/ai/providers/<vendor>/<interface>/ vendor adapters, catalogs, tests
drivers/ai/protocols/openaiCompatible/ the shared OpenAI wire protocol
drivers/ai/shared/ helpers several interfaces use
Files are moved and their relative imports rewritten; no implementation
changes. Driver names, aliases, interfaces, config keys, model ids and
usage keys are unchanged.
The chat catalog registration test scanned providers/*/ next to itself.
It now scans providers/*/chat/ and fails if the scan finds no catalog, so
a wrong directory can't pass it.
General helpers that non-AI code also uses stay in drivers/util: file
input, data URIs, upstream errors, stream piping, CRUD limits.
* fix(fs): a healed entry carries its written path, and an entry with no path answers 404
The lineage heal now returns the entry with the path it just wrote
instead of re-reading the row, so a replica that hasn't caught up can't
hand back the old NULL path. Entries that still have no path are kept
out of the cache, resolve to the usual 404 in the fs routes, and an ACL
check on a resource with no path refuses instead of throwing.
* fix(fs): heal paths along one parent chain, fill only empty paths, and return the healed row from the primary
The path lineage now picks the target row and walks up its own
parent_uid links, so each row in a healed path is the parent of the
next. The backfill only fills a NULL path and never replaces one that
is set. Both lineage routes return the row read back from the primary,
so every column is current. Path-less entries are kept out of the
cache by the cache writers themselves, and a missing or empty path is
unresolvable both in resolveOrNotFound and in ACLService.check.
* fix(fs): a path lineage resolves only to a row still at that path, and the backfill runs its updates concurrently
The lineage lookup now answers a miss when the row read back from the
primary sits at a different path than the one walked, and doesn't cache
it. The backfill runs its per-row updates through
runWithConcurrencyLimit and drops cached entries only for rows it
actually filled. ACLService.check refuses only a resource with no
string path, so an empty client path keeps its existing 400.
* fix(fs): build healed paths from the primary's ancestor chain, and invalidate the paths the heal wrote
Both lineage routes now read the ancestor chain from the primary and
write the path it spells, so a lagging replica's names never end up in
a path column. A path lookup whose replica walk no longer matches the
primary's chain is a miss and writes nothing. The backfill drops the
cached entries of the rows it filled in one call, keyed by the path it
wrote. The lazy-chain comparison now lets a missing path reach
ACLService.check on both sides.
* fix(fs): leave path-less uid and id lookups to resolveNode
resolveNode already answers a uid or id lookup whose entry has no path
as not found, and a path lookup always returns an entry with its path,
so resolveOrNotFound goes back to trusting what it resolves. The
readdir case for a broken parent chain is covered by the existing HTTP
test.
* fix(fs): the ancestor walk stops at a fixed depth and updates only rows with an empty path
The primary's ancestor walk now selects each row's path and hands only
the rows whose path is NULL to the backfill, so filled ancestors cost no
UPDATE. The walk stops after LINEAGE_MAX_DEPTH steps; a chain that has
not reached a row without a parent by then is treated as broken and the
row is left alone.
* fix(fs): a listing heals its path-less entries a bounded number at a time
* test(fs): a stored path the parent chain no longer spells heals from the primary, and a listing leaves a path-less entry uncached
---------
Co-authored-by: Daniel Salazar <[email protected]>
* fix(auth): a just-minted read URL or token is found while the replica catches up
* fix(auth): a token's grants aren't cached when it's revoked mid-mint
* fix(permissions): a mint succeeds when its grants cannot be read back to cache
The grants are already stored at that point, so a failed read-back drops
the cache key and the next read fills it.
* test(auth): assert the grant list cached at mint; skip the sqlite-copy replica test on postgres
* fix(auth): test that a token row is read from the primary only when both replica lookups miss
* fix(auth): one-line comments on the replica-then-primary token row lookup
* perf(auth): a mint caches the grants it stored without reading them back
---------
Co-authored-by: Daniel Salazar <[email protected]>
* fix(share): look up a cross-owner move's origin by its former owner
The move listener now passes the event's fromUserId down to the origin
side of the fan-out, so the shares at the former location are looked up
under the owner they belong to, and re-marks the new owner as shared
before the fan-out runs instead of after it starts.
Neither changes what recipients hear today: the origin's ancestors
already carry the former owner, and the fan-out reads ancestors before
it consults the owner-shares cache. Both are needed once the fan-out
checks the entry's owner before reading ancestors. Tests cover a real
cross-owner move with a cold owner-shares cache, and a hand-over to an
owner with nothing shared.
* perf(share): skip the ancestor read when the entry's owner shares nothing
Every fs write, create, rename and move resolved the entry's ancestors
(a cache batch) before asking whether any owner on that chain had
anything shared. Ask about the entry's owner first, plus the former
owner for the origin side of a cross-owner move, and return no audience
without reading ancestors when none of them has a share. Otherwise the
lookup runs as before.
Self-contained so it can be reverted on its own. It assumes every entry
in a shared subtree carries the subtree owner's user id. Entries that
older writers created under someone else's shared folder with the
writer's own id would stop notifying that folder's recipients when the
writer has nothing shared, so this only holds once existing data has no
such mixed-owner subtrees.
* fix(share): read ancestors whenever an entry sits outside its owner's home
The early return in the share fan-out assumed every entry in a subtree
carries the subtree owner's id. Rows from older writers don't always:
an app writing into another user's AppData stamped itself as the owner,
so an entry in a shared folder can name an owner with nothing shared.
Skip the ancestor read only when the entry's owner has nothing shared
and the path sits in that owner's own home directory; anything under
another user's home takes the full lookup as before, whatever the data
holds. The owner's username comes from a per-process map (60s TTL,
10k entries) backed by the user store's cache, so a hit costs nothing
and a miss one user cache read, against the ancestor batch it replaces.
* refactor(share): drop the former-owner hint from the move fan-out
With the home-directory check, the origin side of a cross-owner move
sits under the former owner's home, not the entry's new owner's, so it
always takes the full ancestor lookup and finds the former owner's
shares through the chain. Passing fromUserId down and re-marking the new
owner before the fan-out no longer change anything, so both go back to
how they were. The test for a hand-over to an owner with nothing shared
stays; the slower duplicate that waited out the cache is removed.
* perf(share): keep the chain owner check after the ancestor read
The early return replaced the check that ran after the ancestor read,
so an entry outside its owner's home went on to the share-table lookup
even when nobody on its chain had anything shared. Ask about every owner
on the chain again once the ancestors are in hand, as before.
* fix(share): mark the new owner shared before a hand-over's fan-out
The fan-out now reads the owner-shares cache before it reads ancestors.
On a move into another user's home, a "nothing shared" answer cached
for the new owner shortly before the move made the destination side
skip the entry's re-pointed share, so its recipient was told the item
was removed rather than moved. Mark the new owner shared before the
fan-out starts whenever the move changed the owner.
The earlier "drop the former-owner hint" commit said this ordering no
longer mattered; it does for the destination side.
* perf(share): cache missing usernames and share in-flight lookups
The home check now uses isOwnHomePath with the owner's username. The
per-process username map also keeps a null answer for an owner with no
account or no username, and a burst of writes by one owner shares a
single user lookup, as the owner-shares lookups already do. A failed
lookup is not cached and reads as someone else's home, so the full
ancestor lookup runs. Tests cover an owner with no account, a home that
differs only in case and a renamed owner, each taking the full lookup.
* test(share): run the hand-over's revoke for real and assert the destination read
* perf(share): check the home path before asking whether the owner shares
An entry outside its owner's home goes straight to the full read, which asks about every owner on the chain together. The username cache is read once per call and the single-caller home helper is inlined.
---------
Co-authored-by: Daniel Salazar <[email protected]>
* ci: take release-please from its current home
google-github-actions/release-please-action is archived: the action
moved to googleapis/release-please-action, same code, same inputs,
same major version, and that is where the fixes now land. The
release-please workflow references the maintained repository.
* ci: declare what the three unscoped workflows may do with the token
Docs Build, Maintain Release Merge PR and Notify HeyPuter had no
permissions block, so they ran with the repository's default token
scope. Docs Build only checks out and builds, Notify HeyPuter only
calls the dispatch endpoint with its own token: both declare
contents: read. Maintain Release Merge PR pushes the
auto-update/release branch and opens the release pull request, so it
declares contents: write and pull-requests: write. The other
workflows already declared their scopes and are left as they are.
* ci: stop persisting the checkout credentials in the jobs that never push
actions/checkout keeps the job token in the clone's git config, where
every later step can read it. Twelve checkouts now set
persist-credentials: false: the backend and puter.js test jobs, the
coverage jobs, the type checks, the docs build, the AI provider tests
and the Docker image build read the clone and push nothing with git.
The two checkouts of Maintain Release Merge PR keep the token: that
job resets the release branch and pushes it through
create-pull-request.
* ci: lock the workflow actions to the commits they resolve to
GitHub's workflow dependency lockfile holds the commit each action
reference resolves to, transitive actions included, and the runner uses
those commits even if a tag moves later. .github/workflows/actions.lock
comes from gh actions-lock (v0.4.1), which also narrowed every reference
to the exact release it locked, so Dependabot keeps bumping versions and
its security alerts keep matching. Re-run the same command after adding
or bumping an action.
* ci: drop the unused token scope from Notify HeyPuter, match workflow indentation
* ci: remove the disabled release workflows
release-please and Maintain Release Merge PR have been disabled since 2024; releases are cut from calver tags. Their entries leave the lockfile with them.
---------
Co-authored-by: Daniel Salazar <[email protected]>
* fix(fs): /token-read serves a file with no known extension as application/octet-stream
* fix(kv): a value too large once stored rejects with value_too_large, and a consistent read of several keys is consistent
* fix(kv): remove on an account with no credits left removes the paths and returns null
* fix(metering): a buffered increment whose base read fails is not also written through
* fix(hosting): site lookups by name share one cache entry for every spelling
* fix(metering): an increment whose base read fails is written through and taken back out of the buffer
* fix(hosting): create stores a site name trimmed and lowercased
* feat(email): sends name a stream, and streams add headers, a from address and message tags from config
`email.streams` maps a stream name to optional headers, a from address and a
tags header. A send that names a configured stream gets that stream's
headers (caller headers win) and from address (when the caller gives none),
and its tags rendered as `name=value` pairs into the tags header. Unknown or
missing streams change nothing, and `streams` never reaches the transport.
Account mail is sent on `auth`, share and app feedback mail on `notify`, and
the signup confirmation on `signup` unless a `puter.signup.validate` listener
names another stream, which is remembered in the account metadata for
resends, deferred sends and save-account.
The signup response carries `confirmation_email_sent` when a confirmation
email was due at signup, and `/send-confirm-email` answers `{ sent }`.
PUT-2346
* fix(email): review fixes for stream naming
* fix(email): contact form mail names the notify stream, streams resolve from own config entries, the no-transport log shows the stream
Templated sends take only replyTo and stream; the header and tag options had no caller.
* fix(signup): a claimed row drops its earlier confirmation stream, and save-account reports a failed send under its own stage
* perf(notifications): notify returns the row it wrote instead of reading it back
The service hands the driver the row the store built on insert, and a
group replacement builds the row it leaves from the one it looked up, so
neither path reads the row back from the primary.
* perf(notifications): mark-ack reads the row's fixed columns from a replica
The type, app uid and audience the dismissal event carries never change,
so they are read from a replica alongside the update, and from the
primary only when the replica has no row yet.
* test(notifications): cover a repeat dismissal and tidy notify docs
* fix(notifications): a repeat mark-ack returns without waiting for the replica read, and a failed replica read is logged
* refactor(notifications): NotificationRow spells out the row's columns, and a replacement passes the app uid it already has
* fix(permissions): an app opened on a file in a shared folder can read and save it
* perf(permissions): app scans check implied options before scanning the user
* test(permissions): cover app grants on folders and revoked shares
* fix(permissions): an app's folder grant counts only while its user holds that folder
The app branch of the ACL check counted a user-to-app grant on any folder above the file. It now counts a grant on the file itself, or on a folder above it that the user holds at the asked mode, so the grant and the user's own access meet on the same node.
* test(fs): the other-app case pins 404 beside the opened app's 200
* fix(auth): sign-in relays a token set between waits
* fix(auth): sign-in relay skips another app's stored token and survives a failed store
* fix(auth): sign-in relay always clears the stored copy and logs only the error message
* fix(auth): the sign-in relay keeps only signed app tokens, per app, and a subscribed wait answers without Redis
/login/set stores a token only when this server signed it for an app, under a
key that includes the app, so a later set for another app or an unsigned value
leaves a pending token in place. /login/wait takes the stored copy once and
falls back to listening when Redis cannot be read. A token that arrives by
event is delivered to every subscribed wait for that app; the stored copy is
then removed best-effort, and left in place when the wait's client has gone.
A relayed token for another app no longer ends the wait. The event payload is
back to { authtoken }.
* chore(events): event types file matches main again
* feat(email): notification emails ask listeners before each recipient
Share notification emails (account holders and invites) and the app
feedback email to the owner now emit `email.notify.validate` with
`{ to, kind, allow }` once per recipient address, next to the existing
unsubscribe and blocked-domain checks. A listener that sets `allow` to
false skips that recipient's email; the others are sent as before. The
invite check runs before its send budget is claimed and the feedback
check before the per-app cap slot, so a skipped address uses neither.
When no listener answers or the hook fails, the email is sent.
* refactor(email): type the notify kind as a template name, drop the dead catch
`emitAndWait` already catches a listener that throws, so the try/catch
around it never ran; a failed listener leaves `allow` true and the email
is sent. `kind` is now `EmailTemplateName`, so renaming a template breaks
the type check instead of the listener.
* fix(share): ask email.notify.validate when the digest is sent
A digest entry can wait out its window or a retried send, so the
listener is asked about the recipient right before the mail goes.
Invites are still asked once more before their budget is claimed.
* fix(email): any falsy allow on email.notify.validate skips the mail
Matches how email.validate reads its answer. The helper also takes the
event client as required, since every caller has one.
* feat(email): email.notify.validate carries every recipient of one kind of mail
The payload is { kind, recipients: [{ to, allow }] }. Share invites ask
once for all their addresses before any budget is claimed; the digest
send and the feedback mail ask with one entry. kind is narrowed to the
three templates that ask.
* fix(gui): desktop push status counts only Puter's devices and follows subscription changes
- fix(gui): desktop push status counts only Puter's own devices
- fix(gui): the desktop keeps its push subscription when the browser replaces it
- docs(gui): shorten push comments to one line
- fix(gui): removed browsers stay off after a subscription change, and websites can reach Puter push
- docs(puter-js): fit the desktop push request comment on one line
- fix(gui): background push registration checks every earlier endpoint and waits out a 429
- fix(puter-js): inside the desktop, requestPermission leaves a held push grant to the desktop
- fix(gui): a browser that stays off after being removed forgets its receipt
- fix(gui): push status holds right after enable(), and a subscription change is picked up after a run in flight
- fix(gui): an app's push grant counts whenever the app list can't be read
* fix(puter.js): a held push grant is left to the desktop only when it says it answers push
The desktop lists `push-permission` in `puter.gui_features` on the app
iframe's URL. Without it the SDK settles a held `notifications:push` from
its own check, so a desktop whose requestPermission handler has no held
check shows no dialog for a grant the app already holds.
* fix(gui): a held push grant isn't asked again beside other access, and never counts without an app uid
requestPermission drops a push grant held for Puter's devices from the
dialog and asks only for what is missing. The app list lookup answers no
for a missing app uid, whatever the list says; an app the list leaves out
still keeps its grant, since the list is capped and can trail a grant.
* test(puter.js): e2e for a push grant the app already holds inside the desktop
A held grant resolves true with no dialog, and a request for it beside
access the app is missing shows only the missing access.
---------
Co-authored-by: Daniel Salazar <[email protected]>
* fix(gui): a browser removed from push stays off until its user turns it on
The silent registration on load and app launch no longer replaces a
subscription the user removed in Puter. When the server answers
push_device_removed, the browser's subscription and its receipt are
dropped and the browser is recorded as turned off, so later loads leave
it alone. Turning push on from Settings or a push dialog still replaces
the removed subscription.
* perf(gui): launches reuse the page's last push status
The built-in push client keeps the last registered status it read in
memory, for the same user and subscription. App launches and an app's
puter.notifications.status() answer from it instead of listing devices
again. It is dropped on enable(), disable(), an unregistered answer and
the worker's subscription-changed message, and the Notifications tab
and push dialogs still read fresh, so a new page load always asks the
server.
* fix(notifications): a background enable opens no sign-in, and a page keeps its last push on for five minutes
_enableWebPush takes { background } in place of { renewRemoved }: a call
without a click leaves a removed browser removed and sends its requests
without interactive sign-in.
The desktop's push client keeps the on that enable() read, so the next
cached status lists no devices, and asks the server again once that on is
five minutes old.
* fix(gui): the kept push on belongs to the user whose devices were listed, and a failed enable() keeps none
A status read or enable() that another user's sign-in overlapped keeps no
on, and an enable() that fails drops what a read begun meanwhile kept.
* fix(puter-js): a confirmed push subscription registers for another account without a new confirmation
The server's verify and active subscribe answers can carry a receipt for
the subscription. It is kept per worker scope and sent with the next
announce of the same endpoint, so the Puter desktop registers the same
browser for the next signed-in account without another confirmation push.
The receipt is forgotten when the subscription is ended, replaced or
removed, and when the app is turned off in Puter. A server without
receipts behaves as before.
* docs(puter-js): shorten a push helper comment
* fix(notifications): keep receipts only in the Puter GUI and store the new one as soon as the server answers
* fix(fs): overwrite a file in place when a write spells its name in another case or normalization form
* docs(fs): say a write needs overwrite off to get a new name beside a look-alike one
* perf(share): skip the legacy owner probe once a recorded share answers
ownerHasShares ran its recorded-owner and legacy NULL-owner queries side
by side, so the join to fsentries ran even when the first query already
found a share. Run the legacy half only when the recorded half finds
nothing; the answers stay the same.
* perf(share): drop the NULL-owner fallbacks from owner and outbound reads
ownerHasShares, listOutbound, countOutbound, the outbound apps summary
and the team-row filter behind listByHolder/countByHolder each carried a
second half for share rows written before entry_owner_user_id existed,
matched by joining fsentries on a NULL owner. The column migration
backfilled those rows from fsentries, so the joins only add work: each
costs about min(owner's files, NULL-owner rows) per call.
Every remaining read goes through entry_owner_user_id alone. Invite rows
(no fsentry, no owner) are matched as before: they were never reachable
through the joins.
Self-contained so it can be reverted on its own. It assumes existing
data has no share row that names an fsentry but has a NULL
entry_owner_user_id; if any remain, they stop showing up in these reads.
* fix(share): backfill the entry owner on share rows written without it
The share reads now match on entry_owner_user_id alone. Add a migration
for each engine that fills that column from fsentries on every row that
names an entry but still has a NULL owner, so the backfill runs before
the reads stop looking at NULL-owner rows and their answers don't depend
on what existing data holds. Rows that name no entry (invites) keep a
NULL owner, and a replay finds nothing to fill.
* fix(share): record the entry owner when a claim names its entry
applyPending can point a claimed invite at an entry, but left
entry_owner_user_id NULL, so the row was missing from the owner's reads
that now match on that column alone. Fill it from fsentries with the
same subquery the upserts use. The share service claims without an
entry today; the parameter stays for callers that pass one.
* refactor(share): read the app from the data column directly
Every caller of the app filter and the app expression passes the share
row's own data column now, so the column argument goes. Also names the
owner probe test after what it checks and puts back an unrelated line
the earlier formatting pass had reflowed.
* chore(share): drop the redundant MySQL and Postgres owner backfills
MySQL and Postgres replay every migration file on boot, and mysql_mig_47
and postgres_mig_36 already end with the same owner backfill, so they
re-run it on every boot. Only SQLite, which tracks a schema version and
runs each file once, needs its own backfill migration (0103).
* fix(share): list a team row for its members when no entry owner is recorded
* refactor(share): claiming an invite leaves its entry and owner as written
* test(db): run the share owner backfill against the full prior schema
* fix(share): ship the owner backfill as its own MySQL and Postgres migration
The backfill at the end of mysql_mig_47 and postgres_mig_36 replays on boot
only when database.migrationPaths is set. A new file gives installs that apply
migrations another way a step to run; a replay finds nothing to fill.
* fix(fs): refuse instead of replacing an entry whose name differs only by an emoji or accent
MySQL compares fsentries names and paths under collations that also fold
accents and most emoji, so a lookup for one name could return a sibling
with another, and writes then overwrote and renamed it.
Path lookups now return only an entry whose stored path matches the
requested one after NFC normalization and lower-casing; case-only and
NFC/NFD spellings still resolve as before. Name-collision checks opt in
to the folded match and treat it as taken by a different entry: write,
mkdir, touch, rename, shortcut, move and copy dedupe when asked and
otherwise answer 409, and never replace or return that entry.
* fix(fs): leave a sibling's subtree alone when a folder its path folds onto is renamed or moved
The subtree rewrite matches descendants with `path LIKE 'old/%'`, and LIKE
compares under the path collation (ASCII case on SQLite). With a sibling
folder whose path differs only that way, renaming or moving one folder also
rewrote the paths below the other.
When another row sits at such a path, the rewrite now follows parent links
down from the moved folder instead. Without one, the rewrite is unchanged,
after one indexed lookup.
* fix(fs): resolve an entry whose stored path is spelled unlike its names, and heal that path on the first read
Older rows can store a `path` whose spelling differs from the names on their
parent chain in ways the path collation ignores. A lookup that finds such a row
by its real spelling now confirms it through the parent chain, writes the path
from the stored names, and returns it, so overwriting, touching or copying over
it by name works and the next read takes the single indexed query again.
The lineage walk now writes a row's path only when it differs byte for byte
from the one its names give, and drops only those rows' cache keys, so a walk
over rows that are already right issues no writes. Whether an entry found at a
name is a different, folded name is decided by its own name and its path after
that walk, not by a stale path column. With `foldedNames`, a row only the path
collation matches still counts as holding the name.
* fix(fs): match entry names under full case mapping
Names are compared after upper- then lower-casing, with a dotted capital I
taken as I, so a request in another case finds the stored entry for Greek
final sigma (`οδος.txt` for `ΟΔΟΣ.txt`), German sharp s (`STRASSE.txt` for
`Straße.txt`) and Turkish dotted I (`istanbul.txt` for `İstanbul.txt`).
Accent, emoji, kana and width differences still make two names different.
* fix(fs): name the existing entry when a folded name is already taken, and show why a confirmed replace failed
`item_with_same_name_exists` from write, move and copy now carries the
existing entry's own name in `entry_name` when it differs from the requested
one only by accents or emoji, so the prompt names the file the folder shows.
In the GUI, a replace confirmed from that prompt that still fails is reported
with its error message and counts as not written, instead of rejecting out of
`handle_same_name_exists` and leaving the caller waiting.
* fix(webdav): answer MKCOL with 405 and COPY or MOVE with Overwrite: F with 412 on a folded name
The existence checks before MKCOL, COPY and MOVE now see an entry whose name
differs from the target only by accents or emoji, so those requests answer as
for an existing target instead of reaching the filesystem and coming back as
409.
* docs(fs): list the errors write, move and copy return when a name is taken
`puter.fs.write()` gets an Errors section with `item_with_same_name_exists`,
`cannot_overwrite_a_directory` and `conflict`, the last for a name that differs
from an existing one only by accents or emoji. The move and copy pages say how
they reject in that case.
* fix(fs): walk the parent chain on the primary when a path lookup reads hard
A lookup that asks for a try-hard read (the checks after a create or move hits
the unique key) now also runs its parent-chain walk on the primary, and falls
back to the primary for the entry it finds. Before, the walk read a replica, so
a name the replica had not seen yet came back as missing and the mkdir or move
surfaced the duplicate-key error as a 500 instead of a 409.
Lookups without a try-hard read are unchanged. A try-hard lookup issues the
same number of queries; its walk reads the primary instead of a replica.
* fix(fs): treat a folder only the collation matches as missing instead of naming a file in it
A name-collision lookup lets only the last segment of the path match a
differently spelled name. When a folder above it matches only under the
collation (`😁dir` for `😀dir`), the lookup reports no occupant, so a write,
mkdir or touch there fails as for a missing folder: 404, or with
`createMissingParents` a 409 that names the folder. Before, it reported the
file inside the other folder and answered 409 naming that file. A folder whose
stored path is spelled that way still counts as the same folder, so lookups
built from stored paths find what they did before.
A rename onto a name the unique key already holds now answers 409 when the
lookup before it missed the occupant, instead of a 500.
* fix(fs): name the existing entry when a rename's name is folded, and leave a copy onto itself out of the copy docs
A rename refused because the folder holds a name that differs only by accents
or emoji now names that entry in its 409, as write, move and copy do.
A copy onto a folded spelling of the source's own name answers as a copy onto
itself (400 with `overwrite`), so the copy page's paragraph on folded names
now covers only items other than the source.
* fix(fs): walk the parent chain on the primary only when a hard read found another spelling of the path
* fix(gui): save a URL from showSaveFilePicker and always answer the picker
The save picker downloads an http(s) URL itself, without cookies or a
referrer, up to 100 MiB, and writes it where the user chose, in apps and on
websites. A failed download shows an error. In apps, a save dialog that
closes without saving (failed download, failed write, close button) answers
fileSaveCancelled once instead of leaving the call pending.
* fix(gui): answer the save picker with the save's real outcome when it closes mid-save
Closing the save dialog while a URL is downloading stops the download,
writes nothing and answers fileSaveCancelled, in apps and on websites. In
apps, closing it once a write has started leaves the answer to the write:
fileSaved if it lands, fileSaveCancelled if it fails, never both. A save
that throws shows an error and answers fileSaveCancelled instead of leaving
the dialog stuck, and a second Enter during a save is ignored.
On an https desktop only https URLs are accepted, with a message saying so,
since the browser blocks http downloads there.
* fix(gui): retry signing a saved file once, and say so when the app can't get it
The app save picker signs the written file for the app with one retry. If
both tries fail, the user is told the file was saved but couldn't be passed
back, and the app gets fileSaveCancelled. The picker's one-answer state
(answered, closed, saving, writing) lives in helpers/savePickerAnswer.js so
the website picker can share it.
* fix(gui): answer the website save picker once, with the save's real outcome
The website save picker runs one save at a time, so a second Enter is
ignored. Closing it while a URL downloads stops the download, writes nothing
and answers fileSaveCancelled; closing it once the write has started leaves
the answer to the write, so the page hears fileSaved or fileSaveCancelled,
never both. A failed download shows an error, answers fileSaveCancelled and
closes the picker, as in apps, and so does a save that throws. A saved file
that can't be signed is reported to the user and answered as cancelled.
* fix(gui): show apostrophes in the save picker's failure messages
* fix(events): offer a queued delivery only to connections that run its handler
A queue subscription with an events worker was sent to every connection of
the app. A connection without the handler declined it at once, which moved it
to the worker while the tab with the handler was still running it.
puter.js now sends `events.handle` with the persistent subscriptions a
connection runs a handler for, on connect and whenever that set changes. The
server puts the connection in a room for each queue row it holds, and offers
the row's deliveries only to that room. With nobody in it anywhere in the
region, the delivery goes to the worker at once. A peer region handed such a
delivery declines it straight back when none of its connections run the
handler. Rows without a worker are still sent to the whole app room.
* fix(events): probe the handler room only when the app has a connection in the region
A queued delivery for a pair with no connection here goes to the worker on
the same local lookup and presence read as before, with no request to the
other nodes.
* fix(events): probe a forwarded batch's handler rooms together, within one timeout
A region receiving a batch now looks up every handler room its queued
deliveries need before it sends any of them: each distinct room once, up to
16 at a time, all under one shared 500 ms deadline. A room not answered by
then counts as empty, so its delivery is declined home with `no_handler` and
never sent. Deliveries still go out in the batch's order, and a batch with no
such delivery probes nothing.
* fix(events): resend a handler announce refused for the call budget
puter.js now sends `events.handle` with an ack and a 10 second timeout. When
the server refuses it with `too_many_requests`, the client sends the current
set again after 10 seconds, until one goes through or the connection is
replaced. No answer, as from a server without the verb, and any other refusal
are left alone. A connection that never had a persistent handler sends no
announce; one that had sends an empty set when the last is removed.
The rate limits page now says that announces count toward the
acknowledgement budget.
* docs(events): say when a client starts getting first try at queued events
A client counts once the server has registered its handler, shortly after it
connects or calls `onPersistent()`. Before then, an event for a subscription
with an events worker goes to another client with the handler or to the
worker.
* fix(socket): settle a cluster room count on the first node that holds a socket
A count ask now hears each node's answer as it arrives: the first node holding
a socket answers it at once, every node saying none answers it once all have
said so, and a node that never answers only decides it when no answering node
holds one. The request sent is unchanged, so every node answers it as before.
* fix(events): look up handler connections in the region's record, not every node
Each connection that announces a handler is kept in a per-subscription
sorted set in the region's Redis, scored by renewal: added after it joins
the handler room, removed before it leaves or on disconnect, renewed every
minute while it runs one, and counted only for three minutes after its last
renewal. Handing out a queued delivery, and receiving a forwarded one, now
check this node's sockets and then that set with one read, instead of
asking every node in the region to count the room.
A connection the set names but that is gone gets the delivery sent to an
empty room, and the socket lease hands it to the worker as before.
* fix(events): try the next region with the app open after one declines for want of a handler
A queued delivery's region is picked by app presence, so the first region
tried can have the app open without the handler. Its no_handler decline now
hands the delivery to the next region with the app open that hasn't had it,
and the worker runs it once none is left. A lease that lapses still goes to
the worker.
A decline now counts only when it comes from the region the current attempt
went to (this region for a local attempt). The region goes into the entry in
the same write as the attempt's lease, so a second decline from a region
already tried can't take the next region's lease. An attempt made after a
decline stamps its lease in its own field, which a server that doesn't make
such attempts never reads, so it can't take that lease back either.
* fix(permissions): a revoked group grant isn't read back from a lagging replica
* fix(share): links removed by trash or a move aren't read back from a lagging replica, in any region
* test(permissions): start each test server in the file on an empty redis mock
* fix(permissions): node deletes find just-made grants on the primary; add a group membership hook
* fix(share): link revokes reach peer regions even when the local marker fails, and subtree retires read the primary
* fix(permissions): read a group's members after the grant is deleted
* fix(permissions): mark group members before a group grant is deleted
The member read and the marker now come before the delete, so a failed
member read leaves the grant in place for a retry and member reads are
already on the primary when the row goes. Members are read again after
the delete, best effort, to include someone who joined meanwhile.
The node-delete path returns those members, so ShareService bumps the
same list instead of reading each group again from a replica.
readUserGroupPerms checks the marker without the unused base-key GET.
* perf(share): trash and delete read grants from a replica unless the owner shared just now
---------
Co-authored-by: Daniel Salazar <[email protected]>
* fix(puter-js): unshare rejects when every revoke was refused
/share/revoke reports refused pairs in a 200 body, and unshare() only
read `revoked`, so a call where every pair was refused resolved as if it
had worked. It now rejects with the first pair's { message, code }, the
same way share() does; a call where some pairs went through still
resolves with { revoked }.
GUI tests drive the SDK's unshare against a refused body and check that
the share dialogs and Remove from Shared show the error and keep the
row. The unshare docs list the per-pair codes.
* fix(puter-js): unshare and share reject with the first refused pair that names a code
A pair the server could not complete carries no code and a generic message,
so it no longer comes ahead of a refusal that says what happened. Both
operations pick the error through one helper. The unshare docs describe which
pair's error is surfaced and the request-level rejections.
* fix(gui): share modal says how many items a person was removed from when some were refused
After removing someone from several items, the refreshed listing is compared
with the items the removal covered, and the status reports the count that
went through instead of all of them.
* test(gui): unshare fixture swaps XMLHttpRequest per call; alert mock cleared in hooks
* feat(puter-js): unshare resolves with the refused pairs; share is as it was
unshare resolves { revoked, failed }, with one failed entry per refused
recipient/item pair. A pair refused because the recipient or the item is gone
counts as nothing to withdraw: the call rejects only when every pair was
refused and at least one for another reason, with the first such refusal that
names a code. paths and recipients are typed and documented.
share() picks its rejection as before; the error choice lives in unshare.
* fix(gui): sharing dialogs report the items an unshare was refused for
The share modal counts refused items from the unshare result across every
run of a large selection, going on past a run refused whole, for removing a
person and for closing the link on several items. The share window and
Remove from Shared show the refusal when the call resolves with the pair
refused.
* fix(gui): sharing dialogs keep the unshare outcome on screen after the listing refresh
The share modal shows the partial count once its refresh has settled, for
removing a person and for closing the link, so a listing that fails for the
refused item does not replace it. The share window does the same when closing
the link.
* feat(puter-js): an unshare rejected for refused pairs lists them in failed; failed[].path is documented as the absolute path
* test(puter-js): API suite covers unshare's refused pairs
* fix(gui): share modal stops at a revoke request that failed and counts only refused items
---------
Co-authored-by: Daniel Salazar <[email protected]>
* fix(auth): the session list leaves out expired sessions
* test(auth): cover revoking expired sessions and their children
* fix(auth): the session list follows what authentication accepts
A token minted under a session that has expired is left out of the
list along with that session. The check runs in listSessions with the
rule authentication uses, so getByUserId returns expired rows again
and the revoke callers need no option.
* fix(auth): revoke-session answers 404 for another user's session
Missing, already revoked and other users' rows now share one answer.
* fix(auth): type the session list rows instead of casting them
---------
Co-authored-by: Daniel Salazar <[email protected]>
* perf(puter-js): reading the user at load joins the boot lookup
* fix(puter-js): keep the token fallback to the user lookup resend
The fallback bearer is opt-in on initXhr, so other requests and their
replays send no bearer after sign-out, as before. A failed join (the
lookup or the copy) falls back to sending the request. Adds tests for
the legacy callback form and a resend after a network failure.
* fix(puter-js): join the boot user lookup only at the same API origin
A user lookup sent after setAPIOrigin() joined the boot /whoami still in
flight for the old origin and resolved its user instead of asking the new
one. The in-flight marker now records the origin too.
* fix(puter-js): a joined user read signs in from the boot lookup's answer and leaves on its first retry
- The boot lookup keeps one in-flight record (token, origin, user, refusal), made and cleared in cacheWhoami_.
- getUser(), puter.getUser() and whoami() share one join. A refused boot lookup drives the sign-in from its own response, so the token is not sent again; a sign-out meanwhile fails with 401 and sends nothing.
- The join ends when the boot lookup first has to retry, so the caller's own request goes out at once (new fetchUrl onRetry option).
- The joined user is copied with a JSON round trip; initXhr is back to its previous signature.
* fix(puter-js): whoami() keeps its refused-request error when it joined the boot lookup
---------
Co-authored-by: Daniel Salazar <[email protected]>
A uid or id lookup can return a legacy row with no path when its parent
chain is broken. resolveNode now reports it as not found, so readdir,
stat and the other uid-addressed routes answer 404 instead of crashing
in the ACL check.
install.sh writes "meteringEnforcement": { "subscriptions": false } into
config.json because a self-hosted install has no paid plans, so with the
gates on the OpenAI/Anthropic-compatible AI endpoints answer 402
subscription_required for every account. install.ps1 never got that
key, so a Windows install made with the README one-liner still has the
gates on. Write the same key from install.ps1.
The auth probe used to mark a token as failed whenever authenticate threw,
so a database error while checking a valid token came back as 401
token_auth_failed, which clients read as "discard this token".
The probe now records the thrown error on the request instead. Routes
that require auth hand it to the error handler (503 db_unavailable with
Retry-After for a database outage, a generic 500 otherwise); routes that
don't require auth serve the request anonymously, as before. Only a token
that authenticate reports invalid still gets 401 token_auth_failed.
The periodic socket re-check likewise keeps a connection up when its
lookup fails, instead of disconnecting it.
* Redirect /show/* to the dashboard's Files tab
/show/<path> redirects to /dashboard#files/<path> with its query intact,
instead of booting the desktop and opening Explorer there.
Also removes what only that route used: the shell's launch options, the
launch_options GUI param, and the GUI service that ran them.
* Remove the unused component window and Flexer
Nothing opens UIComponentWindow or uses Flexer, and JustHTML was only the
component window's default body. Also drops the attach() shim UIQRCode kept
for component containers.
* Remove translation keys left by removed features
Drops 36 keys nothing references in every language: the keyboard shortcuts
guide, desktop shortcuts, personalization, the toolbar's fullscreen, GitHub
and refer entries, the referral strings, and the publishing option prompt.
* Inline the toolbar height, which is always zero
The toolbar is gone, so window.toolbar_height only added or subtracted zero
in window sizing and placement.
* Remove window edge snapping
Dragging a window to a screen edge or corner no longer snaps it to half or a
quarter of the screen. Double-clicking the head and the scale button still
maximize.
* Remove the window color theme service
Nothing can set a window theme, so the GUI stops reading the saved theme
file on boot and windows keep the default colors. puter.ui.on() still
accepts themeChanged, which is never sent; its docs entry is removed.
* Remove styles and icons nothing references
Drops the stylesheet rules of the removed Settings window, launch menu,
intro menu and a few other removed features, and 16 icons whose names
appear nowhere in the GUI.
* Remove more translation keys nothing references
Drops 56 keys in every language that no GUI code, extension or backend
message uses, such as the system information labels, the old 2FA setup and
confirm-code steps, and the auth token copy dialog strings.
* Remove the global menubar leftovers and more unused styles
Nothing creates a global menubar, so the window code stops hiding, showing
and removing one. Also drops stylesheet rules for classes nothing uses,
including the old auth dialog, the responsive hidden-* and visible-*
helpers, and two keyframes only those rules animated.
* fix(auth): mint under a full-access token from the row auth loaded, and look up token rows by uid with one index per column
A token minted by a full-access token now parents to the session row that
authentication already loaded and checked for the minting token, instead of
reading it again by token uid. The same applies to the session a relay token
is bound to. Tokens whose claims name no row of their own still look it up.
findActiveByAccessTokenUid now queries access_token_uid first and the legacy
uid only on a miss, each through its own index, instead of one OR across both
columns.
* fix(auth): send both access-token row lookups together, so a miss on the first costs no second round trip
Same-origin assets of a launched public app carry the launch token in the
Referer. When that token's signature verifies and it names the same user
and session as a valid public billing cookie (and, for an app token, the
same app), the cookie identity is used directly: no re-authentication, no
new cookie, no Set-Cookie. Any other combination resolves exactly as
before. Responses that do set the public cookie now carry
`Cache-Control: private`.
When the purchased-credit read or the near-allowance exact read fails while
settling a metered call, the call is now settled against the month record it
already has: the share that fits the monthly allowance is charged, and
purchased credit is left untouched since its balance could not be read.
Previously the whole settle was skipped, leaving the cost in the month total
but in neither pool.
Such settles skip the cached-balance update and overuse check, and raise one
info alarm id with the amount credit would have covered.
Access-token revocation is announced as outer.auth.access-token.revoked,
so peer clusters evict the token's socket.io connections and realtime
relay sessions as the revoking cluster does. Peer payloads without a
string token_uid are ignored.
* perf(gui): keep the stored token when /get-gui-token returns one for the same session
/get-gui-token signs a new string on every call, so every signed-in boot
stored a token that differed from the one it held only in `iat` and passed
it to puter.setAuthToken. That opened a second FS socket, re-sent /whoami
and the cache timestamp, and every other open tab picked the new token up
from storage and did the same.
The boot now keeps the stored token when the returned one carries exactly
the same claims apart from `iat`; any other difference replaces it as
before. A kept token still starts the boot from an empty FS cache, as the
swap did. A boot that keeps the persisted cache (no session cookie) now
waits for the cache timestamp check before the first render, which it
started before reading the user.
* perf(gui): skip /get-gui-token at boot when the page already says what it would return
Every signed-in boot waited on /get-gui-token before it read the user. The
shell is rendered from the same session cookie that route reads, so it now
carries `guiTokenDigest` in gui_params: a digest of the claims (minus
`iat`) of the GUI token the cookie's session would get, or null when the
request has no session cookie. A cookie that doesn't resolve to a user
session leaves it out.
The GUI skips the request when the digest matches its stored token (same
session, user and token type), or when it is null (the route would refuse).
It still asks when the parameter is missing, the digest names another
session, nothing is stored, the page and the route are on different
origins, or Navigation Timing doesn't show this page as freshly served for
this load (a history navigation or restored tab can reuse a page rendered
for an older cookie).
* Remove the desktop's icons and right-click menu
The desktop no longer lists the Desktop folder's items, and right-clicking
its background opens no menu. It stays as the surface windows open on.
Also removes what only the icons used: file drops and item drops on the
desktop, rubber-band selection, auto arrange and saved icon positions, and
their two keys in puter.js's GUI boot cache. Clicking the desktop clears the
active item container so keyboard shortcuts stop acting on a window's items.
* Redirect /desktop and /settings to the dashboard
/desktop redirects to the root with its query intact, and /settings to the
dashboard's Home tab. /desktop drops out of the OIDC return_to allowlist,
sign-out returns to the root, and the auth cover page's logo links there.
Also removes eight GUI globals nothing references.
* Remove code paths that only ran for the desktop's item container
The desktop is no longer an item container, so the Trash item that
refresh_item_container added to it, the empty-folder message exemption,
and the context menu parent check for it can't be reached.
* Remove the desktop's Publish Website window
The right-click menu on desktop folders and on an Explorer window's
background no longer has a Publish as Website item. This removes
UIWindowPublishWebsite, the publishAsWebsite helper that opened it, both
menu items, the window's CSS, window.extractProtocol (only the window used
it), and the dir_published_as_website and pick_name_for_website i18n keys.
The dashboard keeps publishing websites through its own dialog.
generateFileContextMenu no longer falls back to the removed window, so it
offers Publish as Website only when the caller passes onPublishWebsite,
which the dashboard's Files tab does. The website badge on desktop items
and the backend hosting API are unchanged.
* Remove the desktop's Hide Desktop Icons toggle
The desktop's right-click menu no longer has a Hide/Show Desktop Icons
item. Without the wallpaper, hiding the icons left an empty page. This
removes the menu item, toggleDesktopIcons/hideDesktopIcons/
showDesktopIcons, the boot-time read of the saved setting, and the
.desktop-icons-hidden CSS rule.
puter.js's GUI boot cache no longer batch-fetches desktop_icons_hidden,
menubar_style or user_preferences.clock_visible, which nothing reads
anymore. Values already saved under those keys stay in KV, unread.
Also drops the desktop's BroadcastChannel, whose message handler was
empty and which nothing else referenced.
* Remove the desktop's ?download= link handler
Opening puter.com/?download=<url> or /desktop?download=<url> no longer
asks to save that remote file to the Desktop. The parameter is ignored,
and a root URL that carries it boots the dashboard like any other root
URL instead of forcing the desktop.
This removes the handler in UIDesktop, its UIWindowProgress import, the
download entry in the GUI's dashboard routing, and the
confirm_download_file_to_desktop, downloading, downloading_file,
uploading_file and error_download_failed i18n keys only it used.
The decision of whether a page load boots the dashboard moves from an
inline block in initgui into helpers/bootsDashboard.js, so it is tested.
* Stop granting Camera and Recorder Desktop access on desktop boot
The desktop no longer grants Camera and Recorder write access to the
user's Desktop folder the first time it loads. Those apps now ask
through the normal permission prompt, as they already did for users
who only used the dashboard. Grants already given stay in place.
puter.js's GUI boot cache no longer batch-fetches the
has_set_default_app_user_permissions flag that kept the grant to one
run. Values already saved under it stay in KV, unread.
* Redirect /@username links to the dashboard
puter.com/@<username> no longer opens that user's Public folder on the
desktop. The backend now redirects /@:username to /, so old links land
on the dashboard instead of booting the desktop.
This removes the GUI's /@ handler and the error_invalid_username i18n
key only it used. error_user_or_path_not_found stays, since share links
still use it.
* Redirect /desktop/app links and stop opening share links on the desktop
/desktop/app/<name> now redirects to /app/<name>, keeping its query
string, so the app opens in the dashboard. The backend no longer serves
an app shell there, OIDC sign-in no longer returns to it, and the GUI no
longer strips a leading /desktop from its route segments. The rate-limits
page lists only /app/<name>, the route the limit still applies to.
/desktop?shared=<path> no longer opens the shared item on the desktop.
The desktop ignores ?shared=: it doesn't ask a guest to sign in for it,
doesn't switch to the account a share email names, and OIDC sign-in from
/desktop doesn't carry it along. Share emails link to /?shared=, which
the dashboard handles as before. Opening a share notification on the
desktop still opens the item.
* Remove the desktop's ?app=explorer link handler
?app=explorer[&path=<folder>] no longer boots the desktop and opens an
Explorer window. ?app= is ignored and no longer forces the desktop, so a
root URL that carries it opens the dashboard, and /app/<name>?app=... now
launches its app in the dashboard instead of booting the desktop and
launching nothing.
* Stop ?puter.fullpage= from booting the desktop
The root and /app/<name> with ?puter.fullpage= now open in the dashboard
like a plain /app/<name>, with the app maximized. The dashboard always
shows apps full page, so the parameter no longer overrides that. Iframe
embeds and popups, which still boot the desktop, read it as before.
bootsDashboard no longer takes the query string, since no parameter
decides the interface anymore.
* Remove the desktop taskbar
The desktop no longer has a taskbar. Windows on /desktop can't be
minimized, since nothing could bring them back; the dashboard still
minimizes into app tiles. The desktop and maximized windows use the
full page height.
Backend: POST /update-taskbar-items is removed, and /whoami and the
login response no longer include taskbar_items. The user table's
taskbar_items column stays until a migration drops it.
puter.ui.createWindow() still accepts show_in_taskbar, which has no
effect and is marked deprecated.
* Keep desktop backdrops above ordinary windows
A backdrop's z-index used to take the taskbar's as a floor, which kept
modal dialogs on /desktop above every ordinary window, including ones
focused after the dialog opened. Keep that floor on the desktop; the
dashboard and fullpage mode stack as before.
Also drops commented-out geometry that referenced window.taskbar_height,
and a dashboard comment about the removed fullpage minimize rule.
* Keep dropped desktop icons fully on screen, and fill the phone popover
A desktop icon drop is accepted only where the whole item fits above the
bottom edge. Phone popovers use the full viewport height.
The desktop no longer shows a wallpaper. It shows the page's plain
background color: white, or #1e1e1e in dark mode. The sign-in dialogs
and account picker that opened over the wallpaper now open over that
color too.
This removes the default wallpaper image and its credit, its preload in
both page shells, the "Set as Desktop Background" item on images'
right-click menu, the GUI's set_desktop_background and
refresh_desktop_background, and the set_as_background i18n key.
Desktop icon labels were white with a shadow to read on the wallpaper;
they now use the same gray as labels inside windows, and a light gray
in dark mode.
The backend drops POST /set-desktop-bg, and /whoami no longer returns
desktop_bg_url, desktop_bg_color or desktop_bg_fit. The user table's
desktop_bg_* columns stay; dropping them needs a migration.
* Remove the desktop's QR login button
The desktop toolbar no longer shows a QR code that signs another device
into the current session. This removes the toolbar button, its click
handler, the UIWindowQR window (its only caller), the qr.svg icon, the
window's CSS, and the four i18n keys only that window used.
UIQRCode and the qrcode library stay, since 2FA setup still uses them.
The ?auth_token= handler in initgui also stays, because the Playwright
e2e harness signs in through it.
* Remove the desktop's My Websites window
The user menu no longer has a My Websites item. This removes the
UIWindowMyWebsites window, its menu item, its CSS, the plug.svg icon only
it used, and the i18n keys only it used.
When publishing a website or worker hits the subdomain limit, the error
now shows only the server's message, without the "Manage your subdomains"
link that opened the window. The trash handlers keep fading the website
badge on a trashed folder and no longer touch the window's rows.
* Remove the desktop's Task Manager window
The user menu no longer has a Task Manager item. This removes the
UIWindowTaskManager window, its menu item, its CSS, the cog.svg icon only
it used, and the i18n keys only it used, including the process_type_*
and process_status_* keys it built at runtime.
The process model in definitions.js, including END_SOFT and END_HARD,
stays: windows still register processes, and a process still forwards
an end signal to its children.
* Remove the desktop's Change Desktop Background window
The desktop's context menu no longer has a "Change Desktop Background…"
item, or the separator above it. This removes the
UIWindowDesktopBGSettings window, its CSS, the palette.svg icon only it
used, and the i18n keys only it used.
Desktop backgrounds still work: the saved background loads at boot, and
an image's "Set as Desktop Background" item still applies and saves it
through /set-desktop-bg.
* Remove the desktop's Start menu
The taskbar no longer has a Start button, so it begins at Explorer. This
removes the Start item and its app popover (recent and recommended apps,
search, tile context menu, and drag-to-taskbar), along with:
- The taskbar's startup /get-launch-apps request and the desktop's
app.opened socket handler, which only kept the menu's lists current
- The Start popover's search, click and keyboard handlers in UIDesktop.js,
keyboard.js, initgui.js and UITaskbarItem.js
- The taskbar's handling of items dropped from outside it, and the
UITaskbarItem append_to_taskbar option only that path used
- The Start CSS, the start.svg icon, and the i18n keys only the menu used
The dashboard's recently used apps still load, since the dashboard fetches
its own list and launchApp.js still records launches. UITaskbar.test.js now
covers the taskbar's item order and that it doesn't fetch launch apps.
* Remove the desktop's Search window
The desktop toolbar no longer has a Search button, and Ctrl/Cmd+F on the
desktop no longer opens the Search window, so it falls through to the
browser's find as it already did on the dashboard. This removes
UIWindowSearch, the toolbar button and its handler, the window's
keyboard handling (arrow keys, Enter, Escape) and click-outside close,
its CSS, the search.svg and magnifier-outline.svg icons only it used, and
the i18n keys only it used.
The backend /search endpoint is unchanged.
* Remove the desktop toolbar
The desktop no longer has the toolbar at the top of the screen, so its
Puter logo, guest Save Account button, and account menu are gone from the
desktop. Log out, account switching, language, settings, and contact all
remain on the dashboard.
Removed:
- The toolbar markup, its auto-hide logic and mouse tracking, its
right-click menu, and the account menu with its click handlers
- The modify-user-options-menu GUI extension, which only added a
Developer item to that menu
- UIWindowFeedback, the desktop's Contact Us window, which only the menu
opened; the dashboard keeps its own contact dialog
- Toolbar hooks elsewhere: the hide_toolbar() call on window maximize,
pointer-events toggles during window and item drags, the click guard in
initgui, the account-button toggle in helpers.js, and the avatar sync
for the toolbar's profile image in TabAccount
- The toolbar and feedback CSS, the toolbar's two KV keys from the
puter.js GUI read cache, and the i18n keys only these used
window.toolbar_height stays: it was already 0, so window and desktop
layout are unchanged. The welcome window keeps its backdrop stacking.
* Remove the desktop's Share window
The right-click menu on desktop and Explorer items, and on items in open
and save file dialogs, no longer has a Share item. This removes
UIWindowShare, that menu item, the share-outline.svg icon only it used, its
CSS, and the share_with and share_row_team i18n keys only it used.
The Dashboard keeps sharing through UIShareModal. generateFileContextMenu
no longer falls back to the removed window, so it offers Share only when
the caller passes onShare, which the Dashboard's Files tab does. The share
dialog layout classes the Blocked Senders window uses, and the recipient
suggestion styles the share modal uses, stay.
* Remove the desktop's Welcome window
New desktop users no longer see the Welcome window on their first visit.
This removes UIWindowWelcome, the first-visit check in UIDesktop that
opened it, its CSS, the welcome_* i18n keys only it used, and the
UIWindow backdrop_covers_toolbar option only it passed.
has_seen_welcome_window is also dropped from the puter.js GUI boot read
cache. Values already saved under that key stay in KV, unread.
* Replace the desktop's color picker with puter.js's picker
puter.ui.showColorPicker() inside Puter now opens puter.js's own
<puter-color-picker>, the one it already shows on websites outside Puter,
instead of the desktop's iro-based window. Apps get the same answer
everywhere: a lowercase 6-digit hex string, or null when the user cancels.
Inside Puter it used to resolve to an 8-digit hex with alpha, or undefined.
This removes UIWindowColorPicker, UIColorPickerWidget, iro.min.js and its
lib-list entries, and the select_color i18n key only that window used.
The GUI loads puter.js from js.puter.com, so the IPC handler doesn't rely
on this branch's component: it drops the alpha from an 8-digit default,
leaves out a default that isn't a 3- or 6-digit hex, and removes the
picker once it answers.
<puter-color-picker> drops the alpha from an 8-digit default instead of
failing to open, opens on its own color when the default isn't valid,
and removes itself when Escape closes it. showColorPicker()'s type and
docs now give the 6-digit return value and null on cancel.
* Remove the desktop's Publish Worker window
The right-click menu on .js files on the desktop and in Explorer, and in
open and save file dialogs, no longer has a Publish as Worker item. This
removes UIWindowPublishWorker, that menu item, its CSS, and the
pick_name_for_worker i18n key only it used.
The Dashboard keeps publishing workers through its own dialog.
generateFileContextMenu no longer falls back to the removed window, so it
offers Publish as Worker only when the caller passes onPublishWorker,
which the Dashboard's Files tab does.
* Add a Language setting to the dashboard's Account tab
The desktop toolbar's account menu was the only place to change Puter's
language, so removing the toolbar left no way to change it. The Account
tab now has a Language card that shows the current language and opens a
dialog listing every supported language by its own name. Saving a new
one stores it on the account and reloads the page in it.
changeLanguage() now returns false when the language can't be saved, so
the dialog can show an error instead of doing nothing. The
change_language key comes back in every locale, and
language_save_failed is new.
* Remove the unused theme dialog and its translations
UIWindowThemeDialog has had no importers since the settings window was
removed, and the reset_colors, ui_colors and change_ui_colors strings
are referenced only by it or by nothing at all.
* Remove the Open Desktop card from the dashboard Home tab
Drops the card's markup, styles, color tokens, and the open_desktop and
open_desktop_hint strings, which nothing else uses.
* feat(gui): let a first-party page frame the custom domain dialog
`/embed/attach-custom-domain` joins the framed popups. On a signed-in page
one label under the Puter origin, `puter.hosting.attach()` now shows the
custom domain dialog in a frame over the page instead of a popup window.
Apps, the desktop and every other website keep their current behavior.
The frame covers the page with a transparent GUI page, so the dialog
floats over it with its own scrim as it does on the desktop. The GUI
serves only the framing page's own user, with no account check waiting,
and otherwise reports `unavailable`, which leaves the page to open the
popup as before. It runs the popup's action unchanged: the framing page
is the requester the dialog names, and gets the same
`customDomainAttached` answer.
Escape is left to the dialog, which doesn't close while a request is in
flight, and the frame allows `clipboard-write` for the DNS record copy
buttons. A call made while the frame shows resolves null without
replacing it, as a second call does on the desktop.
* Update RecommendedAppsService.ts
* fix(gui): let the framed custom domain dialog finish closing before it answers
The answer removes the frame from the page, so the dialog's exit transition
never showed. The framed action waits for the overlay to leave first, with the
wait the feedback embed already had, now one shared helper.
JSON request bodies over 32 KiB are parsed in slices, with the event loop
running between them, instead of in one JSON.parse call. Reading, limits,
decompression, req.rawBody and the errors stay as express.json has them.
Large bodies parse one at a time.
Gemini TTS and video replies use the same parser, and their base64 is
checked and decoded 1 MiB at a time.
A profile picture is sized before its pattern runs, so one of several
megabytes gets a 413.
Body parsers skip the dav subdomain: a PUT labeled as JSON or form data
was read by them and stored empty, or refused.
puter.js sends auth_token as the first field of a driver call. A chat body
over 50 MiB that opens with a valid token is accepted up to 128 MiB, as
one with a header credential is.
JSON in a charset other than UTF-8 is now limited to 1 MiB, and a body
over 32 KiB nested deeper than 10,000 levels is refused.
Two writers that both start before the row exists each upload under their
own object key. The second one found the first writer's row and updated its
bucket and size while keeping the first writer's uuid, so the row could name
an object that was never written under that key.
The store now leaves a row untouched when an overwrite arrives with a
different object key, and the service moves the uploaded bytes onto the
row's key before updating it. The last writer wins and the uuid is stable.
- drivers: a null `args` in a driver call is treated as no arguments
- ai-chat: a `model` that is not a string is answered as model not found (400)
- apps: app stats split a long uid list across ClickHouse queries, which cap the size of one query parameter
The Usage tab told an account how much add-on credit it had left in a
line of text under the monthly meter. It now gets a meter of its own
there: a heading, how much of the credit bought has been spent, and a
bar, with the line saying what is left and when it is used kept under
it. An account that never had add-on credit sees the tab as before.
* feat(gui): let a first-party page frame the deployment's top-up dialog
`/embed/top-up` joins the framed popups. Like the upgrade, it covers the
framing page with a transparent GUI page, serves only the page's own
user, and reports `unavailable` unless the deployment defines
`window.UITopUp` with `embeddable === true` and no account check is
waiting. It runs `new UITopUp({ onClose }).open()` and posts
`closed { toppedUp }` once the dialog closes. The frame's lifecycle is the
upgrade's, now shared by both.
`<puter-user-menu>` gets an Add credits item under Usage, shown only when
whoami reports `subscribed: true`. It frames the top-up on a page that can
frame the GUI, and otherwise opens the dashboard with `?topup=` in a new
tab, within the click. Hidden items are skipped by the menu's arrow keys.
`puter.ui.showTopUp_()` is internal; no public SDK method is added.
* fix(puter-js): show no usage-limit dialog over an open top-up
A call refused for funds while the framed top-up was open put the
usage-limit dialog in the top layer over it, covering the payment the
user was making to clear that refusal. The prompt now stays out of the
way while a top-up shows, as it does for the framed upgrade.
* fix(puter-js): leave Add credits out of the user menu for a team seat
whoami reports a seat as subscribed through its team's plan, so the menu
offered Add credits to an account that can't buy any: the dialog only
answers that the team pays. The item now stays hidden when whoami names a
team paying for the account, as the desktop's own menu already does.
* fix(puter-js): open the top-up tab on the page's own deployment
The tab the Add credits item falls back to went to puter.com whatever
GUI origin the page belongs to, so on another deployment it opened a
different one, signed in as whoever is signed in there. It now opens the
dashboard of the GUI origin, like the menu's Usage link beside it.
* fix(puter-js): return keyboard focus to the account button after Add credits
Choosing Add credits closed the menu with focus on the item it had just
hidden, and the frame restored focus to the menu's shadow host, which
can't take it, so a keyboard user was left on the page body once the
dialog closed. The menu now moves focus to its button first, and a
framed popup restores focus to the element inside a shadow root.
* docs(embed): say when the top-up dialog's open() resolves
It resolves after the account has loaded, not as soon as the dialog
shows.
* fix(puter-js): offer Add credits after an upgrade made on the page
The user menu showed Add credits by the whoami it loaded with the page.
A user who subscribed in the framed upgrade therefore found no Add
credits in the menu until a reload. A framed upgrade that reports a plan
now marks the page's cached whoami as subscribed, as a confirmed email
already is, and the menu reads it again each time it opens.
* fix(puter-js): let a top-up that failed to start be asked for again
showTopUp_ cleared its in-flight marker in a finally inside the async
function it stored. A failure before that function's first await, such
as building the dashboard address from an origin with no scheme, ran the
finally before the marker was set, so the marker kept the rejected
promise for good: every later call got the same rejection, and
topUpShowing_ went on suppressing the usage-limit dialog. The marker is
now cleared once the stored promise settles.
* fix(puter-js): keep requestUpgrade from framing the upgrade over an open top-up
The usage-limit dialog already stays away while the top-up shows, but a
page's own requestUpgrade() call, such as one in the handler of a call
refused mid-payment, still mounted the upgrade frame. Frames are single
per path, so it covered the top-up and the payment being typed in it,
and the two dialogs' own guard can't see across documents. The call now
waits for the top-up and resolves { upgraded: false } once it closes.
* fix(puter-js): offer Add credits after an upgrade when whoami was read again
The user menu keeps the whoami object it loaded and ignores a token set
again unchanged, but the SDK reads whoami into a new object each time a
token is set. A framed upgrade then marked the new object as subscribed
and the menu went on reading the old one, so Add credits stayed hidden
until a reload. The menu now reads the SDK's cached whoami when it is
the same account's.
* docs(embed): list the top-up tests among the checks to run
* fix(explorer): record typed address-bar paths in navigation history and canonicalize them
Pressing Enter in the path input now pushes the destination onto the
window's Back/Forward history like every other navigation, and resolves
the entry first (trim, ~ and home-relative expansion, trailing or doubled
slashes) so the window's path matches the form the rest of the GUI compares
against.
* fix(explorer): keep the address-bar input in step with the Shared view and the root
The path input was only updated after a successful stat, so in views that
are never statted it kept the previous folder's path and Enter navigated
back there.
* fix(explorer): drop the previous folder's identity when a folder fails to load
A rejected stat was never handled, so the window kept the previous folder's
uid, title and item count while showing the load error, and layout or sort
changes were saved to the wrong folder. The window now clears them, and the
failed request no longer surfaces as an unhandled rejection.
* fix(explorer): release a window's selection listeners and history when it closes
Each folder window left its rubber-band selection instance, a document
mouseup handler and its navigation history behind after closing, so every
later mouse press ran once per window ever opened. Windows that are not
folders no longer bind the handler at all.
* fix(explorer): anchor a Ctrl/Cmd rubber-band selection where the drag started
The rectangle's origin was only recorded for unmodified drags, so adding to
a selection drew it from the previous drag's start point or the top-left
corner.
* fix(explorer): keep arrow-key navigation on visible items and a valid anchor
Arrow keys could land on a hidden dotfile and then stop moving, threw when
the remembered anchor belonged to another window, and left the footer's
selected count stale.
* fix(explorer): ignore non-character keys in type-to-select
Shift, arrows and Enter were appended to the search term by name, so typing
a capital letter or typing right after navigating matched nothing. Hidden
files are no longer matched, and the footer count follows the container the
selection changed in.
* fix(explorer): make the keyboard permanent-delete confirmation work in every language
The confirmation compared the dialog's answer with the English button label,
so in any other language confirming did nothing.
* fix(explorer): paste once per Ctrl+V press
The shortcut ran on both keydown and keyup, so releasing V before Ctrl
pasted the clipboard twice.
* fix(explorer): keep selection shortcuts working after navigating, and out of text fields
Select All, Copy, Cut and Delete looked up the folder from the element last
pressed, which is a removed item or a sidebar entry after navigating, so the
shortcuts did nothing. Select All also skips hidden files, and Delete keys
typed in a text field are left to the field.
* fix(explorer): count only visible items in the footer and drop hidden files from the selection
The item count included dotfiles that were not shown, and hiding hidden
files left them selected, so a later Delete or Cut acted on files the user
could not see.
* fix(explorer): paste cut items after their folder is no longer displayed
Paste resolved the cut paths to rows on screen, so cutting a file and then
navigating the same window to the destination moved nothing and silently
emptied the clipboard. Items without a row are now looked up and moved the
same way.
* fix(explorer): show a moved item once in its destination
The tab that moves an item renders the row and also receives the move
event, so the destination showed the item twice whenever the event arrived
first. Both paths now replace any row the other already rendered.
* fix(explorer): sort names the way the dashboard does, with a stable tie-break
Folder windows and the desktop compared raw lowercase strings, so file10
came before file2 and accented names landed after z, and equal sizes, dates
or types kept whatever order they arrived in. They now use the dashboard's
comparator, so the same folder reads the same in both.
* fix(explorer): keep the Trash window's states when Trash is emptied elsewhere
Emptying Trash from another tab cleared the whole container, including its
empty, error and loading elements, and left the item count stale.
* fix(explorer): remove rows deleted elsewhere instead of hiding them
A deletion from another tab or an app only faded the row out, so it still
counted in the footer, in Select All and in keyboard actions. The row is now
removed, and windows showing the deleted folder close even when its own row
is not displayed anywhere.
* fix(explorer): refresh size and date when a file is overwritten elsewhere
An overwrite arrives as an update event, which only applied the name, icon
and path, so the size and modified columns and sorting kept old values.
* fix(explorer): download files whose names contain &, #, % or +
The path went into the download URL unencoded, so the server received a
truncated path and the download failed.
* fix(explorer): allow moving into folders whose name starts with Trash
The deleted-folder check matched any sibling of Trash sharing its prefix,
so moving into a folder such as Trash-old was refused.
* fix(explorer): end the extract operation when an archive is corrupt or has no files
A corrupt zip threw before its error could be shown, and an archive with
nothing to write or a failed upload never closed the progress window. A
progress update arriving before the window existed also threw.
* fix(explorer): follow renamed and moved folders in Back/Forward and on undo
A window's navigation history kept the old path after a folder was renamed
or moved, so Back led to a load error. Undoing a folder rename also left
windows and items inside it on the renamed path, because the undo retargeted
from the path it was restoring.
* fix(explorer): keep one progress window per copy or move
The progress timer is re-armed after every conflict dialog, and it opened a
second window when one was already showing, leaving the first on screen for
good.
* fix(explorer): show file names once-escaped in move alerts
The name was escaped before being passed to an alert that escapes its whole
message, so names with &, quotes or angle brackets showed entity text.
* fix(explorer): don't flash the empty-folder message while a folder refreshes
Clearing the rows for a refresh showed the message until the listing came
back, even for a folder full of items.
* fix(explorer): accept drops into a window that covers a desktop folder
A desktop folder hidden under a window still reacted to the drag and
disabled the window as a drop target, so the drop was ignored. A drop that
bailed out early also left every folder view disabled as a target until the
next hover; targets are now restored when the drag ends.
* fix(explorer): release each row's document click handler when the row is removed
Every file row bound a click handler on document that was never unbound, so
browsing folders grew the number of handlers run on every click and kept the
removed rows alive.
* fix(explorer): let Delete work on a file after its rename was cancelled
Cancelling a rename with Escape, or leaving the editor without a change,
left the item marked as being renamed, and the Delete shortcut skips such
items.
* fix(explorer): offer a download when no app can open a file
The prompt called a translation import that is not a function, so opening
a file with no suitable app threw and did nothing.
* fix(explorer): use an item's current path and name in its context menu
Cut, Copy, Download, Create Shortcut and the rename editor read the path and
name captured when the row was created. After a rename made from another
window or tab they acted on the old path, and closing the editor repainted
the old name.
* fix(explorer): offer Cut only where the item can be moved
The item menu on the desktop and in folder windows offered Cut on trashed
items and on shared items the user may not move, unlike the same menu built
for the dashboard.
* fix(explorer): give a folder named *.app a folder icon
The app-shortcut branch ran before the directory branch, so such a folder
was read as a file on every listing, failed, and showed the app icon.
* fix(explorer): make New > JPEG Image create an actual JPEG
The blank image was encoded with the canvas default, so the .jpg file held
transparent PNG data.
* fix(explorer): handle a drop onto a folder that is part of the dragged selection
Dragging a selection onto one of its own selected folders also tried to move
that folder into itself. On the desktop the drop also cleared the saved
position of the target folder instead of the items that left the desktop.
* fix(explorer): don't highlight a window as a drop target for its own items
The check meant to skip the folder an item is already in compared the wrong
path, so dragging inside a window outlined it although dropping there does
nothing.
* fix(explorer): keep desktop rearranging to the desktop itself
Dropping an item from a window showing the Desktop folder onto the desktop
background positioned the window's row absolutely and saved that position.
* fix(explorer): empty Trash from its window in one request
The Trash window's own Empty Trash sent one delete request per item and
reported Trash as empty before any of them finished. It now runs the same
single operation as the taskbar and desktop.
* fix(explorer): label the root and Shared in the Back/Forward menus
The long-press history menus showed an empty row for the root folder and a
lowercase path segment for Shared, and the Forward menu's Home entry was not
translated.
* fix(explorer): keep sidebar labels translated and titled after reordering
A reordered sidebar rendered the labels saved at the time of the drag, so
they stayed in the old language, and every item's tooltip was empty.
* fix(explorer): enable the Open dialog's button only for a selected file
The button stayed enabled after navigating to another folder, and a
rubber-band selection of folders alone enabled it, although clicking it then
did nothing.
* fix(explorer): show the slow-load message only for a load that is slow
Once any folder took over a second to load, the spinner text switched to
the slow-load message three seconds later even after the load finished, and
every later load in that window opened with it. The text is also translated
now.
* fix(explorer): keep the current folder visible in a long breadcrumb
A path too long for the bar was cut at its end, hiding the folder being
viewed and its nearest parents. It is now cut at the root end.
* fix(explorer): grey out the whole selected row in an unfocused details view
Only the name lost its highlight when the window lost focus, leaving a grey
pill inside a blue row.
* fix(explorer): align the details header with its rows and mark it clickable
The header ended 10px before the rows and the selection highlight, and its
sortable column titles kept the default cursor.
* fix(explorer): give the layout button a tooltip and a pointer cursor
It was the only navbar control without either, so it did not read as
clickable.
* fix(explorer): remove the stray tooltip from the loading spinner
The spinner's SVG carried an internal title that showed as a tooltip and
was read out by screen readers.
* fix(explorer): refuse uploads, copies and shortcuts into the Shared view
Dropping files on Shared in the sidebar, or Ctrl/Alt-dropping items onto a
window showing it, sent requests against a path that is not a directory and
ended in an error or a stuck progress window.
* fix(explorer): open an item's context menu where it was requested
The menu is built after permission and app lookups, and it opened wherever
the pointer had moved to by the time they finished.
* fix(explorer): close a folder's window when the folder is deleted, however it was opened
Windows opened by path, such as with Ctrl-click on a breadcrumb or sidebar
entry, never received the marker used to find a deleted folder's windows,
so they stayed open on a folder that no longer existed.
* Send one request, not four, after the account pick in the sign-in popup
A sign-in popup exchanges for the opener's app token as soon as the user
picks an account. postAuthActions now hands over that result when the
opener origin and session token still match, and exchanges again otherwise.
The relationship check is skipped when an account was picked, since the
pick already decides both the second picker and consent. Signup in the
popup is not a pick and still runs it.
The /whoami read before the picker is reused only when the same user and
session token are still in place, the exchange just accepted that session,
no verification gate is pending, and the read is under a minute old.
* Test picking a different account in the sign-in popup
The popup has to read the picked account once and hand over a token for
it, not for the account it opened with. The second-account helper moves
to the shared e2e helpers so both specs that need one use the same copy.
The `puter-apps` `select` method returned every app that was not
protected unless the caller sent `predicate: ['user-can-edit']`. It now
always scopes to the caller's own apps. The predicate is still accepted
and no longer changes the result.
Apps owned by someone else stay reachable by name or uid through `read`
and `/query/app`.
`AppStore.count` drops `visibleToUserId`, which only the unscoped
listing used.
* Reload a tab when another tab switches to a different account
Every tab shares one stored session token. A tab that sees a new token
appear in storage adopted it whoever it was issued to, so after an account
switch in one tab the others kept showing the previous account while
sending requests as the new one.
A tab now reads the user out of the stored token. A token for the account
it already shows is adopted as before. A token for a different account
reloads the tab, which comes back up as that account. A tab with no
signed-in user, or a token whose user can't be read, behaves as before.
* Store a new account's token only after its session cookie is set
A tab that sees another account's token in storage reloads and signs in
from the session cookie. update_auth_data() stored the token before it
synced the cookie, so a tab that reloaded faster than the sync came back
up as the previous account and stored that account's token again, which
the switching tab then adopted under the new account.
The token is now stored after the cookie sync, in the same task as the
stored user.
* Keep the new account's token when another tab stores one during the cookie sync
update_auth_data() sets window.auth_token, then waits for the session cookie sync. A token another tab stored during that wait was adopted over it, so the tab finished the switch holding the previous account's token and saved it under the new account in logged_in_users. The token is set again once the sync has settled.
* Create one visitor account for the account switch e2e tests
Each test signed up its own visitor, and sign-ups are limited to 10 per 15 minutes, so a few runs in a row failed in setup.
* Show a shared item once, with its strongest access, when it has more than one share
The Shared listing returns one row per grant, so an item shared by two
people, or directly and through a team, appeared twice and took its
actions from whichever grant was listed last. The GUI now merges the
rows for an item and keeps the strongest mode.
* Keep a shared item under each person who shared it in the people filter
A merged row carried only the strongest grant's issuer, so filtering the
Shared view to someone whose grant was the weaker one hid an item they
shared, and their chip left it out of its count. A merged row now lists
every issuer. The filter and its counts match any of them, Everyone
counts the item once, and the Shared by tooltip names them all.
* Keep an item listed after Remove from Shared when a team still shares it
Leaving an item drops the grants made to the user directly. One a team
also holds stays shared, but the desktop took its icon away until the
next refresh and the dashboard removed the row before re-listing it. A
merged row now records that a team holds the item too. The desktop
re-lists the Shared folder so the item shows as the team share it has
become, and the dashboard leaves the row in place for its refresh.
* Show an item as shared by the person the Shared view is filtered to
An item several people shared showed the strongest grant's issuer in
the Shared by column even when the listing was filtered to one of the
others. A filtered row now shows the person filtered to, which also
keeps the Shared by sort in step with what the column displays.
* Match desktop items and windows by their path value, not by a selector
Path lookups on the desktop compare data-path values through one helper, so
a name holding &, ' or " is found like any other. Explorer windows keep the
raw path in data-path after navigating, the same as when they open.
* Read data-path straight from the element in path lookups
atPath() and underPath() built two jQuery objects for every element they
looked at. On a desktop with 5,000 items a lookup took about 1.4 ms,
against 0.08 ms for the selector it replaced. Reading the attribute from
the element brings it to about 0.3 ms.
* Match no element when a path lookup has no path
atPath() with a missing path matched every element whose attribute is
empty, which is every item that is not a shortcut and every app window.
An item.moved event without old_path would point all of those items at
the moved file. underPath() with an empty path matched every path.
* Use atPath() to find the items a share badge belongs to
mark_item_shared() had its own copy of the same lookup.
* Keep the raw folder name in an explorer window's data-name
update_window_path() and refresh_item_container() wrote the HTML-encoded
name there, so after navigating into "R&D" the tab title and the publish
dialog showed "R&D". A newly opened window already held the raw name.
* Answer requestEmailConfirmation() once, and show it in a frame on first-party pages
puter.ui.requestEmailConfirmation() in an app opened a second code dialog
after the first: a confirmed user got a dialog they couldn't complete and
two answers, and an unconfirmed one entered the code and then got a second
dialog, whose result was the only answer. The desktop handler now runs one
walk-through and answers once with its result.
That walk-through is ensureConfirmedEmail: save a temporary account, then
ask an unconfirmed email for its code, each window at most once. Publishing
a website or worker uses it too, replacing two copies of the same steps.
The code window now resolves false when it closes without a confirmation,
so dismissing it answers the app instead of leaving it waiting, and it
marks window.user confirmed before resolving, so a caller that checks next
doesn't ask again before the refresh lands. A logout from its footer still
leaves it unsettled, so the boot gates don't reopen it. In the dashboard
the windows open above the app's window, which stays on top and covered
them.
On a signed-in page one label under the Puter origin, the call now frames
/embed/request-email-confirmation over the page, served like the upgrade
frame, and resolves { response }, the field an app reads from the
desktop's answer. The GUI shows it only for the framing page's own user
with no phone, card or password check waiting. It skips the boot's
non-closable email gate for this action, so the user gets one code window
they can close or Escape. An email already confirmed resolves true without
showing the frame. A frame that can't show it resolves { response: false }.
* Skip the email confirmation frame when the page knows the email is confirmed
A confirmed email never goes back to unconfirmed, so requestEmailConfirmation()
on a first-party page resolves { response: true } from the page's whoami
instead of booting the GUI in a frame behind a loading scrim.
* Close the framed email confirmation on Escape in the save-account window
A temporary account sees the save-account window first, where Escape did
nothing, while it closed the code window that follows.
* Let the email code window recover from a failed code check with no response body
A network failure has no JSON body, so reading its error threw, leaving the
window spinning with its inputs disabled. It now shows an unknown-error message
and lets the user try again.
* Answer an email confirmation asked for while one is showing with the windows already open
A second walk-through opened a second code window over the first, and for a
temporary account it never answered, since the save-account window opens once.
* Read the email code from the code window it was typed in
A second code window read the inputs of the first one on the page, so typing
in it never filled in the code or enabled its button.
* Keep the email code window's answer in step with a code check it was closed during
Closing the window while a code was being checked answered false, and the
check went on to confirm the email. A code not sent yet is no longer sent, and
the window waits for one already sent before it closes.
* Show why a new email code couldn't be sent
A failed re-send, such as one past the hourly limit or a network failure,
showed nothing, so the link seemed to do nothing.
* Skip the email confirmation frame on calls after the user confirmed in one
The page's cached whoami kept reading the email as unconfirmed, so each later
call framed the GUI again and flashed its loading scrim before resolving true.
* Leave the email code window unsettled when any logout closes it
Only a logout from its footer did. One from elsewhere, such as after a 401,
answered false, so the boot's email gate opened a new code window while the
page was navigating away.
* Hide the save-account window while its email code window shows
A save-account window with a backdrop stayed under the code window's own, so
the page behind was dimmed twice, and on a phone the window showed around the
code window's edge.
* Answer a save-account request made while the save-account window is open
The window opens once, so the second request got no window and never
answered, which left an email confirmation that asked for it waiting too.
* Answer a framed email confirmation closed with Escape with the code check it was closed during
Escape in the framed code or save-account window closes that window, and the window's answer closes the frame. A code already sent is waited for, as it is when the window's close button is used. Before, Escape closed the frame and answered false while the check went on to confirm the email.
A file row without a stored region is read with the server's configured
region. When its bucket lives elsewhere, S3 answers 301 PermanentRedirect
and the read failed with a 500. The SDK now retries once in the region the
redirect names.
File, directory and save pickers opened in a popup now post their results,
cancellations and the save-data request to window.openerOrigin instead of
'*', matching the token hand-off. A test checks that opener messages in the
GUI only target '*' when the opener's origin is not yet known.
* Show the upgrade in a frame on first-party pages
puter.ui.requestUpgrade() on a signed-in page one label under the Puter
origin now frames /embed/upgrade over the page and resolves { upgraded }
once it closes. The usage limit dialog's Upgrade button does the same on
those pages instead of opening the dashboard in a new tab. Apps, the
desktop and every other page keep their current behavior.
/embed/upgrade is served like the sign-in and picker frames: the GUI
shell with the same framing rules, query and exact-path redirects and
no-store. The GUI shows the deployment's upgrade dialog
(window.UIUpgradeAccount) only for the framing page's own user, with
nothing left to verify, and only when the dialog says it reports its
close (UIUpgradeAccount.embeddable). Otherwise it reports unavailable and
the page opens the dashboard upgrade in a tab, through a consent dialog
when the click has expired. The app it credits is resolved from the
framing page's origin, never read from the fragment.
The frame covers the page with a transparent GUI page so the dialog's own
scrim shows, allows payment, and leaves Escape to the dialog. A call made
while it shows settles with it. If every dialog and window in the frame
goes without a close being reported, the frame closes itself rather than
staying over the page.
* Open the framed upgrade only once the frame has a viewport
The page keeps a loading frame hidden, which Chromium and WebKit lay out at
0x0. The team-seat alert, a GUI window that centers itself when it opens,
landed at (-175, 0) with its message and OK button cut off. A hidden frame
now tells the page it's ready first, and opens the dialog on the resize that
follows, or after a second if none comes.
* Document when the framed upgrade posts ready
* Retry a framed popup's focus() that the frame ignored
Firefox and WebKit ignore focus() inside a frame for a moment after the
page shows it, and Firefox sends no focus event afterwards, so the framed
upgrade dialog and its team-seat alert opened with nothing focused there:
Enter didn't press OK and screen readers didn't land in the dialog. A
focus() that didn't take is now retried briefly, until the user presses a
key or the pointer.
* Keep the framed upgrade's GUI in the light color scheme
The transparent upgrade frame put `color-scheme: light dark` on body as
well as the root, so in dark mode form fields the GUI leaves at browser
defaults turned dark. The email confirmation code digits showed slate
text on a dark grey field inside a white window. Only the root needs to
match the frame's scheme to stay transparent; body now keeps the light
scheme the GUI uses everywhere else.
* Fall back to the upgrade tab when the framed dialog fails to open
If the deployment's upgrade dialog threw while opening, the frame reported
a close, so requestUpgrade() resolved { upgraded: false } and the user's
click did nothing. It now reports itself unavailable, and the page opens
the dashboard's upgrade page as it does for any frame that can't show it.
* Say which plan changes count as upgraded in UpgradeResult
The upgrade dialog sets `upgraded` only for a subscribe or an upgrade;
a downgrade, cancellation or resume closes with false. The type said any
plan change counted.
* Stop the page scrolling behind a framed GUI embed
Firefox and WebKit pass a wheel or touch scroll over a frame on to the
page behind it, whatever the dialog's overscroll-behavior, so scrolling
over the framed upgrade, sign-in or a picker scrolled the page under the
scrim. The page's root now stops scrolling while an embed shows, taking
a classic scrollbar's width as padding so its content doesn't shift, and
gets its own styles back once the last embed closes.
* Skip the usage limit dialog while the framed upgrade shows
A refused call while the upgrade shows in a frame over a first-party
page opened the usage limit dialog on top of it, which made the upgrade
dialog inert until the user closed it. The refusal still rejects.
* Restore the page's root styles after an embed the page removed
An embed dialog the page removed itself never unlocked the page scroll,
and the next embed read the locked styles as the page's own, so the
page stayed unscrollable once it closed. The page's styles now go back
before the next lock reads them.
* Open the upgrade tab when the page can no longer frame it
The usage limit dialog decided to frame the upgrade when it opened, so
an Upgrade click after the page signed out did nothing. onUpgrade now
returns false when the page can't frame it, and the dialog opens the
dashboard tab as it does without onUpgrade.
* Keep the upgrade showing until its tab consent is answered
When the framed upgrade fell back to a consent dialog, requestUpgrade
settled as soon as the dialog showed. A refusal meanwhile opened a usage
limit dialog over it, and a second call asked again. The upgrade now
counts as showing until the user answers, so both join it.
* Say where requestUpgrade never settles
Its JSDoc named only apps, though on any website that can't frame the
upgrade the promise doesn't settle either.
* Treat a GUI embed the page removes as dismissed
A page that re-renders its body took the embed's dialog with it, and the
embed's promise never settled. For the upgrade that left it counted as
showing for good: later requestUpgrade() calls joined the dead one, no
refused call opened the usage limit dialog again, and the page stayed
unscrollable.
* Let the framed upgrade credit the remembered prompt when it knows none
When the framing page's origin resolved to no app and the call named no
reason or method, the frame handed the dialog an empty attribution. The
dialog then remembered that as the latest prompt, erasing another app's
credit, and sent the purchase with no attribution at all. It now gets
null, as from every other entry point.
* Pad the page by the width its scrollbar gives back behind an embed
A page with scrollbar-gutter: stable keeps its gutter once the root stops
scrolling, so padding it by the scrollbar's width as well shifted its
content left while any GUI embed showed. The lock now measures how much
width the root actually gets back.
* Keep the page's scroll position behind an embed when its body scrolls it
A page whose body carries the viewport's overflow (html and body at 100%
height, body with overflow-x hidden) jumped to the top while an embed
showed, and stayed there after: hiding the root's overflow made the body
a scroller of its own. The lock now hides the overflow of whichever
element the viewport uses, and restores a single-axis inline overflow.
* fix(dashboard): middle-truncate long names in the default Files list view
In the list view's default auto-width name column, a long name was cut at
the end by CSS with no tooltip, so names that differ only near the end
("report … (1).txt", "report ….txt") looked identical and the extension
was hidden. The name is now cut in the middle to the room its cell has,
like a resized column and grid tiles already are, and the full name shows
on hover. The phone layout's two-row rows get the same treatment.
* fix(dashboard): toast the newest notifications of a catch-up burst
A burst of more than four notifications toasts three and folds the rest
into an "N new notifications" summary. The split kept the first three it
was given, assuming newest first, but the reconnect catch-up and the
events-feed replay hand notifications over oldest first, so after a tab
woke up the toasts showed the oldest three and hid the newest. The split
now sorts the burst itself.
* fix(dashboard): act on a regrouped notification's current content when its toast is clicked
When more shares from the same person fold into a notification that's
already toasted, the toast's text updates ("alice shared 3 items") but
its click still acted on the notification as first shown, so it selected
only the first file instead of opening Shared. The click now looks up the
notification's current content.
* fix(dashboard): show browsers on Puter's own Web Push as browsers in Your devices
A device registered through Puter's own Web Push client reports platform
"webpush", which the Notifications tab didn't know: it got the phone icon
and no "Web" label. It's now treated like the other browser platform.
* fix(dashboard): clear the push preference's load error once it loads
If the Puter notifications preference couldn't be read, its card's hint
was replaced with a load error. Coming back to the tab once the read
worked re-enabled the switch but left the error text in place. A
successful read now puts the hint back.
* fix(dashboard): keep push off for this browser when removing it half-fails
Removing "This browser" from Your devices turns its push off and then
deletes the server record. If the delete failed (a dropped connection),
the handler returned before saving the opt-out, so the card still read
"on" and the next dashboard load quietly turned push back on. The opt-out
is now saved as soon as push is off, and the card is redrawn on failure.
* fix(dashboard): don't let an older device or app listing redraw a removed row
The Notifications tab lists devices and apps each time it opens. If one
of those listings was still in flight when the user removed a device or
turned an app off, it landed afterwards and drew the removed row again.
Only the newest listing draws now, and a removal outdates any listing
already in flight, as the browser card already did.
* fix(dashboard): report the items that were shared when a large share fails partway
Sharing more than 50 items sends one request per 50. If a later request
failed (the daily share limit, say), the items earlier requests had
already shared were forgotten: the dialog showed a flat failure and none
of their rows got the shared badge until a reload. The dialog now says
"Shared with bob on 50 of 60 items" along with the error, badges those
rows, and refreshes the shared listing.
* fix(dashboard): drop the site address from files inside a disassociated folder
Disassociating a folder's website from its Properties cleared the
folder's own badge, but the files inside it kept their website-address
badge and URL, which now led to a site that no longer exists. Their
addresses are now worked out again without that site, so a file still
served by a site on a folder within keeps its address.
* fix(dashboard): keep an app's own "(N) " title prefix beside the unread badge
The dashboard puts the unread count in front of the page title and keeps
it there as app windows rewrite the title. It removed any leading
"(N) " before adding its own, so an app titled "(3) Inbox" lost its
count ("(1) Inbox"). Only the badge the dashboard itself put there is
replaced now ("(1) (3) Inbox").
* perf(dashboard): redraw the notification list once per burst of acks from other tabs
"Mark all read" in one tab sends the other open dashboards one ack per
notification, and each ack rebuilt the whole list. With 150 unread that
was about half a second of main-thread work in every other tab (more on
slower devices). Acks and removals from other tabs now redraw once per
50ms beat: 150 acks take about 5ms.
* fix(dashboard): let the keyboard reach "Also delete data" in the Uninstall dialog
The Apps tab's dialogs keep Tab inside the card, but the trap only
cycled buttons and text areas, so Tab and Shift+Tab bounced between
Cancel and Uninstall and the delete-data checkbox couldn't be reached
without a mouse. Inputs, selects and links are part of the cycle now.
* fix(dashboard): don't leave the app request form on an IME composition's Escape
In the "Request an app" form, Escape steps back to the options. While
typing with an input method (Chinese, Japanese, Korean), Escape is how
the composition is cancelled, and that Escape also left the form. It's
now ignored while composing, as the Apps grid and folder name already do.
* fix(dashboard): keep focus out of an Apps folder while it closes
A folder's card stays on screen for its 240ms close animation. During
that time its tiles could still take focus: an arrow key pressed right
after Escape moved into the closing card, and focus then fell to the
page when the card was removed, losing the user's place. The closing
card is now inert and the arrow keys skip it.
* fix(dashboard): save the Apps layout one write at a time
The app order and folders were saved with a write per change, without
waiting for the one before. Changes close together (ejecting an app from
a folder saves, and the drop right after saves again) put two writes of
the same key in flight, and if they landed out of order the older layout
was what the next load showed. Each key now has one write in flight at a
time, and the next carries only the newest layout.
* fix(dashboard): close an open dialog when browser Back switches tabs
Back and Forward between dashboard tabs closed the drawer, menus, Share
and Properties, but not the dashboard's dialogs (Edit Profile, Change
password, confirmations) or the photo crop. The tab changed underneath a
dialog that stayed up with focus inside it. They now close through their
own close buttons, like the rest.
* fix(dashboard): count the trial countdown in calendar days
The Home tab's trial badge rounded the time left up to whole 24-hour
blocks, so it disagreed with the date shown under it: a trial ending at
3pm today read "1 day left" beside today's date and never "Ends today",
and one ending tomorrow morning read "2 days left". It now counts
calendar days in the user's time zone.
* perf(gui): rewrite paths after a username change only on elements that carry one
Changing the username from the dashboard's Account tab rewrote paths on
every element in the document (the selector matched them all) and set
every username label once per element, which grows with the size of the
Files listing on screen. It now visits only elements with a path
attribute and sets the labels once. With no earlier username (signing
in) there's nothing to rewrite, which also stops it throwing then.
* fix(dashboard): make long resource names in the Usage table readable
Resource names in Usage details are cut off with an ellipsis, and on a
phone the resource column is the one that shrinks, so a key like
"openai:gpt-4o-mini-2024-07-18:prompt_tokens" read "openai:gpt-4o-mi…"
with no way to see the rest. The full name now shows on hover, and on
phones, which have no hover, the name wraps instead of being cut.
* fix(dashboard): show Usage units in the dashboard's language
In Usage details the Cost column followed the dashboard's language but
the Units column beside it always used English separators, so a German
row read "1,234,567 | 1,23 $", where the units look like 1.2. Units now
use the language's separators too ("1.234.567").
* fix(gui): translate the 2FA setup wizard's step labels and messages
The two-factor setup opened from the dashboard's Security tab had
English hardcoded in its progress steps (Scan, Backup, Verify, Done),
the "Enabling…" spinner, the success message and its Done button, the
copy-secret button's label, the "Copied!" confirmation and the printed
recovery codes' heading. They go through i18n now.
* fix(dashboard): leave Alt and Cmd arrow shortcuts to the browser in Files
Files took every arrow, Home/End and Page key to move the selection,
whatever modifier was held, so Alt+Left (Back) and Cmd+Up/Down moved the
selection instead of doing what the browser does with them. Arrow keys
with Alt or Cmd are left alone now.
* fix(dashboard): explain in the user's language why a Trash item can't be restored
An item moved into the Trash by an app or the API has no record of where
it came from. Choosing Restore on it showed "Cannot restore: original
path not found in metadata", in English and in internal terms. The
message now says which item it is and why, through i18n.
* perf(dashboard): delete Trash items permanently a few at a time
"Delete permanently" on a Trash selection deleted one item after another,
a round trip each: 60 items took about 8s at 100ms of latency, and a few
hundred took most of a minute. Six run at once now (60 items: about
1.3s). The bounded runner the notification center used for "Mark all
read" moves to a shared helper for both.
* fix(dashboard): don't leave a "folder is empty" notice over the next folder's rows
Opening a folder clears the pane while its listing loads. If something
finished in that time and found the pane empty (a Trash restore, a
failed new folder), it added the "This folder is empty" notice, and the
listing's rows were then drawn under it. The listing now clears a stale
notice before drawing its rows.
* fix(dashboard): restore create_file when two "New" creates overlap in Files
Each "New > Text Document" (or HTML, Web Link…) in Files swaps in its own
window.create_file while it runs and put back whatever it found when it
finished. When two overlapped and the first finished first, the second
put back the first's stand-in for good. Any later create that reached it
counted a create that never ended, and from then on files added by apps
or other tabs stopped showing in the listing until a reload. The stand-ins
are now tracked together, and the real function comes back once none is
left.
* fix(dashboard): don't let a slow "New" file pull the user back or land in another listing
After "New > Text Document" (or HTML, Web Link…) in Files, the new file's
row is drawn once its upload finishes. If the user opened another folder
meanwhile, then:
- if that folder was still loading, the row (with its rename box) was
drawn into the loading pane and the spinner removed, and the folder
then lost its "empty" notice;
- if it had loaded, the user was taken back to the folder the file was
made in.
The row is now drawn only if the folder is still on screen, and the user
is only taken to the folder whose menu made it if they haven't moved.
* fix(dashboard): keep an uninstalled app off the Apps grid until its uninstall lands
Uninstalling an app takes its tile away at once, but the server keeps
listing the app as installed until the revoke request finishes. A grid
refresh in that time (switching tabs, coming back to the browser tab)
put the tile back, and it stayed until the next refresh; clicking it
reinstalled the app. Refreshes now leave the app out until the revoke
settles, and ones started before it landed are discarded.
* fix(dashboard): keep keyboard focus when uninstalling an app from inside a folder
After uninstalling an app from the Apps grid, focus goes to the tile that
took its place. An app uninstalled from inside an open folder had no
place on the grid, so focus fell to the page, and a keyboard user had to
start over from the top. Focus now goes to the folder's next app, to the
folder itself, or, when only one app was left and the folder dissolved,
to that app.
* fix(gui): show sizes under 1 KB as whole bytes
The size formatter wrote a size under 1 KB with two decimals and a
mismatched unit: a new account's storage on the dashboard read "0 Byte
Used", and a small file "149.00 Bytes". Bytes are now whole, with the
unit in the right number: "0 Bytes", "1 Byte", "149 Bytes".
* fix(dashboard): show the bottom edge of the Files drop outline in list view
Dragging files over the Files listing outlines it with a dashed border,
but in list view the bottom edge was missing: the listing cleared the
footer with padding, so its box ran under the footer and the footer hid
the outline's bottom edge. It now clears the footer with a margin, as
the grid view already did, so the outline shows all the way round.
* Fill the send-feedback popup with the feedback dialog
The popup opened by puter.ui.showFeedbackDialog() drew the floating modal
over a scrim. It now fills the window like the custom domain popup, with
the actions pinned to the bottom and the message box taking the spare
height. The full-page styles move from .custom-domain-fullpage to a shared
.app-feedback-fullpage on the modal shell.
* Land the first ArrowDown on the first <puter-user-menu> item after a click
Opening the menu with the pointer focused the first item without a visible
focus ring, so ArrowDown appeared to skip it. A pointer open now focuses the
menu itself; opening from the keyboard still focuses the first item.
* Show sign-in and file pickers in a frame on first-party pages
puter.auth.signIn(), showOpenFilePicker(), showSaveFilePicker() and
showDirectoryPicker() on a page one label under the Puter origin now
show the GUI popup in a frame over the page instead of a window. Every
other page, and any frame that can't serve the request, keeps the popup
exactly as before, so apps need no changes.
/embed/sign-in and /embed/show-{open-file,save-file,directory}-picker
render the normal GUI shell with the framing rules, query redirect and
no-store of /embed/apps, and name the framing page (read from the
referrer, pinned with frame-ancestors) as embed_host_origin. The GUI
runs the popup boot unchanged with that page as window.opener; close()
asks it to remove the frame. Popup parameters come from the fragment
(msg_id, options, request_auth, and user for a picker).
Sign-in has a close button that rejects with auth_window_closed, as
closing the popup does. A federated provider can't be framed, so
choosing one continues in the popup. attempt_temp_user_creation and
cross-origin isolated pages keep the popup.
Pickers frame only for a signed-in page whose user owns the GUI
session; otherwise the GUI reports unavailable and the page opens the
popup, through a consent dialog if the click has expired. The page
keeps its own token rather than adopting the one sent with the answer.
The feedback embed and the new frames share one loading/ready state
machine in guiEmbed.js.
* Close a framed picker when Escape cancels it
Escape closes only the picker window inside the frame, so the frame now
closes with it. The mismatched-user e2e test waits for sign-in's whoami
before naming another user, which it could otherwise overwrite.
* Close a framed sign-in on Escape
The sign-in shows as a page in the frame, which ignored Escape. Escape
now asks the framing page to close it, as its close button does, unless
a request is in flight.
* Focus a framed sign-in once the page focuses the frame
A cross-origin frame's focus() calls do nothing until the framing page
focuses it, so the sign-in opened with focus on the body: keys missed
the form and Escape never reached the dialog. The screen now takes
focus when the frame gains it, unless something already has it.
* Skip the frame on subdomains the server refuses to let frame
The SDK framed embeds from any page one label under the GUI origin, but
the server refuses its own subdomains (api, docs, apps, ...). On those,
sign-in and the pickers waited on a refused frame before opening the
popup, which Safari could then block for want of a recent click. They
open the popup straight away now, and the user menu hides Apps there,
as its docs say.
* Keep a framed picker on the framing page's user
With several saved accounts, a framed picker could still show the
account picker (no grant yet, or request_auth in its fragment). Picking
another account switched the session inside the frame, and the picker
then ran with the first user's details and the second user's token,
saving that token under the first account. A framed picker already
holds the framing page's user, so it no longer offers to switch, and
its user check no longer writes the boot's shared whoami.
* Take a save picker's file only from the page that asked
The save picker opened a dialog for every showSaveFilePickerPopup
message from any window. In a frame, a sibling frame of the framing
page can reach it, so it could supply the bytes the user then saved,
or stack extra dialogs. The picker now takes the file once, and only
from its opener.
* Reopen a framed embed that reloads itself into a refused frame
A reload or navigation the embed starts itself names the embed as its
referrer, so the server refuses the framing: logging out at an account
gate, a sign-out in another tab, or a reset session left a blank box.
The page now shows its spinner and opens the embed again itself, once
per ready, and falls back as for a refused frame if that fails too.
* Correct why a temporary-account sign-in keeps the popup
notes.puter.com and puter.com are the same site, so browsers don't
partition the frame's storage. The popup is kept because only it
creates a temporary account.
* Remove the picker's consent dialog once answered
The consent dialog a picker shows when its click has expired was
closed but left in the page after Continue or Cancel, one element per
fallback. It is removed now, as the other consent dialogs are.
* Name the embed dialog for assistive tech
The modal dialog around a framed embed had no accessible name, so
screen readers announced an unnamed dialog. It is labelled with the
frame's title.
* Expose an embed's loading status only while it shows
The spinner is faded out rather than removed, so assistive tech still
read a Loading status over a frame that was ready. The status is
hidden from it whenever the spinner isn't showing.
* Let a popup frame fill in texted codes
Signup and the account checks fill a texted code in through WebOTP,
which a cross-origin frame may only use when allowed. Popup frames now
allow otp-credentials, so the code fills in as it does in the popup.
* Close a popup frame on Escape from the page too
Once a popup frame was ready, Escape with focus on the page (after a
click on the scrim) only moved focus back into the frame, so it took a
second Escape to close, and none if the frame stopped responding. It
now closes the frame, as Escape inside it does. The feedback embed
still leaves dismissal to the embed.
* Keep scrolling over an embed's scrim from scrolling the page
A wheel or swipe over the scrim around a framed embed chained to the
page behind the modal. The dialog now contains its overscroll.
* Frame a GUI popup only at its exact path
Routes match case-insensitively and with a trailing slash, so another
spelling of /embed/<action> was served frameable while the shell booted
the desktop for it. Any other spelling now redirects to the exact path.
* Prefill a web save picker with the suggested name
The popup and framed save pickers ignored suggestedName and opened with
an empty filename, unlike the save picker an app gets in the desktop.
* Open a provider picked in a framed sign-in straight in the popup
Picking a provider in the framed sign-in fell back to the sign-in popup,
where the user had to pick the provider again. The frame now hands the
page the provider's start URL for its sign-in, and the page opens the
popup there after checking it is the GUI's OIDC start for that sign-in.
* Open the popup straight away for a page framed by another site
frame-ancestors on the embeds allows only the GUI and the framing page,
so a first-party page inside another site's frame waited about a second
on a refused frame before sign-in or a picker opened the popup, by which
time Safari asks for another click. Such a page opens the popup straight
away now, as it did before the frame.
* Close a framed sign-in on Escape at its account checks
Escape closed the framed sign-in on its auth screens but did nothing once
signup moved on to confirming the email, phone or card, which show as
windows. Escape there now closes the frame as well, as its close button
does.
* Leave Escape that cancels an IME conversion to the IME in sign-in
In a framed sign-in, Escape pressed to cancel an IME conversion in a
field also closed the sign-in, losing what was typed. The auth screens
and the account checks now leave an Escape mid-composition to the IME,
as the dashboard's text fields do.
* Fade in a popup frame's scrim and box
The scrim behind a framed sign-in or picker, and the box's white
background, appeared at full opacity in one frame while the GUI's content
faded in over them. Both now fade in briefly, unless the user prefers
reduced motion.
* Keep a page's own styles off a popup frame's dialog, frame and close button
The dialog, its frame and the close button live in the page's document, so
a page's global dialog, iframe and button rules reached them: a transform
that centers the page's own dialogs moved the overlay half off screen, an
iframe max-width shrank the frame inside its box, and button margins and
min sizes turned the close button into a pill. The dialog and frame now
reset those properties inline, and the close button starts from all:unset.
* Fall back to the popup when a reopened embed frame never loads
After a refused self-reload the page opens the embed again, but nothing
timed that second start: a stalled request left the loading spinner up
until the user dismissed it, which settled the request as closed instead
of opening the popup. The reopened frame now gets the same ready timeout
as the first start.
* Hand a save picker frame its file again after it reloads
The page answered a picker's sendMeFileData once and then stopped
listening. A framed save picker that reloads asks again, and without an
answer it never opened its dialog, so the page waited out the ready
timeout and fell back to the popup. A frame is now answered until it
closes; a popup is still answered once.
* Keep a popup frame's white backing off its rounded edge in dark mode
The box's white background shared the frame's anti-aliased corner, so a
light arc showed around the dark sign-in. The backing now stops 1px inside
the edge, which leaves the curve to the frame and the scrim.
* Center a popup frame's box with margins so it fills a phone edge to edge
On a phone with a fractional pixel ratio the translate that centered the
full-screen box left it a fraction of a pixel short of the top, so a line
of scrim showed above the sign-in. Margins snap to device pixels.
* Focus a framed save picker's file name as the popup does
A cross-origin frame's focus() does nothing until the page focuses it, and
the page focusing it again resets it, so the framed save picker opened with
nothing focused and typing went nowhere. A framed popup now gets its latest
focus() back whenever the frame gains focus with nothing focused, until the
user presses a key or the pointer in it.
* Replace a showing popup frame instead of stacking a second one
A double click on a page's picker button framed two GUIs, one over the
other under two scrims, and the user had to cancel twice. A popup reuses
its named window instead. Now a newer popup frame dismisses the one
showing, which settles it as closed.
* Share one framed sign-in between calls made while it's open
A double click on a page's sign-in button started a second framed
sign-in, which replaced the first and rejected it as closed while the
user was still signing in. A call made while a framed sign-in with the
same request_auth is open now settles with it.
* Close only a context menu on Escape over a file dialog
Escape to dismiss a right-click menu in a file dialog also closed the
dialog, which cancels a picker and, in a framed picker, removes the whole
frame. An open context menu now takes the Escape on its own.
* Document that one popup frame shows at a time
* Keep a save dialog's file name and buttons on a phone's screen
The save dialog's bottom bar couldn't shrink below the file name's own
width, so on a phone it spilled off both edges: the name's start and
the Save button were cut off. A framed save picker fills the phone, so it
always hit this. The name now shrinks to fit.
* Keep a file dialog's buttons on one line on a small phone
On a 320px-wide screen the open picker's Open button wrapped under
Cancel and ran into the screen's edge, and Upload wrapped under its
icon. The buttons now stay on one line, with less side padding up to
480px wide so the save dialog's file name keeps room to show.
* Say what a picker is for when its popup needs a click to open
When a framed picker falls back to the popup after the click expired,
the consent dialog said the page used Puter for AI and Cloud features,
not that it wanted a file or folder. It now names the picker, as the
push notification dialog does.
* Share a framed sign-in with a call that differs in request_auth
Implicit auth asks with request_auth, so an explicit signIn() made while
it was open replaced its frame and rejected every puter.* call waiting
on it. Any framed sign-in in progress is now joined.
* Keep a popup frame open when a frame for another popup shows
A sign-in replaced a showing picker frame, cancelling the picker, and
the other way round, though their popups have different window names and
stay open together. Only a frame for the same popup is replaced now.
* Reopen a refused embed frame in WebKit
WebKit took the refused URL as a fragment navigation of its error page
and loaded nothing, so a frame that reloaded itself fell back to the
popup. The frame is reopened through the server's query redirect, which
keeps the fragment.
* Go back to the save picker on Escape at its replace prompt
Escape closed the alert without an answer, so the save waited on it
forever behind the picker's busy mask. In a frame nothing could close
the picker after that. The alert closing now counts as Cancel.
* Leave Safari's IME-ending Escape alone at a framed sign-in's account checks
Safari sends the Escape that ends an IME conversion with keyCode 229 and
isComposing false, so it closed the framed sign-in. The auth screens
already check for it.
* Keep an open dialog's buttons whole on a small phone in German
At 320px, German's longer labels pushed the Open button past the screen
edge, since the upload label couldn't shrink and the buttons could. The
upload label now gives way, cut off with an ellipsis.
Signing up with a provider whose account already exists signs the user
in to it, then showed a blocking "You have been logged in to an existing
account" alert. In a sign-in popup it held back the token until the user
clicked OK, right before the popup closed. The sign-in now completes
without it, and the callback no longer adds oidc_switched to the return
URL.
The signed-in menu now shows the user's picture, username and email
(hidden when whoami leaves the email out), links to Manage account,
Usage and Open Puter on the GUI origin in a new tab, and Send feedback,
which calls puter.ui.showFeedbackDialog(). Each new item takes a label
attribute like the existing ones. The menu opens on its first item, and
the arrow, Home and End keys move between items.
The framed feedback dialog stayed hidden until the embed had checked the
GUI session, so on a slow network a click on Send feedback showed
nothing for seconds, and a second click silently resolved false. A
check that takes longer than 200ms now shows a scrim with a spinner over
the page, matching the embed's own scrim, which takes its place once
the dialog is ready. Escape or a click on it cancels, and a refused
session still falls back to the popup.
WebKit gives a frame the color-scheme of its iframe element as its
preferred scheme, and the feedback embed's frame was set to normal, so
Safari in dark mode showed the light dialog. The frame and the embed's
root now both declare light dark: the embed follows the OS in every
engine, and the frame stays transparent because the two match.
* Show the app feedback dialog in a frame on first-party pages
puter.ui.showFeedbackDialog() on a page one label under the Puter origin
now frames /embed/feedback in a modal over the page, instead of opening a
puter.com popup. The embed uses the same framing rules, query redirect,
and host-user binding as /embed/apps, and sends the feedback for the
framing page's origin, which the server reads from the referrer and pins
with frame-ancestors. Without the named user's Puter session, or with an
email still to confirm, the embed reports itself unavailable and the SDK
falls back to the popup, which can sign in. A frame that never answers
(such as one the host's CSP blocks) falls back too.
The embed session check moves out of appsEmbed.js into a shared helper,
and the first-party page check out of <puter-user-menu> into puter.js's
lib, so both embeds use one copy of each.
* Keep the framing page from scrolling behind the feedback embed
A wheel or touch scroll over the feedback embed's scrim chained out of
the frame and scrolled the first-party page underneath the open dialog.
The embed's root now stops overscroll from leaving the frame.
* Name the feedback embed's user from the cached whoami
showFeedbackDialog() on a first-party page fetched /whoami before it
started loading the embed, adding a round trip to every call. It now
uses the user puter.js already cached for the session and asks the API
only when there is none.
editor, markus and pdf moved to standard host routing, but the host
redirect and embed framing tests still used editor as their native app,
so they failed. They now use about, and a test checks that editor,
markus and pdf are passed through even with a folder under the native
apps root.
The dashboard's Contact Us now uses the dashboard form dialog instead of a
UIWindow: a centered card on desktop and a bottom sheet on phones. Files can
be picked, dropped anywhere over the dialog, or pasted into the message, and
images show a preview. Cmd/Ctrl+Enter sends. The desktop keeps
UIWindowFeedback.
The signup screen showed its email form, then looked up the identity
providers and folded the form away behind "Sign up with email" once they
arrived, a round trip later. Every screen made its own lookup, so moving
from login to signup redrew the card each time.
The provider lookup now happens once per auth flow, so moving between
login and signup draws the providers before the next paint. When signup
is the first screen, its email form stays hidden until the lookup
answers, or for 1.5 seconds if it is slow, and shows when no providers
are offered or the form was opened prefilled.
Updates the English translation for `popup_opener_uses_puter` to a shorter single-line message. This removes the extra explanatory text about AI/cloud features and using a Puter account, likely for a cleaner popup prompt.
Reduce the signup terms text in the auth dialog to match the tighter dashboard layout. This is a small styling adjustment for the dialog copy to keep it balanced and readable.
Someone logging in already has a Puter account, so like the account
picker the login screen no longer explains that the site uses Puter.
The signup screen keeps it.
The account picker reads pictures from the logged_in_users list in
localStorage, but update_auth_data saved that list with an empty profile
and only added the picture in memory once the profile loaded. A picture
reached storage only when a later account switch or sign-out rewrote the
list, so the active account usually showed just its initial.
update_auth_data now keeps the account's saved profile until the fresh
one loads, then saves the user and the account list again. The profile
loads after puter.js holds the new account's token, so a switch can no
longer fetch the previous account's profile, and a profile that arrives
after another account signed in is dropped.
The first-visit migration in globals.js now stores the account list as
JSON instead of "[object Object]".
* Show the app browser embed's session only to the host's signed-in user
/embed/apps now reads the host page's signed-in user from its fragment
(#user=<uuid>) and asks /whoami which user the GUI session belongs to.
It shows the user's apps only when the two match. A host that names no
user, a malformed one, or another user, and a session the API refuses,
get the signed-out view of recommended apps with no SDK and no app
tokens. A failed /whoami check shows the unavailable state. Changing the
fragment reloads the embed.
Hosts pass the uuid from puter.auth.getUser() and remove the iframe when
their own sign-in changes; the doc's example does both.
* Add <puter-user-menu> for first-party apps on Puter subdomains
puter.js registers a <puter-user-menu> element that follows the page's
own puter.js sign-in. Signed out it shows Sign in; signed in it shows the
user's profile picture, or their initial when there is none, with a menu
holding the username and Sign out. On a page one label under the Puter
origin, an Apps button opens /embed/apps in a modal with the signed-in
user named in the fragment, so the app browser shows the Puter session's
apps only to that user. Any change to the page's sign-in closes the app
browser. The element renders nothing outside a top-level page.
Its labels can be set with apps-label, sign-in-label, sign-out-label,
close-label and account-label, and it fires signin and signout events.
It is documented internally in components.md and the embed doc, and is
left out of the public docs because the app browser only works on Puter
subdomains.
* Show <puter-user-menu>'s app browser in a panel under its button
The Apps view and the account menu are now panels that hang under their
button with a caret, closing on Escape, an outside press or the button.
They use the top layer where the browser has one, so a host's transform,
overflow or z-index can't clip or cover them. On screens up to 600px wide
the Apps panel spans the width below the button.
Closing the Apps panel keeps the app browser loaded; a change to the
page's sign-in still discards it. A spinner shows until it loads, and its
colour scheme follows the component's theme, which follows the OS unless
the theme attribute is set.
A temporary account counts as signed out: it gets Sign in, no profile
picture request, and an app browser with no user named. The signin event
carries the signed-in user, and the component exposes the parts
apps-button, avatar, sign-in, menu and apps-panel for host styling.
* Read the user <puter-user-menu> signs in as after the SDK starts its whoami
setAuthToken notifies auth listeners before it starts the new token's
/whoami, so a sign-in on a loaded page showed '?' and opened the app
browser without naming the user.
* Close <puter-user-menu>'s account menu when Tab leaves it
Focus moved on to the page with the menu still open, where Escape no
longer reached it. Tab now closes the menu and moves on from its button.
* Move focus to Sign in after signing out from <puter-user-menu>
The focused menu and avatar disappear on sign-out, which dropped focus
to the page body.
* Show the app browser in the theme <puter-user-menu> forces
Chromium doesn't pass an iframe's color-scheme on to the page inside it,
so a forced theme that differed from the OS showed the app browser in
the OS's theme inside a panel in the forced one. The component now
passes a forced theme as theme= in the fragment, and the embed applies
it to its color-scheme rules.
* Show <puter-user-menu>'s account before its profile picture loads
The avatar and Apps button waited for the /profile request as well as
the user, so a slow profile kept them hidden. The picture is now added
when it arrives.
* Open one sign-in popup at a time from <puter-user-menu>
Clicking Sign in again while its popup is still open opened another popup each time. Further clicks are ignored until the sign-in finishes or its popup is closed. A cross-origin isolated page can't see its popup close, so it keeps the old behavior rather than locking the button.
* Keep <puter-user-menu>'s Apps button unshaded after a tap closes it
Touch screens keep :hover on the last tapped element, so tapping the Apps button to close the panel left it shaded as if the panel were still open. Hover shading now applies only where the device can hover; the open state still shades the button everywhere.
* Show <puter-user-menu>'s Sign in pressed and its account hovered
Sign in had no pressed state and the account button no hover state, so neither responded to the pointer. Sign in now takes the pressed look of Puter's other primary buttons, and the account button shows its open-menu ring on hover where the device can hover.
* Show a spinner in the app browser embed until its apps arrive
The embed stayed blank while it checked the session with /whoami and loaded the SDK. <puter-user-menu> hides its own spinner once the embed's page loads, so its panel went blank for that whole time. The empty embed now shows the same spinner in the same place until it renders its apps or its unavailable message.
* Offer a custom domain when publishing a folder from the dashboard
The Files tab's Publish Website dialog has a "Use a custom domain" switch.
It appears only when the server has custom domains. Turned on, it asks for
the domain, with the monthly price and suggestions from the domains already
on the account. Picking one of those moves it to the new site instead of
adding it again.
Publishing creates the site, then adds the domain. The done screen shows the
live address and the domain's status with the DNS records to set, each with
a Copy button, and a Check Status button. A failure after the site is live
keeps the site: the address locks, and the button retries only the domain.
A domain held by another account shows its ownership record to verify.
On a plan without custom domains, the switch shows an upgrade prompt in
place of the field and opens the upgrade flow where the deployment has one.
Once the plan changes, the field unlocks in place.
The /custom-domains request, error wording, price format and status-check
merge move to helpers/customDomains.js, shared with the desktop's attach
dialog.
* Name the served and forwarding domains the right way round in the redirect note
The note reads "served on {first}, forwarded from {second}", but both the
publish dialog and the attach dialog passed the root domain first.
* Fit DNS record labels of any length in the publish dialog
The label column was a fixed 44px, so a longer translation ("Pangalan")
ran under the field beside it. Each record card is now one grid whose label
column grows to its longest label, keeping the name and value aligned.
* Run one domain action at a time on the publish done screen
Check Status and the switch to www each disabled only themselves, so both
could be in flight and a slow status check could redraw the root domain
over the www one it had just become.
* Keep focus on the custom domain switch when it shows the upgrade prompt
Turning the switch on moved focus to Upgrade, so a second Space meant to
turn it back off opened the upgrade flow instead. The prompt follows the
switch in tab order.
* Finish with the live site when the plan can't add its domain
Once the site was live, a 402 from adding the domain left the button
reading Add Domain, and pressing it said to turn the switch off "to publish
without one". The button now reads Done and finishes with the site; an
upgrade brings the add back.
* Keep the ownership record and the Verify step in step while typing
Any edit hid the claim's TXT record for good, but the button went back to
Verify and Add as soon as the claimed hostname was typed again, asking to
verify a record no longer on screen. Both now follow the typed hostname.
* Keep the publish done screen up through a stray backdrop click when it lists DNS records
The records to set at the DNS provider appear only on this screen; a click
outside the card while switching to the provider's tab threw them away.
Done, Escape and the close button still close it.
* Listen for plan changes only once the publish dialog has offered the upgrade
Every website publish dialog added a window listener that held its closed
dialog until the next plan change, which may never come. The dialog is
modal, so the plan can only change after its Upgrade button opened the flow;
the listener is added then.
* Keep the upgrade prompt's text beside its icon on phones
Wrapping put the text on its own line under the icon, leaving the icon
alone on the first row; now only the button wraps.
* Drop "After publishing" from the domain hint once the site is live
When adding the domain needed another try, the hint still promised DNS
records "after publishing" for a site already published; it now keeps
only the price.
* Announce publish domain checks in a status region that stays on screen
Each check redrew the domain section, status line included, so its result
landed in a brand-new live region that screen readers don't announce, while
the redrawn records sat in the done screen's status region. The summary is
now redrawn on its own, outside the announcements.
Each refresh of an item container now takes an id, and only the newest
refresh may write to the container or its window. Before, a slow stat for
the folder a window had left could point the window back at it, so New
folder, Paste and Upload went to the old folder while the window showed
the new one. A listing that arrived just before navigating away added its
rows to the new folder, and returning to a folder while its first listing
was in flight rendered every row twice. The stat in update_window_path
also skips its writes once the window has moved to another path.
The desktop context menu (UIContextMenu) stayed white with dark text when
the OS was in dark mode, even though the dashboard around it turns dark.
It now follows prefers-color-scheme like the dashboard: a dark translucent
surface, light text, and dark variants for dividers, the open-submenu and
blurred highlights, the submenu chevron, monochrome icons, and the mobile
action sheet.
A sign-in page already fills the screen, so it no longer shrinks itself
to the area above an on-screen keyboard. On iOS that resize fought the
browser's own pan to the focused field and the form moved up and down
as the keyboard opened. Dialogs still fit above the keyboard.
Signed out, /action/login, /action/signup, /action/password-recovery and
the sign-in page for returning visitors, app landings and share links no
longer load the wallpaper behind them, and the page covers the screen at
once instead of fading in over whatever was there.
The signup screen no longer explains that only providers are offered when
email signup is closed; it just shows the provider buttons.
* Move login, signup, account picker, password recovery and 2FA to dashboard-style dialogs
The sign-in surfaces were separate UIWindows that opened over each other
(login, then a recovery window over it, then a 2FA window over that). They
are now screens of one dashboard-style card built on mountDashboardOverlay,
the same shell, fields, buttons and status line as the dashboard's account
dialogs. Screens replace each other in place, with Back, and keep what was
typed.
- Page presentation for cover pages and sign-in popups: an opaque page with
a centered card, filling the screen on phones and in popups. Dialog
presentation elsewhere: dimmed backdrop, centered card, bottom sheet on
phones.
- Each call site keeps whether it could be dismissed, its promise contract,
OIDC gating in popups, Turnstile, the honeypot, bonus codes, the
post-signup verification gates, reauth auth_id, and redirect handling.
- Keys typed in the card no longer reach the desktop's document shortcuts.
- A second sign-in prompt joins the open flow instead of stacking a card.
- Server messages render as text; prefilled values are encoded.
* Show the signup screen's errors above the provider buttons
The status line lived inside the email form, which hides behind the
provider buttons (or for good, when only provider signups are open), so
an error the screen opened with, like a refused provider signup, was
never shown.
* Cover the page with a new account's checks when signup was a page
Signup as a page (a sign-in popup, /action/signup) closes its card for
the email, phone and card checks; they opened as floating windows over
an empty backdrop instead of covering the page as they did before.
* Give the auth card's Back and close bar a background once it scrolls
On a short screen the card's content scrolled under the bare Back and
close buttons, which then sat on top of it: the Back chevron covered the
edge of a provider button and took its taps. Once the showing screen is
scrolled, the bar turns opaque with a divider, as the footer has; at
rest nothing changes.
* Raise a failed session-cookie sync alert above the sign-in card
Signing in waits on this alert with the auth card still up. The card
sits above every ordinary window, so the alert opened underneath it and
the login spun forever. It now opens in the stay-on-top band.
* Keep focus where it is when a second sign-in prompt joins the card
A prompt that joins the open auth card focused the screen's first
field, so one arriving while the user typed their password sent the
rest of it into the username field. It now only brings focus back when
it has left the card.
* Keep focus in the auth card while its form is busy
Submitting disabled the focused field, which dropped focus to the page,
so keys pressed while the request was in flight (Delete, Enter, letters)
reached the desktop's document shortcuts.
* Keep focus in the auth card when its backdrop is pressed
A press on the dimmed backdrop (or a page's background) that didn't
dismiss the card moved focus to the page, so the next keys went to the
desktop's document shortcuts and Tab could leave the card.
* Point the Turnstile migration note at the new signup screen
UIWindowSignup no longer exists; the widget renders in UI/Auth/UIAuthSignup.js.
* Reload after sign-in when only the URL's hash would change
On a dashboard tab (/#files) the redirect after a login or signup was
the same page with a hash, so location.replace only scrolled: the new
account was signed in but the page never reloaded, and the card stayed
up, busy, until closed.
* Span the screen with the auth bottom sheet on wider phones
The card's 400px cap outranked the dashboard sheet's full width, so on
phones between 401 and 600px wide the sheet left gaps on both sides.
* Return focus to the auth form when a request fails
A busy card parks focus on itself while its fields are locked. When the
server refused a signup, focus stayed there, so Enter did nothing and the
user had to tab back in. Ending busy now hands focus back to the control
that had it, once that control is enabled again.
* Keep the current account when a login is dismissed mid-request
Closing the auth card while its login request was out still signed the
browser in to the other account and reloaded once the server answered,
though the caller had already been told the user dismissed it. A login
that answers after the card closed is now dropped.
* Make the auth card's logo a link where it leads somewhere
On a cover page the Puter logo was an image with a click handler: no
keyboard access, no name for screen readers, no middle-click. It is now a
link named Puter. Everywhere else the logo is marked decorative, since the
title beside it names the screen.
* Return to the login screen when signing in fails after a 2FA code
If storing the new session failed after a verified code, the error went
to the hidden login screen while the code screen stayed up, busy, with
its boxes locked. The card now goes back to the login screen first.
* Hand the session cookie back when a login is dismissed mid-request
The server sets the session cookie as soon as it accepts a login or a
second-factor code. Dropping an answer that arrived after the card closed
kept the page on its account while the cookie had moved to the other one,
so cookie-gated actions (password, 2FA) applied to that account and the
next load picked up its token. The cookie is now synced back to the
account the page is signed in as.
* Keep padded notices inside the auth page's column
On a phone-width page or in a sign-in popup, every direct child of the
auth body is stretched to the column's width. The overlay sits outside
.dashboard, so it isn't border-box, and the padded error line and bonus
notice came out wider than the fields and scrolled the page sideways.
* Keep the recovery-code field one size when it takes focus
On phones the generic dialog-input and phone input rules outranked the
recovery-code field's style until it was focused, so tapping it grew the
text from 16-17px to 20px and shifted the screen. The field's rule now
carries the same weight as theirs, like the code boxes beside it.
* Show the provider buttons' hover in dark mode
The hover shade was the sidebar background, which in dark mode is one
step off the card's, so Google, Apple and the other sign-in buttons gave
no feedback under the pointer. Dark mode now uses the hover shade.
* Theme the signup bonus notice with the auth card
The notice kept the old signup window's fixed pastel colors, which stood
out as a light box on the card in dark mode. It now uses the dashboard's
success and danger colors, which follow the color scheme.
* Keep a new account's email check from closing without an answer
A signup that needs its email confirmed takes the auth card down and
waits on the confirmation window, whose ✕ closed it without ever
answering. In a sign-in popup reached from the account picker that left
an empty popup with nothing to pick. The check now stays until the code
is confirmed, like the phone and card checks and the boot-time email
check.
- lowerDecoratorsPlugin dropped esbuild's source map, so coverage and
stack traces reported transformed lines. Pass the external map through.
- puter.js node runner kept a ~6 MB `__coverage__` per vm context and ran
out of heap. Contexts now share one object; workerd merges as it goes.
report-coverage skips its comparison when the PR leg failed.
- CI-only test/hook timeouts of 20 s / 60 s.
- setupTestServer disables the database-liveness health check, whose
`SELECT 1` landed in query-counting tests.
- LocalWorkerService.shutdown asserts the timer shutdown arms instead of
the global fake-timer count.
- userHandlers worker test waits for posts by label.
- AppStore stats test pins the clock so rows don't cross midnight UTC.
- Realtime backpressure test waits up to 5 s for holds to settle.
Updates `RECOMMENDED_APP_NAMES` to prioritize core productivity apps (`word-processor`, `spreadsheet`, `presentation`, `whiteboard`, `pdf-editor`) immediately after `builder`, and removes `terminal` from the recommended list.
* Show recommended apps in the app browser embed without a session
/embed/apps only worked for a signed-in user; without a session it showed
an unavailable state. The route now puts the recommended apps
(RecommendedAppsService) in the page's config as guestApps, and when the
embed finds no session it shows those, without loading puter.js or reading
installed apps, folders, or saved order. Selecting one opens it at the
same URL a signed-in launch uses, with no opener and no token on offer, so
the app signs in on its own; apps that need the desktop open /app/<name>.
Signed-in behavior is unchanged.
createAppBrowser takes a guestApps option for this, and appTabLaunch's
URL building is shared between launchAppInTab and the new
guestAppLaunchUrl.
* Show the app browser embed's error state when the session lookup fails
Only a 401 from /get-gui-token now means there is no session. A rate
limit or server error shows the unavailable state, as before, instead of
the signed-out list of recommended apps.
* Let the app browser embed find no session when storage is refused
Reading local storage can throw when the browser blocks site data. The
embed now falls back to the session cookie, as it does for empty
storage, so a visitor without a session sees the recommended apps
instead of the unavailable state.
* Fix the app browser embed doc's broken sentence about puterOrigin
Safari keys a framed origin's local storage by the top-level origin, so the
app browser embedded on a sibling subdomain finds no auth_token_v2 and shows
"Apps are unavailable". The session cookie is still sent there, so the embed
now falls back to /get-gui-token and keeps the token in memory.
/embed/apps now names the referring origin in frame-ancestors only when it
is exactly one label under the domain and that label is not one the server
answers itself (api, dav, js, www, native-app hosts) or the host of
api_base_url. Nested subdomains are refused too.
The query-string redirect now checks the raw URL instead of the parsed
query, so every request carrying a query string is redirected.
On a website, puter.notifications.enable() threw user_gesture_required
without prompting once the calling click's activation was gone: after
puter.auth.signIn() or requestPermission() opened a popup in the same
click, after a few seconds of awaited work, or when called on load. Apps
that ignored the error showed nothing.
It now shows Puter's consent dialog in that case and asks the browser
from its Continue click; Cancel resolves false. Calls made while an ask
is open share it. PuterDialog gets an onContinue option and a show()
that launch() also uses. Inside Puter's own desktop page the call still
throws, since it always runs from a click.
The push guide also notes that Chrome stops showing the prompt after
three dismissals and that its quieter messaging shows a bell icon.
Recently used apps on the Home tab opened /app/<name> in a new tab, which
loads the desktop and launches the app from there. They now open the way
Apps tab tiles do: a new tab at the app's index_url through launch_app's
newTab, or in this page when the browser refuses the tab. The Apps tab's
open-window lookup moves to helpers/focusAppWindow.js so both share it,
and the shared launch lives in helpers/dashboardAppLaunch.js, outside the
app browser so the embed bundle doesn't pull in launch_app.
The embed page always loaded /dist/apps-embed.min.js from the server,
while the GUI bundle and the embed build are published to gui_bundle's
location (a CDN in production). The server's /dist copy can be stale, so
the embed ran an old build. It now loads apps-embed.min.js from
gui_bundle's directory, keeping /dist when gui_bundle isn't set.
* Open apps from the embedded app browser at their own URL
Selecting an app in the /embed/apps browser now opens it in a new tab at
its index_url, with the same launch params the dashboard's tab launch
uses, instead of the GUI's /app/<name> landing. The tab is opened inside
the click and without noopener; the app's SDK asks the embed for its
token through the opener, so the token stays out of the URL.
Apps that need the desktop (built-in and godmode apps, private apps the
user can't open, or a failed token request) still open /app/<name>, with
the tab's opener cleared. Website shortcuts are unchanged.
The app URL, the app token request and the /rao report move out of
launch_app into helpers without desktop globals (appLaunchUrl.js,
appLaunchApi.js), so launch_app and the embed build the same URL.
uuidv4 moves to a helper the embed can import.
* Tag app launches with utm_medium and a utm_campaign naming the launch surface
Apps now get utm_medium=referral and utm_campaign=desktop, dashboard, or
app_browser_embed alongside utm_source=puter.com, so their analytics can
tell where in Puter they were opened from.
* Open dashboard app tiles in a new tab at the app's index_url
Clicking an app tile in the dashboard now opens the app in a new browser
tab at its index_url, with the same query params (puter.app_instance_id,
puter.app.*, puter.auth.token, puter.domain, puter.api_origin,
puter.locale, puter.args, ...) its in-page iframe gets.
launch_app takes a newTab option: it runs the normal launch (private
access, file signing, token) and sends the caller's tab to the built URL
instead of opening a window. The tile click opens the blank tab inside
the click so popup blockers allow it, and closes it if the launch fails.
* Hand an app tab its token through the opener, not the URL
An app opened in its own tab no longer gets puter.auth.token in its URL,
which kept the token in browser history, and which puter.js ignored anyway
outside an iframe.
The GUI now holds the token for the tab it opened. Once the app loads,
puter.js (standalone, with puter.app_instance_id in its URL and an opener)
asks its opener for it at the configured GUI origin, takes only a reply
from that window at that origin, and then clears window.opener. The GUI
answers once, only to that tab at the origin it sent the tab to, and
drops the offer after two minutes. Sign-in and implicit auth wait for the
handoff, then fall back to the normal sign-in flow when no token came
(opener closed, slow load, redirect to another origin).
Built-in apps join explorer in opening in-page, since they belong to the
desktop.
* Open files from the dashboard Files tab in a new tab
Opening a file in the Files tab (double-click, Enter, tap, or the Open
menu item) now opens its app in a new browser tab, the same way app
tiles do. The tab is opened inside the gesture and handed to open_item,
which passes it to launch_app or closes it when the file opens no app.
Web links reuse the tab, cut off from the dashboard. A second file of a
multi-select Enter, whose tab the popup blocker refuses, opens in-page.
The opened file's signed read/write URLs don't expire, so a tab gets
them through the opener handoff with its token instead of in its URL;
the URL keeps only puter.item.uid as a marker. puter.js delivers the
handed-over item to onLaunchedWithItems / onItemsOpened, and
wasLaunchedWithItems answers true while it's on its way.
launch_app now closes a tab it was given whenever the launch fails, so
callers don't each have to.
* Open "Open With" apps from the Files tab in a new tab
Picking an app under Open With in the dashboard Files tab now opens it
in a new browser tab, like opening the row. The tab is opened in the
menu click, or, when the "always open with" prompt comes first, right
after it's answered, since that click counts as a fresh user gesture.
If the popup blocker refuses the tab, the app opens in-page. The desktop
keeps opening in-page.
* Wait for a GUI-opened tab's token in getUser, whoami and getProfile
A tab the GUI opens for an app gets its token over postMessage moments
after puter.js loads. These calls short-circuited on a missing token, so
an app reading the user at startup failed with a 401 (or a null profile)
even though the token was on its way. They now wait for the handoff
first, the same way sign-in already does.
* Close an app's blank tab before alerting that its launch failed
A launch denied for a private app, or one whose token couldn't be
fetched, awaited its alert while the blank tab it was given stayed in
front. The alert renders on the dashboard behind it, so the user was
left on an empty tab until they went looking. The tab is now closed
before the alert shows.
* Launch an app tile in-page when the browser refuses its tab
When window.open returned null (a popup-blocking setting, or a context
that can't open tabs), clicking an app tile did nothing at all. It now
falls back to the in-page launch it had before, as opening a file from
the Files tab and Open With already do: an open window is surfaced,
otherwise the app opens maximized with the tile's open morph.
* Open no tab for a file opened from the Trash
Opening a file in the Trash only explains that it can't be opened, but
the Files tab still opened a blank tab for it first, which then closed
again: a flash of a new tab before the alert. Trashed files (by the
path a shortcut resolves to) now get no tab.
* Don't count a tab launch as one that can report interactivity
launch.ipc_tracked on app-is-ready is the denominator for the
app-interactive series: launches that could produce one. An app opened
in a new tab never signals over IPC, yet was recorded as tracked, so
every tab launch counted as an interactive span that never came. Tab
launches are now recorded as untracked.
* Leave openerLaunch_ unset when there is no GUI opener to ask
requestLaunchFromOpener resolved null asynchronously even when there was
no opener at all, so openerLaunch_ was briefly set on every boot with an
app instance id in the URL. A reloaded app tab (opener already cut off,
URL still marking the opened item with puter.item.uid) reported
wasLaunchedWithItems() as true to code running in the same task as the
boot, though no item would ever arrive. With no opener to ask it now
returns null synchronously and nothing is left pending.
* Call an app tab launch off when the user closed its tab
Closing the blank tab while the launch's fetches were in flight didn't
stop the launch: it still navigated the closed tab, offered it a token,
and recorded the app as opened (recent apps and /rao). The launch now
checks the tab before sending the app there and ends as not launched,
without an alert, when the user has closed it.
* Keep a same-origin opener instead of asking it for a GUI launch
Any top-level page with an app instance id and an opener asked that
opener for its launch, waited out the timeout when no answer came, and
then nulled window.opener. A page an app opened from its own origin (a
pop-out of itself, say) can't have the GUI as its opener, yet it waited
five seconds before its launch item and sign-in, and lost its link to
the app window that opened it. An opener readable from this page, on an
origin other than the GUI's, is now left alone and not asked.
Each response that starts runs `ai.prompt.validate` on the text the client
sent since the previous check (instructions, message text and transcripts,
function call output), and its usage follows as `ai.prompt.cost-calculated`
under the same completion id. A refusal closes the session with
`permission_denied`. Nothing runs when no host listens for the check.
* Add embeddable Puter toolbar and shared app browser
Provide deployment-scoped iframe routes, a responsive host helper, read-only app browsing, saved-account switching, and new-tab account and upgrade links. Validate account sessions before switching and keep credentials inside the GUI origin.
Verified with 182 targeted tests, production GUI build, backend type checking, lint, and a real-backend browser smoke test. Translation checker could not run because tools/check-translations.js is missing.
* Prevent embedded toolbar from scrolling when menus open
* Stop double-encoding translated toolbar labels
i18n() already HTML-encodes its result, so wrapping it in escape() again
turned accented labels into literal entities (e.g. "Schließen") for
screen readers, and .text() did the same for the error message.
* Load only the preferred language in the toolbar and app browser embeds
Importing i18n.js pulled every translation into each embed, making the
toolbar bundle 1.1 MB. The embeds now bundle English and fetch the user's
language on demand (toolbar 1.12 MB -> 179 KB, app browser 1.18 MB -> 237 KB).
The desktop bundle keeps all translations.
* Keep toolbar buttons in place when a menu opens near the viewport edge
On phones the expanded frame was clamped to an 8px viewport margin, which
moved the whole toolbar row (by the header padding minus 8px) under the
user's finger. The frame now shrinks to fit beside and below the collapsed
toolbar, and only falls back to shifting when there isn't enough room.
* Don't pull focus back into the toolbar when the host page closes its menu
Clicking a host text field while a toolbar menu was open closed the menu and
then refocused the toolbar button, so the host field lost focus and typing
went nowhere. Focus is now restored only while the toolbar document has it.
* Close toolbar menus when the empty part of the expanded frame is pressed
While a menu is open the toolbar frame covers host content beside the
buttons; presses there reached neither the host nor the toolbar's outside
click handling, so the menu stayed open. They now close it.
* Omit the empty email line in the toolbar account menu
Temporary accounts and saved accounts without an email rendered an empty
element, adding a stray gap under the username.
* Stop user-hosted sites from framing the toolbar and app browser embeds
frame-ancestors allowed every *.<domain> subdomain, which on the default
self-hosted layout includes user sites (*.site.<domain>) and private apps
(*.app.<domain>), letting any hosted page clickjack sign-out and account
switching. The policy now names the embedding page exactly, from the
request's referrer, and refuses the user-content hosting domains.
* Use the dashboard danger color for the toolbar sign-out confirmation
The hardcoded red had 3:1 contrast on the dark panel, below WCAG AA for
14px text. The dashboard's danger token is 4.8:1 in light and 6:1 in dark.
* Drop the dashboard's hamburger clearance from the embedded app browser
At narrow widths the dashboard pads the search 60px down to clear its fixed
menu button. The embeds have no such button, so the toolbar's app panel
lost 90px of a ~500px frame to empty space above the search.
* Keep the toolbar's Upgrade button legible in dark mode
Upgrade was filled with the link color, which dark mode lightens to
#6b8cff: 3.1:1 under white text. Use the dashboard's primary-button fill
(4.6:1 in both themes) and its hover color, and drop the button classes
nothing styles on this page.
* Keep the toolbar's apps icon legible on hosts in the other theme
The toolbar follows the OS theme but draws on the host page, so in dark
mode on a light host its icon was #d4d4d4 on white (and the reverse on a
dark host). Give the apps toggle a surface of its own, like the avatar
beside it, and the session-unavailable notice one too.
* Let the toolbar sign out of a session that already ended
When the session was ended elsewhere (revoked, or signed out in another
tab), the anti-CSRF and logout requests answer 401 and sign-out failed
on every retry, stranding the stale token. Treat 401 as already signed
out and finish clearing it locally, as the desktop does; other failures
still keep the session and show the error.
* Keep the toolbar's slot following the host layout while a menu is open
The helper reserved the toolbar's space with a span frozen at the
frame's resolved size and margins, so a host that right-aligns it with
margin-left: auto (or sizes it in percentages or media queries) left the
open menu anchored to a stale spot, overflowing the header, after a
resize or rotation. Reserve it with an empty clone of the frame instead,
which the page's own rules keep laying out; if they target
data-puter-toolbar, which the clone drops so hosts still find one frame,
hold the frame's measured size.
* Reuse isOrgSeat for the toolbar's Upgrade check
The toolbar re-implemented the dashboard's organization-seat test
inline. Use the shared helper so both surfaces agree on who sees an
upgrade offer.
* Point comments at AppBrowser.js, where the Apps tab code now lives
The constants and methods these comments name moved out of TabApps.js
when the app browser became shared.
* Restore the license header on the new embed and toolbar files
TabApps.js lost its AGPL header when its code moved to AppBrowser.js,
and the new embed, toolbar, and host-helper modules were added without
one; every other GUI source file carries it.
* Remove the embeddable toolbar, keeping the standalone app browser
Drop /embed/toolbar, its page and host helper, their styles, strings and
tests, and the dashboard's ?upgrade=1 deep link that only the toolbar's
Upgrade button used. /embed/apps no longer allows the GUI origin to frame
it with a wildcard over deployment subdomains, which existed only for the
toolbar's nested app browser, and its bootstrap config no longer carries
the domain the toolbar read.
2026-11-05 17:19:05 -07:00
1155 changed files with 64874 additions and 34191 deletions
- [doc/contributing-apis.md](doc/contributing-apis.md) — adding and maintaining public APIs end to end (backend surface → puter.js → types → docs → tests). Follow it for any API work.
- [doc/ai-drivers.md](doc/ai-drivers.md) — where AI driver code lives (interfaces, providers, protocols, shared), the import rules between those folders, and how to add a provider or an AI interface.
- [doc/pagination.md](doc/pagination.md) — the one pagination convention for list APIs (limit/cursor/offset/includeTotal, envelope shape, cursor semantics).
- [doc/alarms.md](doc/alarms.md) — raising alarms and picking a severity (what pages, what only gets recorded), plus the config that routes them.
@@ -70,7 +71,7 @@ Commit messages and PR descriptions never credit anyone or mention a bug report.
- **Test new behavior.** Every new function, endpoint, driver method, or logic branch gets a test; every bug fix gets a regression test that fails before the fix. If something is genuinely hard to test, skip it but say so in the PR.
- **Boy Scout Rule, proportional to the change.** In files you're already touching, fix the typo, the dead import, the missing test, the bit you had to read twice. Don't ride a refactor along with a bug fix.
- **Understand what you commit.** AI assistance is fine; shipping code you couldn't have written, debugged, or defended in review is not.
- **A limit change is not done until the docs change with it.** Every rate limit, concurrency cap, quota, and allowance in the code is published in [src/docs/src/rate-limits-and-quotas.md](src/docs/src/rate-limits-and-quotas.md) — an undisclosed limit is one developers discover as a service failure. If a PR moves any of these numbers, the same PR updates that page. The numbers live in `src/backend/controllers/fs/limits.ts` (filesystem), `src/backend/drivers/util/aiLimits.ts` (all AI drivers), per-driver `rateLimit`/`concurrent` configs, and `src/backend/data/subPolicies/` (free-tier allowances).
- **A limit change is not done until the docs change with it.** Every rate limit, concurrency cap, quota, and allowance in the code is published in [src/docs/src/rate-limits-and-quotas.md](src/docs/src/rate-limits-and-quotas.md) — an undisclosed limit is one developers discover as a service failure. If a PR moves any of these numbers, the same PR updates that page. The numbers live in `src/backend/controllers/fs/limits.ts` (filesystem), `src/backend/drivers/ai/shared/aiLimits.ts` (all AI drivers), per-driver `rateLimit`/`concurrent` configs, and `src/backend/data/subPolicies/` (free-tier allowances).
---
@@ -97,6 +98,10 @@ Layer rules:
- **Drivers and controllers stay thin.** Domain rules a second entry point needs (signup, app CRUD, worker deploy, AI routing/billing) live in a service, not in the first driver or controller that needed them.
- **Prefer explicit arguments over [`Context`](src/backend/core/context.ts) (ALS).** Use it only for request-scoped values that would otherwise thread through many layers; today that's mostly `actor` and `req`.
### AI drivers
AI drivers live in [src/backend/drivers/ai/](src/backend/drivers/ai/): `interfaces/` holds the Puter-facing drivers, `providers/<vendor>/<interface>/` holds vendor code, `protocols/` holds wire protocols several vendors speak, and `shared/` holds helpers more than one interface uses. Follow [doc/ai-drivers.md](doc/ai-drivers.md) when adding or moving AI code.
### Extensions
[extensions/](extensions/) is for **non-crucial parts of the system**: Puter must still work with the extension removed. If core needs to call it, it belongs in core ([whoami](extensions/whoami.ts) is the cautionary example). Inside an extension, follow the same layered structure, unless it only needs a few route handlers, in which case the `extension.get/post/...` helpers are enough on their own. The `extension` API is in [doc/architecture.md](doc/architecture.md#extension-api).
@@ -159,7 +164,9 @@ The reference has two kinds of page:
An overview page answers "what is this API and is it what I need?", and a function page answers "how exactly does this call behave?". A change to one function usually only touches that function's page, so a new `kv.list()` option goes in `KV/list.md` without a matching update to `KV.md`. Don't copy a function's options, limits, or edge cases onto the overview page for completeness. Where a detail goes is a judgment call, not a hard rule: update the overview when a change affects how someone would describe the API as a whole.
When you add a page, add it to [src/docs/src/sidebar.js](src/docs/src/sidebar.js) under its area, or it won't show up in the navigation. When you add a runnable example, add its HTML to `src/docs/src/playground/examples/<slug>.html`, register the slug in [src/docs/src/examples.js](src/docs/src/examples.js), and link the code block to it by putting the slug after the language with a semicolon (` ```html;kv-set `).
When you add a page, add it to [src/docs/src/sidebar.js](src/docs/src/sidebar.js) under its area, or it won't show up in the navigation.
**Sidebar order.** Order the API areas in the sidebar by how often developers use them, most common first. Overview stays first and Objects stays last. Inside an area, order the pages by use too, and keep pages that make up one feature next to each other, such as `share()`, `unshare()`, `listShared()`, and `listSharedByMe()`. A group goes where its most-used page would go. A helper that only supports another function, such as `listModels()` for `chat()`, goes by its own use. Put create, list, get, update, and delete in that order, and constants such as `MAX_KEY_SIZE` last. The function list on the area's overview page uses the sidebar's order. When you add a runnable example, add its HTML to `src/docs/src/playground/examples/<slug>.html`, register the slug in [src/docs/src/examples.js](src/docs/src/examples.js), and link the code block to it by putting the slug after the language with a semicolon (` ```html;kv-set `).
### Function page layout
@@ -200,6 +207,7 @@ These apply to reference pages and recipes alike.
- **Describe behavior directly.** No quoted inner-voice phrases. Write "`isNew` is `true` when the person did not have access before", not "this tells 'I just gave this to someone' from 'they already had it'".
- **Keep references easy to follow.** Name what a list or code block belongs to in the sentence directly above it. Name a thing once and keep that name. Introduce an actor as "a user" before giving them a role: "A user can create a team. The user who creates the team becomes its owner."
- **Sentence mechanics.** Never start a sentence with inline code: write "To store data, use the [`set()`](/KV/set/) method." Link every inline mention of an API to its page, every time. No em dashes, and no colons or semicolons mid-sentence; a colon that introduces a code block is fine.
- **No `##` or `###` headings inside tabs.** The table of contents lists every `##` and `###` heading on the page, including the ones in a hidden tab (an `example-content` block switched by the `example-group` pills), so its links lead to content the reader can't see. Put the heading above the tabs, or use `####` inside a tab, which the table of contents skips.
- **Show the correct way.** No "don't" and no wrong-way-first examples, in recipes and playground examples alike. Mention a wrong approach only when developers already hit it in practice, and state how it fails as a fact: "`add()` reads a plain object as a path map, so the call rejects with …".
@@ -40,7 +40,7 @@ The following have already been reviewed and determined **not to be vulnerabilit
- **SSRF via `secureFetch`.** Production routes outbound requests through an isolated proxy that has no access to internal/SSRF-sensitive resources.
- **Attacks that depend on guessing an `appInstanceID` or app UID.** These are random 128-bit secret values and are not considered guessable.
- **Apps invoking drivers, creating workers, or using KV.** Applications are intended to do this; worker permissions are scoped to the owning app. This is by design.
- **App metadata or app user-count "leaks".** This information is currently public by design. This covers **private apps** too: private apps are an unreleased, admin-only beta for paid listings in the Puter App Center. Their `index_url` and other metadata are not secret, the flag is not user-settable, and no production app relies on it. Reports that a private app's metadata is reachable through a sibling route (taskbar items, `/get-launch-apps`, `index_url` drift on the hosting gate, etc.) are normalization work, not vulnerabilities.
- **App metadata or app user-count "leaks".** This information is currently public by design. This covers **private apps** too: private apps are an unreleased, admin-only beta for paid listings in the Puter App Center. Their `index_url` and other metadata are not secret, the flag is not user-settable, and no production app relies on it. Reports that a private app's metadata is reachable through a sibling route (`/get-launch-apps`, `index_url` drift on the hosting gate, etc.) are normalization work, not vulnerabilities.
- **Worker hot-reload driven by a share recipient.** Sharing a worker's source file with write access is how you let a collaborator change that worker's code and have it deploy live. The redeploy running as the file owner, with the owner's worker credentials, is the intended behavior of that grant — the owner chose to hand out write access to executable code. If you do not want that, do not share the source file with write access.
- **Re-claiming a site or worker name its owner deleted.** A hosted site or worker name that its owner deletes is treated as abandoned by that owner. If one of their apps still points at it, whoever claims the name next serves that app's `index_url` and receives what the app is launched with, and a new app on the same origin may share the old one's identity. Guarding app identities against being recycled this way is ongoing hardening, not a security boundary, so new ways to re-claim an abandoned name, or to inherit an app's tokens or data through one, are not eligible.
- **Share-mode downgrades that do not cascade.** Demoting a delegate does not retroactively revoke the grants that delegate already issued downstream. Shares are independent grants once issued, not a live-evaluated chain. Revoking the downstream grant is a separate, supported action.
Where the AI driver code lives under [src/backend/drivers/ai/](../src/backend/drivers/ai/), what each folder owns, and how to add a provider or a new AI interface. The general layer rules are in [architecture.md](architecture.md) and [AGENTS.md](../AGENTS.md#backend). This page covers what is specific to AI.
## Layout
```text
src/backend/drivers/ai/
├── interfaces/ Puter-facing drivers, one folder per AI interface
├── providers/ Vendor code, as providers/<vendor>/<interface>/
├── protocols/ Wire protocols that several vendors speak
└── shared/ Helpers that more than one interface uses
```
| Folder | Owns | Example |
| --- | --- | --- |
| `interfaces/<interface>/` | The driver class, the interface's contracts in `types.ts`, its provider base class where one exists, and the helpers and tests that apply to every provider of that interface. | [interfaces/chat/ChatCompletionDriver.ts](../src/backend/drivers/ai/interfaces/chat/ChatCompletionDriver.ts) |
| `providers/<vendor>/<interface>/` | One vendor's implementation of one interface: the adapter, its model catalog and prices, its fixtures, and its tests. | [providers/gemini/video/](../src/backend/drivers/ai/providers/gemini/video/) |
| `providers/<vendor>/` | Helpers that several of that vendor's interfaces use. | [providers/gemini/geminiRest.ts](../src/backend/drivers/ai/providers/gemini/geminiRest.ts) |
| `protocols/openaiCompatible/` | The OpenAI wire protocol: the base provider class, message and usage conversion, and parameter mapping for Chat Completions and Responses. | [protocols/openaiCompatible/OpenAICompatProvider.ts](../src/backend/drivers/ai/protocols/openaiCompatible/OpenAICompatProvider.ts) |
| `shared/` | Helpers that more than one interface uses: limits, the provider registry, the remote catalog cache, job polling, streamed charging, and media input. | [shared/aiLimits.ts](../src/backend/drivers/ai/shared/aiLimits.ts) |
Helpers that non-AI code also uses stay in [src/backend/drivers/util/](../src/backend/drivers/util/): file input, data URIs, upstream error mapping, and stream piping.
AI code that a service also needs lives in [src/backend/util/](../src/backend/util/), because a service can't import from the driver layer. The realtime relay in `services/realtime/` bills through the same cost factor as the drivers, so these three files are there:
| File | Holds |
| --- | --- |
| [aiCostFactor.ts](../src/backend/util/aiCostFactor.ts) | `withAiCostFactor`, the metering view every AI driver and the realtime relay record usage through, and `withAiCreditHold`. |
| [aiProviderKey.ts](../src/backend/util/aiProviderKey.ts) | `readProviderKey`, which reads an API key from `config.providers` blocks. |
| [aiPrices.ts](../src/backend/util/aiPrices.ts) | Vendor prices that a driver and a service both bill. |
Folder names are not runtime identifiers. `driverInterface`, `driverName`, driver aliases, the keys under `providers` in config, provider ids, model ids, and usage keys are public contracts. Moving or renaming a file never changes them.
## Request flow
A call goes from the interface to a provider, and from there to a shared protocol when the vendor speaks one:
```text
interfaces/chat/ChatCompletionDriver.ts
validates the call, picks the model and provider, holds credits
→ providers/deepseek/chat/DeepSeekProvider.ts
DeepSeek's catalog, endpoint, and request differences
the OpenAI-compatible request, response, and stream handling
```
A provider whose vendor has its own API, such as `ClaudeProvider` or `ElevenLabsTTSProvider`, implements the interface's contract directly and uses no protocol.
## Import rules
-`shared/` imports nothing from `interfaces/`, `providers/`, or `protocols/`.
- Nothing in `services/`, `stores/`, or `clients/` imports from `drivers/ai/`. Code that a service and a driver both need goes in `src/backend/util/`.
-`providers/` and `protocols/` import an interface's `types.ts` and helpers. They never import a driver class. Their tests can, to drive a provider through the real driver.
- Only an interface's driver file imports providers, to register them. The interface's helpers don't.
- An interface doesn't import another interface. When a second interface needs a helper, move the helper to `shared/`, or to `providers/<vendor>/` when it belongs to one vendor.
- A vendor folder imports another vendor's folder only when it serves that vendor's models. `providers/azure/` reads the OpenAI and xAI catalogs for that reason.
## Where a file goes
| The code is | It goes in |
| --- | --- |
| Input validation, gates, routing, credit holds, or anything else every provider of the interface gets | `interfaces/<interface>/` |
| A request or response contract that providers implement | `interfaces/<interface>/types.ts` |
| A vendor's endpoint, request shape, catalog, prices, or quirks | `providers/<vendor>/<interface>/` |
| A vendor's auth, REST plumbing, or constants that two of its interfaces use | `providers/<vendor>/` |
| Behavior of a wire protocol that several vendors speak | `protocols/<protocol>/` |
| A helper that two or more interfaces use and that belongs to no vendor | `shared/` |
| A helper that non-AI drivers or controllers also use | `src/backend/drivers/util/` |
| AI code that a service also uses | `src/backend/util/`, in a file named `ai<Something>.ts` |
Keep a helper next to its only consumer. Move it up when a second consumer appears, not before.
When one Puter option becomes a different request shape per vendor, the interface reads the option and each dialect maps it. The chat `compaction` option is read in `interfaces/chat/utils/compaction.js`, mapped for OpenAI by `toOpenAiContextManagement` in `protocols/openaiCompatible/openaiParams.ts`, and mapped for Anthropic by `toAnthropicContextManagement` in `providers/claude/chat/contextManagement.ts`. Tools work the same way: `normalize_tools_object` in the interface, then `toolsToWire` for OpenAI and `claudeToolPolicy` for Anthropic.
Tests sit next to the code they test. Fixtures go in a `fixtures/` folder next to the test that reads them.
## Adding a provider
1. Create `providers/<vendor>/<interface>/`. Use the vendor's existing folder if it has one, so all of a vendor's interfaces stay together.
2. Implement the interface's contract from `interfaces/<interface>/types.ts`:
| Interface | Contract | Base class |
| --- | --- | --- |
| chat | `IChatProvider` | `OpenAICompatProvider` for an OpenAI-compatible vendor |
Speech to speech has one provider, so `VoiceChangerDriver` uses `ElevenLabsSpeechToSpeechProvider` directly. Add a contract to its `types.ts` when a second provider needs one.
3. Put the model catalog and prices in the same folder, as `models.ts` or `costs.ts`. A provider that loads its catalog from the vendor wraps the fetch in `cachedRemoteCatalog` from `shared/`.
4. Register the provider in the interface's driver: in `#registerProviders()`, or in `onServerStart()` for OCR and speech to speech. A chat provider built from an API key and an optional base URL is one row in `KEYED_PROVIDERS` instead. Text to speech and speech to text also list the provider id and its aliases in `providerAliases.ts`.
5. Read credentials from `config.providers[<key>]`. The key is part of the self-hosting config contract, so reuse the vendor's existing key when it has one.
6. Add tests next to the provider. Mock the vendor SDK or `fetch` at the network boundary and drive the provider through the real driver where you can. A live test is a `*.integration.test.ts` file that skips itself, with `skipUnlessEnv` from [testEnv.ts](../src/backend/testEnv.ts), unless its `PUTER_TEST_AI_*` variable is set. Helpers for provider tests are in [shared/integrationTestUtil.ts](../src/backend/drivers/ai/shared/integrationTestUtil.ts).
7. For a chat provider, add its static catalog to `CATALOGS` in [modelCatalogs.test.ts](../src/backend/drivers/ai/interfaces/chat/modelCatalogs.test.ts) and the provider to [providerConsistency.test.ts](../src/backend/drivers/ai/interfaces/chat/providerConsistency.test.ts). The catalog test fails when a `providers/*/chat/` file with "model" in its name exports a catalog that isn't listed.
8. Add the provider's config key to the `providers` config of the interface's [fuzz suite](#fuzz-tests), and its id to the list of providers the suite expects.
9. Update the published docs for the models and limits the provider adds, following [contributing-apis.md](contributing-apis.md).
### OpenAI-compatible vendors
A vendor that speaks the OpenAI Chat Completions protocol extends `OpenAICompatProvider` and overrides only what differs. It still gets its own `providers/<vendor>/chat/` folder.
Vendors share one constants-only folder only when at least six of them differ in nothing but base URL and model catalog. Each then becomes one data file under `providers/openaiCompatible/providers/`, read by a single implementation. No vendor qualifies today, because every subclass overrides request, catalog, or billing behavior.
## Fuzz tests
Two seeded suites send malformed input through the real drivers and providers. They run with the rest of the backend tests.
| Suite | Covers |
| --- | --- |
| [interfaces/chat/ChatCompletionDriver.fuzz.test.ts](../src/backend/drivers/ai/interfaces/chat/ChatCompletionDriver.fuzz.test.ts) | Chat requests with a few values changed, and valid requests against wrong statuses, bodies of the wrong shape, and broken event streams. |
| [interfaces/mediaDrivers.fuzz.test.ts](../src/backend/drivers/ai/interfaces/mediaDrivers.fuzz.test.ts) | Image, video, speech and OCR requests with a few values changed. Every vendor refuses the request. |
What the suites assert:
- A malformed request never fails with an error that has no status, which `/drivers/call` would report as an unclassified 500.
- For chat, with the vendors answering, a malformed request also never fails with a 5xx and never marks a route unhealthy.
- When the upstream misbehaves, chat ends with an answer or an `HttpError`. A stream only has to end: the error line a stream carries isn't checked, so a malformed event can still end one with a 500.
- Requests reach the vendors, and with the vendors answering some chat requests are answered, so a suite that stops exercising the providers fails.
The vendors are played at the HTTP boundary by `upstream` in [shared/fuzzTestUtil.ts](../src/backend/drivers/ai/shared/fuzzTestUtil.ts), which stands in for global `fetch`, undici `fetch` and axios, and makes `https.request` throw so an SDK with its own client fails the suite. The vendors' SDKs run, except Groq's, which is swapped for the OpenAI SDK, and the AWS clients, whose `send` is stubbed. The media suite calls the drivers as a user with unlimited metering, so a request gets past file input and the credit hold to the vendor. A provider that loads its catalog from the vendor registers no models there, so the suites skip it.
A failure lists each distinct finding with its case number and input. Each case is generated from the seed and its own number, so the same case fails again on the next run. Routes are picked by rendezvous hashing: a model added to a catalog takes only the cases it wins, and every other case keeps its route. To search further, raise the case count:
```sh
PUTER_FUZZ_CASES=100000 npx vitest run --config src/backend/vitest.config.ts src/backend/drivers/ai/interfaces/chat/ChatCompletionDriver.fuzz.test.ts
```
When a suite finds a crash, validate the value where the request enters, in the driver or in `normalize_single_message`, so every provider is covered. Reject only what crashed: a value a provider ignored or refused before must still reach it. An option a driver reads as text, a number or a flag goes in the driver's `SCALAR_OPTIONS`, and `assertScalarOptions` from `shared/scalarOptions.ts` answers 400 for a value there that can't be converted to text. Add a unit test for the case next to the fix.
## Adding an interface
1. Create `interfaces/<interface>/` with the driver class and a `types.ts` for the contracts providers implement.
2. Register the driver in [src/backend/drivers/index.ts](../src/backend/drivers/index.ts).
3. Use `AI_RATE_LIMIT` and `AI_CONCURRENT` from `shared/aiLimits.ts`, and publish any new limit in [rate-limits-and-quotas.md](../src/docs/src/rate-limits-and-quotas.md).
4. Meter through `withAiCostFactor` from `src/backend/util/aiCostFactor.ts`, and route between providers with `ProviderRegistry` from `shared/providerRegistry.ts`.
5. List the options the providers read as text, a number or a flag in a `SCALAR_OPTIONS` array, and call `assertScalarOptions` from `shared/scalarOptions.ts` first in each method. Add the driver to [mediaDrivers.fuzz.test.ts](../src/backend/drivers/ai/interfaces/mediaDrivers.fuzz.test.ts).
6. Follow [contributing-apis.md](contributing-apis.md) for the puter.js method, types, docs, and tests.
Extensions sit alongside this stack and can register into any layer; see [Extensions](#extensions).
### AI drivers
The AI drivers have their own layout under [src/backend/drivers/ai/](../src/backend/drivers/ai/):
| Folder | Holds |
| --- | --- |
| `interfaces/` | The Puter-facing drivers, one folder per AI interface (chat, image, video, text to speech, speech to text, speech to speech, OCR). |
| `providers/` | Vendor code, as `providers/<vendor>/<interface>/`. |
| `protocols/` | Wire protocols that several vendors speak, such as the OpenAI-compatible one. |
| `shared/` | Helpers that more than one interface uses. |
A call goes from an interface's driver to a provider, and from there to a shared protocol when the vendor speaks one. [ai-drivers.md](ai-drivers.md) has the ownership rules, the import rules, and the steps for adding a provider.
@@ -16,7 +16,7 @@ Core subscribes the browser to a push service only when the server also publishe
`uid` is the notification's uid, a later push with the same `group` replaces the one showing, and `target` and `data` reach the app when the user taps it.
When the browser replaces the subscription (`pushsubscriptionchange`), `sw.js`posts `{ type: 'puter-push-subscription-changed' }` to every open window on Puter's origin. The worker has no token or session, so the page's push client registers the new subscription.
When the browser replaces the subscription (`pushsubscriptionchange`), `sw.js`keeps the new one, or subscribes again with Puter's stored key when the browser gave none and the event, the receipt or an earlier mark names the subscription that went. It writes `puter:subscription-changed:<scope>` to the `puter-push` IndexedDB as `{ at, oldEndpoint }`, the old endpoint kept from an earlier mark no page handled yet, else taken from the event or from the receipt, forgets the receipt kept for that scope, and posts `{ type: 'puter-push-subscription-changed' }` to every open window on Puter's origin. The worker has no token or session, so an open page runs `helpers/pushSync.js` on that message, after any run already in flight, and a later load does the same through the marker.
## Receipts
@@ -59,9 +59,9 @@ Apps on their own origin do the same through `push-sw.js`, which they import int
-`enable()` may ask for notification permission, so core calls it from a click: Turn on in the Notifications tab, Allow on a permission prompt that includes `notifications:push` while this browser is off, and the "Turn on notifications" button of the `/action/enable-push` popup and the matching desktop dialog (`UIEnablePushDialog`). Core also calls it without a click, from `helpers/pushSync.js`, when `Notification.permission` is already `granted`, `status()` reports the browser as not registered, the user has not turned push off in this browser, and an app holds `notifications:push`; the browser does not prompt then. The built-in client runs `puter.notifications.enable()`, which subscribes with the `/sw.js` registration and registers the browser through the Web Push flow above (`platform: 'webpush'`). It then resolves the `uid` of this browser's `webpush` device from `listDevices()`. It rejects when permission is refused or the browser wasn't registered.
-`enable()` may ask for notification permission, so core calls it from a click: Turn on in the Notifications tab, Allow on a permission prompt that includes `notifications:push` while this browser is off, and the "Turn on notifications" button of the `/action/enable-push` popup and the matching desktop dialog (`UIEnablePushDialog`). Core also calls it without a click, from `helpers/pushSync.js`, when `Notification.permission` is already `granted`, `status()` reports the browser as not registered, the user has not turned push off in this browser, and an app may push to Puter's devices (`puterDevices` in `GET /push/apps`); the browser does not prompt then. That call passes `{ background: true }`: a browser the user removed in Puter stays removed, and the built-in client rejects with code `push_device_removed`, after which core records the browser as turned off. Before a background call the built-in client asks `POST /push/web/subscriptions/status` about every earlier endpoint it knows: the marker's `oldEndpoint` and the last endpoint the server answered `active` for (`puter.push.last-endpoint:<user uuid>` in `localStorage`). On a 429 it registers nothing and rejects with `too_many_requests`, which the sync reports as `later`, keeping the marker for the next run. When any answer is `removed`, it ends the current subscription in the browser, clears the marker and rejects with `push_device_removed`, so a subscription change or a dropped subscription never brings back a browser removed from another device. A click renews it as before. For a click the built-in client runs `puter.notifications.enable()`, which subscribes with the `/sw.js` registration and registers the browser through the Web Push flow above (`platform: 'webpush'`); in the background it runs the same registration without replacing a removed subscription or opening Puter's sign-in. It then resolves the `uid` of this browser's `webpush` device from `listDevices()` and clears the subscription-changed marker. It rejects when permission is refused or the browser wasn't registered.
-`disable()` runs `puter.notifications.disable()`, which sends `DELETE /push/web/subscriptions { endpoint }` and ends the browser's subscription. The tab calls it for Turn off, and for Remove on the device marked as this browser. Remove on any other device is `DELETE /push/devices/:uid`.
-`status()` reports the state without prompting. `permission` is `Notification.permission`. `registered` is true when this browser has a device registered for the signed-in user, and `deviceUid` is that device's `uid`.
-`status()` reports the state without prompting. `permission` is `Notification.permission`. `registered` is true when this browser has a device registered for the signed-in user and `POST /push/web/subscriptions/status` answers `active` for the current subscription's endpoint, and `deviceUid` is that device's `uid`. A server that can't answer leaves the device match alone, and so does an `unknown` for an endpoint `enable()` registered in this page in the last 5 minutes, since that check can trail a registration. While the subscription-changed marker is set, `registered` is false. With `{ cached: true }` a client may answer with the last registered state it read in this page. Core passes it for the silent registration on app launch and for an app's `puter.notifications.status()`; the Notifications tab and the push dialogs read fresh. The built-in client keeps that state in memory for the same user and subscription for up to 5 minutes, from `enable()` or once the server answered `active` for the endpoint, and answers from it without either request. Reads of the same user and endpoint share one real check while it is in flight. It drops it on `disable()`, a failed `enable()`, an unregistered answer, a set subscription-changed marker, and the worker's `puter-push-subscription-changed` message, so a new page load always asks the server. Whether the calling app may push is checked on every call, against a `GET /push/apps` answer the page reuses for 30 seconds and drops on `enable()`, `disable()`, a grant, a revoke and a user change. When it can't be read, or leaves the app out, the grant alone counts.
After `enable()` resolves, the tab calls `POST /push/devices/:uid/test` and asks the user whether a notification showed up.
The tab also reads `GET /push/apps` and `GET`/`POST /push/preferences`.
Core keeps its own record of a browser the user turned off, `puter.push.browser-off:<user uuid>` in `localStorage`, written by Turn off and by Remove on this browser's device, and cleared when `enable()` resolves. The silent registration above skips a browser with that record, so a device removed here stays removed while one removed from another device, or a new sign-in in a browser that already allows notifications, registers again.
Core keeps its own record of a browser the user turned off, `puter.push.browser-off:<user uuid>` in `localStorage`, written by Turn off, by Remove on this browser's device, and by a silent registration the server refuses with `push_device_removed`, and cleared when `enable()` resolves. The silent registration above skips a browser with that record, so a device removed here or from another device stays removed until the user turns it on from a click, while a new sign-in in a browser that already allows notifications registers again.
@@ -326,6 +326,20 @@ Used for password resets, email confirmation, and notifications. Without it thos
To require email confirmation before login, also set `"strict_email_verification_required": true`.
Account and notification mail is sent on a named stream: `auth` (password, address and 2FA changes), `signup` (address confirmation; a `puter.signup.validate` listener may name another: lowercase letters, digits, `:`, `_` and `-`, up to 64 characters, starting with a letter or digit) and `notify` (share, app feedback and contact form mail). Add `"streams"` inside `"email"` to configure one, for instance to let your mail provider route or report each kind of mail separately:
`headers` are added to every message on the stream, `from` replaces the default sender, and `tagsHeader` names the header that carries a message's tags as `name=value` pairs joined by `, `. A stream that is not listed adds nothing: the message goes out with the default from address and the caller's headers. `streams` is never handed to the transport.
To read the mail yourself instead of sending it, point the transport at a local SMTP sink — [MailHog](https://github.com/mailhog/MailHog) catches everything and serves it at `http://localhost:8025`. Publish both ports; a container that only exposes them is unreachable from the host:
'[email] attempted to send email without transport. If you need to send email, configure an SMTP transport in your config file (see docs for details). Email content:',
describe('S3Client — bucket in another region',()=>{
letserver: Server;
letclient: S3Client;
letsignedRegions: string[];
// Answers like a bucket that lives in us-west-2: a request signed for any
// other region gets S3's 301 naming the bucket's region.
beforeEach(async()=>{
signedRegions=[];
server=createServer((req,res)=>{
constregion=
/Credential=[^/]+\/\d{8}\/([^/]+)\//.exec(
req.headers.authorization??'',
)?.[1]??'';
signedRegions.push(region);
if(region!=='us-west-2'){
res.writeHead(301,{
'content-type':'application/xml',
'x-amz-bucket-region':'us-west-2',
});
res.end(
'<?xml version="1.0" encoding="UTF-8"?><Error><Code>PermanentRedirect</Code><Message>The bucket you are attempting to access must be addressed using the specified endpoint.</Message></Error>',
it('passes on the error when the token could not be checked',()=>{
constlookupError=newError('down');
constgot=runGate(requireUserActorGate(),{
tokenAuthError: lookupError,
});
expect(got).toBe(lookupError);
});
it('rejects a FULL-ACCESS access token too — account routes stay closed',()=>{
// The account wall is actor-type based: even a full-access PAT (which
// the resource wall lets through) is rejected here, so it can never
Loaded 100 of 1155 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.