Compare commits

...
Author SHA1 Message Date
Daniel Salazar 7a7ff47442 chore: bump version (#3461) 2026-07-27 23:59:51 -07:00
Daniel Salazar 148771a82b fix: performance degradations in app fetching + puter-js module parent class (#3460)
* fix: centralize module methods in parent class

* fix: performance degradations in app fetching
2026-07-27 23:41:51 -07:00
Daniel Salazar 5bad6a972f fix: puter-js cleanup around docs and ai resolution (#3459)
* fix: puter-js cleanup around docs and ai resolution

* more jsdoc stuff

* fix: docs

* fix: doc types + misc hardening

* fix: harden auth
2026-07-27 22:31:05 -07:00
Luke Oliff 8bb7d59527 feat: add Speechify TTS driver (puter-tts) (#3453)
* feat: add Speechify TTS driver

Adds a SpeechifyTTSProvider under the ai-tts driver (mirrors the xAI/
ElevenLabs REST-provider shape), registered in TTSDriver alongside the
existing providers. Wires puter-js txt2speech with provider: "speechify"
support, default voice/model, and the speechify-tts driver alias.

Every outbound request sets Speechify-Caller: puter; base URL is
https://api.speechify.ai only; default model is simba-3.2.

* fix: replace placeholder voice IDs with real simba-3.2/simba-english voices

DEFAULT_VOICE and the starter voice catalog used invalid IDs (henry,
cliff, kristy, george, aria) that the real API rejects with 400. Swapped
in confirmed-real voices from a live GET /v1/voices call: geffen_32,
dominic_32, harper_32, hugh_32, imogen_32, and alec for the
simba-english model-override test.

* fix: purge remaining placeholder voice IDs from client, docs, and types

The previous fix only covered the backend provider — the client-side
default in tts.js, its test, the txt2speech docs page, and the ai.d.ts
type comment all still referenced the invalid henry/cliff/kristy/george/
aria set. All replaced with the live-verified voices (geffen_32 default).
Also corrects the docs link to docs.speechify.ai.
2026-07-27 17:04:11 -07:00
Neal Shah 8732494442 dead code cleanup (#3441) 2026-07-27 17:51:15 -04:00
Daniel Salazar cade75ac9b fix: PUT-1385 PUT-1382 PUT-1381 PUT-1380 PUT-1379 (#3454) 2026-07-27 11:42:40 -07:00
meridah7 9abc9d1a19 feat: add Infron AI chat provider (#3433)
Adds Infron (https://infron.ai) as an aggregator provider for the
puter-chat-completion driver, following the OpenRouter provider as
reference per doc/contributing-apis.md.

- OpenAI-compatible gateway at llm.onerouter.pro/v1
- Dynamic model catalog (kv-cached 15 min, authenticated fetch),
  filtered to chat-capable LLM entries
- Bills the gateway-reported authoritative cost when present
  (usage: {include: true}); falls back to catalog per-token pricing
- Registered as an aggregator so its aliases never shadow
  first-party providers
- Offline unit tests (mocked SDK/axios against a real test server)
  plus an env-gated integration test
2026-07-27 08:38:04 -07:00
Nariman Jelveh f3fd8a30da Rework permission requests: new dialog, working popup flow for websites (#3447)
* Rework permission requests: new dialog, working popup flow for websites

- Replace the UIWindow-based permission prompt with a standalone top-layer
  <dialog> (responsive, light/dark, app/site identity, input protection)
- Implement puter.ui.requestPermission for env=web: opens the GUI's
  /action/request-permission popup with pinned origin/source/msg_id,
  popup-closed detection, and a check-permissions polling fallback for
  crossOriginIsolated openers
- Move the GUI's request-permission action into postAuthActions so
  signed-out users sign in first; identify the app by opener origin,
  correlate responses with original_msg_id, close the popup after answering
- Always respond from the IPC handler so the SDK promise can't hang;
  normalize the result to a strict boolean (a failed grant no longer
  resolves truthy)
- Accept origin in /auth/grant-user-app and /auth/revoke-user-app,
  mirroring grant-dev-app (fixes puter.perms.grantOrigin/revokeOrigin)
- Add Playwright e2e coverage for both the desktop and popup flows;
  update e2e harness for the auth_token_v2 localStorage key
- Update types and docs (perms request methods now work on websites)

* Harden permission request flows

- Grace period before treating popup close as denial: the GUI posts the
  decision then closes the popup, and postMessage delivery is not ordered
  relative to `closed` becoming true, so a grant could race to a false
- Unique popup window name per request so window.open name-reuse can't
  hijack a still-pending request's popup
- request-permission action always answers the requester and closes the
  popup, even when app resolution or the dialog throws
- Permission dialog: refuse unidentifiable requesters, allowlist icon URL
  schemes, and time out the grant request into the retryable error path
- Validate app_uid/origin/permission types and length in grant-user-app
  and revoke-user-app
- Tests: revoke-by-origin and input-validation backend tests; e2e for
  dialog dedup, unsupported permissions, and the consent-dialog path

* Sign in first in the requestPermission fixture's email flow on the web

In env=web the site has no auth token, so whoami threw 401 immediately
and the email button appeared to do nothing. Sign in via popup first,
matching the real third-party flow; env=app already has a token and is
unaffected.

* Point the requestPermission fixture at the real api subdomain

The SDK sends credentialed CORS requests; the GUI host doesn't answer
with Access-Control-Allow-Credentials, so whoami (and any authed call)
from the fixture origin failed CORS and looped through retries.

* Fix /auth/list-permissions schema mismatches

The endpoint's queries referenced columns that don't exist:
user_to_app_permissions stores a numeric app_id FK (not app_uid), and
user_to_user_permissions uses holder_user_id (not target_user_id) —
every call 500'd. Join apps to expose the app's uid and use the real
column names.

Replace the catch-either-branch test (which documented the breakage
instead of failing on it) with real assertions covering all three
sections of the response.

* Identify permission requesters by origin only

The request-permission action took `app_uid` straight from the query
string and used it as the grant target whenever the origin was absent or
unresolvable. Now that /auth/grant-user-app accepts `origin` and prefers
`app_uid` when both arrive, the displayed identity and the grant target
could diverge; with only `app_uid` in the URL the dialog rendered with an
empty name, so a link could produce a bare "Allow" prompt for an unnamed
requester. Resolve the uid from the origin alone, and let the server
resolve it from that same origin when the client lookup fails.

Also give the no-gesture consent popup a unique window name. UI.js does
this on the direct path because window.open() reuses a window with a
matching name, but the PuterDialog fallback opened under the default
'Puter' — the same name sign-in uses, so a consent click could navigate
an in-progress sign-in popup away.

And guard the IPC responder: an app that closes its own window while the
dialog is up leaves target_iframe.contentWindow null.

* Keep the permission popup from signing the site in

The popup loads the GUI with embedded_in_popup=true, so it ran the
sign-in token exchange and posted puter.token to the opener before the
user answered the prompt. A site that called requestPermission() walked
away holding a user-app token for the account even when the user pressed
"Don't Allow" — and because the SDK's global puter.token handler feeds
event.data.token into setAuthToken() without looking at `success`, a
failed exchange posted token: null and wiped a token the site already
had. Keep running the exchange (it bootstraps the app row the grant
needs and caches host_app_uid) but leave the token in the popup. A site
that wants credentials still has to call signIn().

Escape on the SDK's consent dialog left the caller pending forever.
PuterDialog wired its Cancel and close buttons but not the <dialog>'s
native cancel event, so the browser dismissed the dialog and nothing
reported it: no dialog, no popup, no answer. Route cancel to the same
handler. Programmatic close() fires only `close`, so launching the popup
— which closes this dialog — is unaffected, and the implicit-auth flow
stops hanging on Escape too.

Serialize the permission dialogs. showModal() makes the whole document
inert rather than just the requesting app's window (which is what the
UIWindow it replaced did), and the dedup map only coalesced identical
requests, so an app asking for permissions in a loop stacked one modal
per request and walled the user off from the desktop — including from
the app doing it. Prompts now queue and open one at a time, and each
caller still gets its own decision.

Identify apps by more than their title. `title` is free-form text the
author picks and is not unique, so it was the whole identity of a prompt
an app titled "Puter Settings" could raise; the registered `name` is
unique and format-restricted, so show it underneath. Give the name line
the unicode-bidi isolation the origin line already had, since escaping
leaves bidi overrides intact.

Stop the dialog from answering over its own in-flight grant: a dismissal
while the POST was outstanding resolved false for a permission the server
was committing. Ignore dismissals while granting, and time-box the
request with AbortController (AbortSignal.timeout isn't everywhere) so a
hung network can't leave a modal no one can close. Fail closed on the
remaining paths that could reject or prompt uselessly — showModal()
throwing under <iframe sandbox>, and a requester known only by app_name,
whose Allow the server would always reject. Pass the error string to
.text() unencoded so translations containing an apostrophe don't render
&#39;.

The e2e suite covers all of it; each new test fails without its fix.

* Restrict the permission popup flow to third-party websites

requestPermission's new web path ran in every environment with a window,
including env='gui' — so a permission_denied driver retry inside the
Puter GUI would open a popup to the Puter origin from the desktop itself
and try to grant the permission to a phantom app for Puter's own origin.
Resolve false everywhere except env='web', the previous behavior.

* Settle the permission dialog when a failed grant has no dialog left

The cancel handler is preventDefault'd, but close requests can't be
suppressed forever: Chrome's close watcher lets a repeated Esc skip
cancel and force-close the dialog while the grant POST is in flight.
The close handler defers to that grant on purpose — but if the grant
then failed, fail_grant re-enabled buttons on a closed dialog and the
promise never settled, leaving the requesting app waiting forever.
Settle as a denial when the dialog is no longer open.

Also add regression tests for this and for the popup-flow env guard
(routing the CDN SDK URL to the local build, since the prod-built GUI
loads its SDK from js.puter.com).

* Withhold the auth token on the popup's first-visit paths

Keeping the token inside the permission popup only covered the plain
token exchange. Two other popup paths mint a user-app token and posted
it to the opener unconditionally: first-visit temp-user creation, and
the manual signup shown when temp users are refused. Both sit on the
path a brand-new visitor takes — the audience the website popup flow
exists for — so a site that asked about one permission and was denied
still walked away holding a token, for a temp account or a real one.
The SDK's global puter.token handler feeds whatever arrives straight
into setAuthToken(), so posting it is the whole of it.

Move the rule into util/popupAuth.js and consult it at every site that
posts the token, so the next token path has one place to ask.

The first-visit path also left the prompt itself unreachable: it waits
on the spinner promise, which only resolves when the spinner was up for
under 2s. End that wait for any action that keeps the popup open;
sign-in still closes the window as before.

The e2e test fails without the fix — the site holds a token after the
user presses "Don't Allow".

* Poll for the decision when the popup's opener is severed

crossOriginIsolated was the test for "the popup can't message us back",
but being isolated also requires COEP. A site sending COOP: same-origin
on its own still has its opener relationship severed when it opens the
Puter popup, and took the watch-the-window path instead — where the
detached proxy reports closed === true on the first tick, so
requestPermission resolved false about a second after the popup opened,
while the user was still reading the dialog. Their "Allow" then had
nowhere to go. Treat an already-closed popup as severed and poll.

Pin the expected event.source before those early returns. popupWindow
was assigned after them, so for the whole consent-dialog wait — as long
as the user takes to click Continue — the handler accepted a decision
from any window on the GUI origin. A forged answer is only advisory
since the grant is written server-side, but the check may as well hold.

Settle instead of rejecting when the consent dialog can't be appended:
document.body is null in a <head> script, and the throw both rejected a
promise documented to resolve to a boolean and left the message listener
behind.

* Key the dialog dedup by the identity its gate accepts

The gate treats an empty app_uid as absent and falls through to the
origin; the dedup key used ?? and kept the empty string, so two requests
from different origins would collide on one key and share a single
decision. No caller can produce a blank uid today — server uids are
never empty and the IPC path's empty attribute is stopped by the gate —
but the two lines have to agree.

* Close the gaps the permission-request flow left open

Seven defects found reviewing the new permission flow end to end, each
reproduced against a running server before being fixed.

Security:

- `cross_origin_isolated=true` bypassed `deliversTokenToOpener` entirely.
  That branch is checked first, mints a user-app token, publishes it via
  `/login/set` and returns — so one query parameter on a
  request-permission URL skipped the prompt and handed the opener a token
  through the unauthenticated `/login/wait`. Gate it with the same rule.

- Grant/revoke by `origin` could land on an unrelated app. An origin with
  no app row synthesises `app-<uuidv5>`, and the permission services
  resolve their identifier as uid *or name* — and the uuid namespace is a
  source constant, so the string is computable offline and registrable as
  an app name. Resolve origins to a uid that names a real app row.

- A website's host was elided on the right, hiding the registrable domain
  that says who is asking. Elide it from the left, as the sibling rule
  already intended.

- A grant whose response was lost (client-side abort, dropped reply) left
  the row committed while the dialog reported a denial. Withdraw it when
  the user then answers "Don't Allow".

Correctness:

- `pollDecision` needs the site's own token, which a permission popup
  deliberately never delivers, so a signed-out cross-origin-isolated site
  burned the full five-minute timeout before answering. Answer at once
  when there is nothing to poll with.

- `getUserAppToken` reports failure by returning null, and three callers
  read `.app_uid` off it. Guard all three, keep the first-visit spinner
  promise settling on its failure paths, and dispatch the `login` event on
  the manual-signup path so `postAuthActions` runs at all — a user who
  signed up inside a permission popup got a blank window and the site got
  no answer.

- Time-box the lookups that run while a request holds the dialog queue's
  slot: they have no timeout of their own, and a stall (not a failure)
  wedged every later permission request in the page.

Also harden the grant/revoke input validation the PR introduced — it
skipped `extra`/`meta`, so a non-object faulted *after* the row was
written, and its length cap was 16x the column it lands in — stop a
non-URL `origin` from throwing past the answer-and-close, and drop the CSS
left behind by the deleted dialog.

* Close three gaps left in the permission-request flow

Each was reproduced first — the squatting grant against a running server,
the COOP timing in a real browser — and each fix was then confirmed by
reverting it and watching the new test fail.

Security: the dialog could name one site and grant to another.

The squatter guard added for grant/revoke by `origin` only ran when
`app_uid` was absent, and the dialog sends both — so `app_uid` won and the
guard never applied. `getAppUIDFromOrigin` returns the synthetic
`app-<uuidv5(origin)>` for any origin with no app row of its own, and the
grant endpoint resolves `app_uid` as uid *or name*, so the grant landed on
whoever registered an app under that computed name (the format allows it,
and the namespace is a source constant). A link like
`/action/request-permission?origin=https://a-site-you-trust.example` named
that site in the prompt while "Allow" handed the permission elsewhere.

Fixed on both sides of the wire. The action now sends the origin alone —
no uid resolved in the browser is safe to forward, whatever its source —
and a supplied `origin` now decides the target on the server even when an
`app_uid` travels beside it: the origin is what the prompt showed the
user, so it is what the grant has to follow.

Correctness: a COOP-only site was answered before the user decided.

7efc0c0b routed a severed opener to `pollDecision` by treating an
already-closed popup as severed, but that reads `closed` synchronously
after `window.open()` — before the navigation whose response headers cause
the severing has committed. Measured in Chromium: `closed` is false at
0ms and true by 200ms. So a site sending COOP: same-origin without COEP
still took the watch-the-window path, and `requestPermission` resolved
false 1.1s after the click while the dialog was still on screen. The
"Allow" that followed committed a grant the site had been told it did not
get. Tell the two apart by when the close lands, and keep the in-flight
message's grace period on both branches — an answer already on its way
outranks whatever the close is taken to mean.

Correctness: a grant that timed out was not withdrawn.

`grant_may_have_committed` was only set in the fetch's `catch`, which
cannot run until the timeout's timer callback returns — and that callback
already calls `fail_grant`, which settles the dialog as a denial outright
when the dialog was force-closed mid-grant. The reconciliation was
skipped in exactly the case it exists for. Record the unknown outcome in
the timer, where the timeout already means the request left the browser.

Also require a `token` from the user-app exchange rather than just a
non-null body: an HTTP failure (a blocked origin, a 5xx) returns the
parsed *error* body, which is truthy, so the guard added for this missed
it — handing the opener an `undefined` token, and prompting for a grant
whose app row was never bootstrapped.

Known limitation, now more reachable: a severed opener cannot signal a
denial at all, since nothing is written for one, so those sites wait out
the poll timeout before receiving false. A grant still resolves promptly.

* Deliver the uncertain-grant withdrawal from a closing popup

The permission dialog reconciles a denial after an uncertain grant by
firing a revoke in the background, but the popup flow posts the answer
and closes the window right after settling — and a plain fetch is
cancelled with its document, so the withdrawal never reached the server
and the user was told "denied" while the grant stayed live. Send it
with keepalive so the browser delivers it independently of the popup,
and cover the popup flow with a regression test (the existing
withdrawal tests only exercise the desktop flow, where the GUI
outlives the dialog).

Also make the popup boot's getAppUIDFromOrigin guard functional: the
helper reports failure by resolving to a null/undefined uid, not by
throwing, so the catch never engaged and a failed lookup clobbered
window.host_app_uid with undefined despite the comment claiming the
token exchange's value was kept.

* Keep the ai-chat model-map build inside the server lifecycle

onServerStart fired #buildModelMap without awaiting or tracking it, so
the network fetches it does (notably Ollama auto-discovery, which is
enabled by default and doomed on any machine without a local Ollama)
kept running after server.shutdown() resolved. In vitest that let the
provider's console.error land during worker teardown, which surfaces as
"Closing rpc while onUserConsoleLog was pending" — the unhandled error
that intermittently fails CI (last seen attributed to
WispController.test.ts). A rejection in the detached chain would also
have been an unhandled rejection.

Track the promise, catch and log rejections, and await it from
onServerShutdown so no provider I/O or logging outlives the server.
Also disable Ollama auto-discovery in setupTestServer's defaults —
every test server was firing a pointless model-list fetch at localhost.

* Settle requestPermission on the launch paths that could still throw

Three gaps left by the permission-request rework, each verified against a
live stack before and after the fix.

The IPC handler normalises a non-object `options` so it can always reply,
but `typeof null === 'object'` let null through the guard; reading
`.permission` off it threw out of the message listener before any reply,
so `puter.ui.requestPermission(null)` hung forever in env=app while
env=web answered false for the same input.

In the env=web branch only the consent-dialog path was wrapped, even
though its own catch reasons that this resolves to a boolean for every
other caller. A `window.open` refused by throwing rather than by
returning null escaped the launch branch and rejected instead.

The perms docs were flipped to platforms: [websites, apps], but every
entry point except `request()` reads the signed-in user's identity
first, so on a signed-out site they reject with Unauthorized and never
prompt — the permission popup deliberately does not sign the site in.
Document the sign-in precondition on those pages.

* Measure a permission's width after the rewrite that decides it

The new grant validation capped `permission` at 255 to match the column
it lands in, but it measured the caller's raw string. `fs:/path:mode` is
rewritten to `fs:<uuid>:mode` before storage, so what lands in the column
is ~44 characters however deep the path is. Granting access to a deeply
nested file therefore returned 400 even though the identical target
granted by uuid returned 200 and stored 44 characters — and because a 4xx
is read as an outright refusal, the permission dialog showed its
retryable error and could never succeed on retry.

Bound the request body only against absurd input, and enforce the column
width in the permission service on the rewritten string, before the app
is resolved so an oversized permission still refuses ahead of a missing
app. Covered both ways: a rewritten-short permission is accepted, and one
that no rewriter shortens is still refused.

* Require a registered app when a dev-app grant names an origin

The user-app handlers resolve a caller-supplied origin through
#registeredAppUidFromOrigin because appUidFromOrigin synthesises
app-<uuidv5(origin)> for an origin with no app row, and the permission
services resolve their identifier as uid-or-name — so the synthetic uid,
derived from a published namespace constant and computable offline,
lands on whoever registered an app under that literal name. The dev-app
handlers were left resolving the raw synthetic uid.

That leg matters at least as much: a dev-app grant is scanned with the
issuer's authority for anyone running as that app, so a squatted grant
hands over the granting user's permission. Verified against the store —
the grant landed on the squatter rather than rejecting.

Without a squatter the synthetic uid resolves to nothing and these
already 404, so the guard costs the legitimate case nothing; a test
covers a registered origin still resolving to its app.

Also assert that revoke accepts the same oversized-but-rewritten
permission grant does, since the dialog's withdrawal of an uncertain
grant depends on that symmetry.

* Revoke the row a user-app grant actually wrote

`app-root-dir:<uid>:<mode>` is a pseudo-permission: its rewriter resolves
it to a real `fs:<root_uid>:<mode>` only while a user-app permission row
is being written, and resolves to a match-nothing sentinel at all other
times so a scan can't match through the fs path.

Revoke shared that rewrite but not the flag, so it aimed the DELETE at
the sentinel: it removed nothing and reported success while the fs
permission stayed live. The permission dialog withdraws a grant whose
outcome it couldn't confirm through exactly this path, so a user who
answered "Don't Allow" after a dropped grant response kept the access
they had just refused.

Grant and revoke now share one rewrite helper. It also has to work for a
caller outside a request scope — an internal job, or a direct unit test —
where `Context.set` has nothing to set the flag on; an empty scope reads
the same as no scope, so it only makes the flag settable.

* Elide a long host from the left, as its own rule intends

The identity line is the only thing on the permission dialog naming the
requester, so a host too long for the dialog has to lose its front, not
its tail: the registrable domain is the part that says who is asking.

`direction: rtl` was there for that, but paired with
`unicode-bidi: plaintext` it does nothing — plaintext takes the base
direction from the content's own first strong character, which for any
Latin host is LTR, so the ellipsis went back on the right. Measured in
the real dialog, `account-security.paypal.com.verify-login.example`
rendered as `account-security.paypal.com.verify-l…`, reading as PayPal.

Isolating instead keeps the box anchored to the end of the text, and
still stops a bidi control character in the host from reordering
anything around it.

The test that covers this asserted the computed `direction` — the
property, not the outcome — so it passed throughout. It now measures
which characters are actually on screen, and that the host still reads
in source order.

* Tell a severed opener from a closed one by whether it can answer

The web popup flow decided which it was looking at by timing: a
`popup.closed` flip within 3s of `window.open()` was COOP severing the
opener, anything later was the user closing the window. Both halves
misfire, and both were reproduced against a real popup.

A COOP-only site whose popup navigation commits after the cutoff had its
severing read as a close, so the site was told "denied" about a second
later — while the prompt was still coming up. The Allow the user went on
to click then committed a grant the site had been told it did not get,
which is the failure the cutoff was introduced to prevent: how long a
navigation takes says nothing about whether the opener survived it.

The other way round, a signed-in site whose user dismissed the popup on
sight had that close read as severing, and fell back to polling for a
decision. A denial writes nothing to poll for, so the caller waited out
the full five-minute timeout instead of being answered.

So ask the question directly: the popup now announces itself to its
opener, which it can only do while the relationship is intact. Having
heard from it proves a later close is a real close and the answer is now;
never hearing from it means the prompt may be live in a window that
cannot answer, and the decision is read back from the server as before.
The announcement carries no token, and goes out before any sign-in gate —
a gate that delayed it would make abandoning sign-in look severed.

Measured: the popup announces itself ~290ms after opening, and a close
just after that is answered in ~1.3s rather than five minutes.

* Stop revoking a literal `*` after a dev-app revoke-all

The `*` arm of /auth/revoke-dev-app fell through: after
`revokeDevAppAll` it also ran `revokeDevAppPermission(…, '*')`, a
DELETE naming a row called literally `*` — which matches nothing —
plus a second `revoke` audit entry for the same action. The user-app
twin already if/elses its two arms; the dev-app handler now matches it,
and a test pins the parity: everything revoked, one audit row.

* Match the popup's messages against a canonical GUI origin

The web flow compared `event.origin` to `puter.defaultGUIOrigin` as raw
strings, but they are different kinds of value: the event carries the
browser's canonical origin serialization, while the configured origin is
whatever text was supplied — a trailing slash, an explicit default port,
or a stray path all name the same origin and all fail the comparison.

The mismatch doesn't read as a config error, it reads as the user's
answer: with every message from the popup dropped, the missing
`permissionPromptReady` makes the popup's close look like a severed
opener, and the missing decision leaves that path to answer on its own —
"denied", for a guest, moments after the user clicked Allow and the
grant committed.

Parse the configured origin once and compare canonical-to-canonical; the
popup URL is built from the same parsed origin, so a trailing slash no
longer yields a `//action/...` path either. A configured origin that
cannot parse could never have hosted the prompt, so it now denies up
front instead of opening a broken window. Pinned by an e2e test that
re-points the SDK at the same GUI through a trailing-slash origin and
expects the grant to be heard; it fails against the raw comparison.

* Take a permission popup's requester from the browser, not the link

`app_uid` was removed from the request-permission URL because the uid
names who receives the grant, so it has to come from the requesting
origin rather than from whoever built the link. Two other parameters
still carried exactly that identity, and the origin is the identity
twice over: it is the name the dialog attributes the request to, and it
is what the server resolves into the app the grant is written against.

`opener_origin` is believed on every popup boot, ahead of the referrer.
`origin` was the fallback in the request-permission block itself,
reached whenever there is no opener at all. Either one lets a bare link
raise a consent prompt in some other app's name — the token exchange
bootstraps an app row for whatever origin was typed, so the grant then
commits against it — while the user is looking at a domain the requester
does not control. That is the whole dialog defeated: a site can name
`docs.google.com` and have the Allow land on Google's app row.

So a permission popup now takes only an origin the browser vouches for:
`document.referrer`, or the opener's own reply to the `requestOrigin`
handshake. Neither can be forged to another origin. Nothing legitimate
relied on the parameters — the SDK sends neither, and the OIDC redirect
`opener_origin` exists for drops `action` too, so no permission flow can
arrive through one. The `origin` fallback only ever fired when there was
no opener, which is to say when there was no requester either; that path
now reports its denial as usual instead of prompting.

The referrer is safe here precisely because `action` is only
`request-permission` on the popup's first load, so it is always the
opener's. Restoring the action across an OIDC hop would break that — the
returning navigation's referrer is the identity provider — which is why
the round trip is withheld rather than repaired:

A popup that signs in through OIDC comes back to a redirect URI the
server hard-codes to `/action/sign-in`, so it returns believing it is a
plain sign-in popup. It posts `puter.token`, which the SDK's global
listener feeds straight into `setAuthToken()`, and never runs the action
it was opened for. For a permission prompt that is the exact outcome
`deliversTokenToOpener` exists to prevent: the site is signed in without
ever asking, the user is never shown the permission they were brought
there to decide, and the request resolves as a denial. Nothing in the
returned URL says what the popup was for, so it cannot recover on its
own. Until the redirect can carry the action back — and the opener
origin can be re-established from the handshake on return — a popup
whose purpose cannot survive the hop does not offer the hop. Email
sign-in stays in the window and is unaffected.

Pinned by e2e tests that spoof each parameter and expect the prompt to
name the real opener, or not to appear at all. The long-hostname test
drove the dialog through `origin=`, which no longer produces one, so it
now asserts the CSS elision contract directly while the popup test
asserts the real flow applies the `perm-dialog-entity-host` class that
contract keys on.

* Refuse an origin the grant could never have named

The identity line elides a long host from the left, keeping the
registrable domain visible, which it does with `direction: rtl`. That is
sound for a host — every character in one resolves left-to-right, so the
string reads in source order — but it is not sound for arbitrary text,
whose neutral and RTL runs can render in an order they were not written
in. On the one line of this dialog whose whole job is saying who is
asking, that is the wrong thing to be lenient about.

The entity resolver reached that state through its own fallback: when
`new URL(origin)` threw it displayed the unparsed string and still marked
it as a host. An origin the server cannot parse cannot name a grant
target either — `AuthService#originFromUrl` rejects it, and rejects
non-http(s) schemes with it — so there was never anything to prompt
about. Deny at the gate, next to the existing "requester the grant can't
name" check, on the same test the server applies.

* Give the decision poll a deadline it can actually reach

`pollDecision` bounds itself with a five-minute budget, but it only reads
the clock between iterations and its `fetch` had no timeout of its own. A
request that never settles — a stalled connection, a proxy that accepts
and never answers — parks that `await` forever: the loop never comes
back round to check, `settle` is never called, and the caller's promise
stays pending for the life of the page with the message listener and
interval still attached. It is the only unconditional hang left in the
flow, and the least recoverable one, because the popup is already closed
on this branch and nothing the user does can rescue it. Each attempt now
gets ten seconds — long enough that a slow-but-working connection is
still heard, short enough that the deadline means something.

The request id changes for a related reason. It was `#messageID++`, a
small integer restarting at 1 on every page load, and `event.source` is
not pinned for as long as the no-gesture consent dialog waits for its
Continue click — a stretch of time the user paces. A permission popup
left open from before a reload posts this exact message shape to its
opener on the way out, and its counter value collides with a fresh
request's, settling it with the decision the user made about a different
permission. A random suffix makes the two impossible to confuse; the GUI
echoes the value back verbatim, which the loose comparison still handles.

* Answer the app when the signup gate throws

The requestPermission branch grew a `respond` helper so every exit tells
the app something and its promise settles instead of hanging. One exit
still doesn't: `await UIWindowSignup(...)` is outside the guarded region,
and `ipc_listener` has no outer catch, so a throw from the signup window
escapes the listener entirely — before any reply — and leaves the app
waiting forever. That is the precise failure the helper was added to rule
out. Treat it as the refusal it amounts to.

* Revert "Keep the ai-chat model-map build inside the server lifecycle"

This reverts commit 1b2482dcb8.
2026-07-27 08:37:41 -07:00
Daniel Salazar b2324cd212 chore: cleanup puter-js driver to match rest of changes (#3452) 2026-07-27 08:25:44 -07:00
jelveh a1c8ba4cdd Fix stuck grab cursor on aborted drag
When dragging is aborted by returning false from the sortable 'start' handler, the cursor plugin never fires its cleanup, leaving the body cursor stuck on 'grabbing'. Reset the body cursor explicitly on each early-return path.
2026-07-26 23:29:33 -07:00
jelveh 30461e7d95 Hide Trash from file explorer listing
Trash is accessible via the sidebar and should not appear as a row in the file explorer. Filters it out from directory listings and prevents socket events from re-adding it to the view.
2026-07-26 22:32:32 -07:00
jelveh 46253fb80a Disable hover during rubberband file select
Add a `rubberband-selecting` state on the dashboard while marquee selection is active in `TabFiles`, and remove it when drag ends. CSS now disables pointer events on surrounding files chrome (sidebar, directory tree, and header) during that state so hover highlights/tooltips don’t trigger when the cursor leaves the clamped selection area.
2026-07-26 22:23:54 -07:00
jelveh 29f2a4d9c9 Default file picker and sidebar to Home
Make Home consistently visible and selected in file navigation. The file tab now shows the Home directory entry and auto-selects Home (instead of Desktop) when no initial path is provided. In `UIWindow`, saved sidebar layouts are backfilled to include Home if older preferences are missing that entry, preventing users from losing quick access after upgrades.
2026-07-26 22:14:17 -07:00
jelveh e00e1e57da Centralize trash icon update logic
Extract duplicated trash icon update code into two shared helpers:

- `window.update_trash_icons(is_empty)` updates all trash icon surfaces (taskbar, desktop items, shortcuts, open window headers, Dashboard sidebar) in one place.
- `window.refresh_trash_state()` stats the trash, emits the socket event, and calls `update_trash_icons`.

Replace all scattered inline stat+emit+icon-update blocks across UIItem, UIWindow, UIDesktop, UIDashboard, keyboard, helpers, and generate_file_context_menu with calls to these helpers. Also syncs the Dashboard sidebar trash icon on load and after every destructive operation.
2026-07-26 21:51:07 -07:00
Nariman Jelveh 6668d778d2 feat: highlight uploaded items after upload
Uploads now land selected wherever they finish: the desktop, explorer
windows, file dialogs, and the dashboard Files tab. Selection is applied
from both completion channels — the batch response selects elements
already in the DOM, and items whose item.added socket event arrives
later are remembered (with a TTL) and selected on creation. The explorer
footer count and the open-file dialog's Open button are updated to
match, and disabled items (e.g. filtered out by a dialog's file-type
filter) are never selected.
2026-07-26 21:14:26 -07:00
Nariman Jelveh 7f3691aea3 Auth: land back on the /app/<name> the user came for after login/signup (#3435)
Landing on /app/<name> logged-out and authenticating used to dump the
user at the root dashboard on several paths, losing the app they came
for:

- OIDC login/signup only sent return_to for /desktop and /dashboard
  (and the backend whitelist only accepted those two), so OIDC from an
  app landing redirected to /.
- UIWindowSignup defaulted its post-success redirect to /, so password
  signup reached via the session list, ?action=signup, or in-app signup
  prompts (IPC.js) lost the app.
- OIDC error redirects always went to /?action=..., so a recovered
  attempt (e.g. account-not-found bounced to signup) also lost the app.

New helpers in src/gui/src/helpers/auth_redirect.js:
- get_auth_redirect_url(): stay on the page auth started from;
  /action/* pages go to /; strips action/auth_error/message/
  request_code so the reload doesn't re-open the auth window or pass
  auth params through to the app as launch args.
- get_oidc_return_to(): pathname when whitelistable, now including
  /app/<name> (trailing slash normalized).

Backend (OIDCController):
- Shared isWhitelistedReturnPath() accepts /desktop, /dashboard, and
  /app/<name> (charset mirrors APP_NAME_REGEX — no open redirect).
- buildErrorRedirectUrl() lands on the whitelisted originating page
  from the signed state's redirect_uri instead of always /.

Tested: 47/47 OIDC controller tests pass (4 new: return_to accepted/
rejected on start, success redirect to /app/<name>, error redirect
keeping /app/<name>); verified live on local dev for first-visit temp
user, password signup (incl. email-confirmation gate), and password
login — all land on /app/camera with the app open.
2026-07-26 20:33:46 -07:00
200 changed files with 13458 additions and 9321 deletions

No files matched your search

+5
View File
@@ -287,6 +287,10 @@
"apiKey": "",
"apiBaseUrl": "https://openrouter.ai/api/v1"
},
"infron": {
"apiKey": "",
"apiBaseUrl": "https://llm.onerouter.pro/v1"
},
"together-ai": { "apiKey": "" },
// Local Ollama. `enabled: false` skips the auto-probe at startup
// (otherwise Puter logs ECONNREFUSED on every boot when no Ollama
@@ -325,6 +329,7 @@
"secret_key": "",
"region": "us-west-2"
},
"speechify": { "apiKey": "" },
"aws-textract": {
"access_key": "",
"secret_key": "",
+1 -1
View File
@@ -28,7 +28,7 @@ beforeAll(async () => {
});
afterAll(async () => {
await server.shutdown();
await server?.shutdown();
});
const seedOwnedApp = async (prefix: string) => {
+1 -1
View File
@@ -38,7 +38,7 @@ beforeAll(async () => {
});
afterAll(async () => {
await server.shutdown();
await server?.shutdown();
});
const seedUser = async () => {
+1 -1
View File
@@ -48,7 +48,7 @@ beforeAll(async () => {
});
afterAll(async () => {
await server.shutdown();
await server?.shutdown();
});
const seedUser = async () => {
+4 -4
View File
@@ -41,7 +41,7 @@ describe('thumbnails extension — handleThumbnailCreated', () => {
});
afterAll(async () => {
await server.shutdown();
await server?.shutdown();
});
it('uploads a valid data: URL thumbnail to S3 and rewrites event.url to an s3:// pointer', async () => {
@@ -108,7 +108,7 @@ describe('thumbnails extension — handleThumbnailUploadPrepare', () => {
});
afterAll(async () => {
await server.shutdown();
await server?.shutdown();
});
it('returns early when event has no items array', async () => {
@@ -188,7 +188,7 @@ describe('thumbnails extension — handleThumbnailRead', () => {
});
afterAll(async () => {
await server.shutdown();
await server?.shutdown();
});
it('rewrites an s3:// thumbnail into a presigned https URL', async () => {
@@ -281,7 +281,7 @@ describe('thumbnails extension — handleFsRemoveNodeThumbnail', () => {
});
afterAll(async () => {
await server.shutdown();
await server?.shutdown();
});
it('deletes the S3 object referenced by an s3:// thumbnail URL', async () => {
+1 -1
View File
@@ -51,7 +51,7 @@ beforeAll(async () => {
});
afterAll(async () => {
await server.shutdown();
await server?.shutdown();
});
const seedUser = async () => {
+66 -2
View File
@@ -1304,7 +1304,6 @@
},
"node_modules/@clack/prompts/node_modules/is-unicode-supported": {
"version": "1.3.0",
"extraneous": true,
"inBundle": true,
"license": "MIT",
"engines": {
@@ -9933,6 +9932,40 @@
"node": ">=10.2.0"
}
},
"node_modules/engine.io-client": {
"version": "6.5.4",
"resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.5.4.tgz",
"integrity": "sha512-GeZeeRjpD2qf49cZQ0Wvh/8NJNfeXkXXcoGh+F77oEAgo9gUHwT1fCRxSNU+YEEaysOJTnsFHmM5oAcPy4ntvQ==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.1",
"engine.io-parser": "~5.2.1",
"ws": "~8.17.1",
"xmlhttprequest-ssl": "~2.0.0"
}
},
"node_modules/engine.io-client/node_modules/ws": {
"version": "8.17.1",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz",
"integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/engine.io-parser": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz",
@@ -15468,6 +15501,12 @@
"tslib": "^2.0.3"
}
},
"node_modules/path-browserify": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/path-browserify/-/path-browserify-1.0.1.tgz",
"integrity": "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==",
"license": "MIT"
},
"node_modules/path-exists": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz",
@@ -17299,6 +17338,21 @@
}
}
},
"node_modules/socket.io-client": {
"version": "4.7.2",
"resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.7.2.tgz",
"integrity": "sha512-vtA0uD4ibrYD793SOIAwlo8cj6haOeMHrGvwPxJsxH7CeIksqJ+3Zc06RvWTIFgiSqx4A3sOnTXpfAEE2Zyz6w==",
"license": "MIT",
"dependencies": {
"@socket.io/component-emitter": "~3.1.0",
"debug": "~4.3.2",
"engine.io-client": "~6.5.2",
"socket.io-parser": "~4.2.4"
},
"engines": {
"node": ">=10.0.0"
}
},
"node_modules/socket.io-parser": {
"version": "4.2.6",
"resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.6.tgz",
@@ -19190,6 +19244,14 @@
"integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==",
"license": "MIT"
},
"node_modules/xmlhttprequest-ssl": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz",
"integrity": "sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==",
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",
@@ -20194,7 +20256,9 @@
"license": "Apache-2.0",
"dependencies": {
"@heyputer/kv.js": "^0.2.1",
"open": "^10.2.0"
"open": "^10.2.0",
"path-browserify": "1.0.1",
"socket.io-client": "4.7.2"
},
"devDependencies": {
"@playwright/test": "^1.49.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "puter.com",
"version": "2.5.1",
"version": "26.07",
"author": "Puter Technologies Inc.",
"license": "AGPL-3.0-only",
"description": "Desktop environment in the browser!",
+36 -15
View File
@@ -3,18 +3,19 @@
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
* Puter is free software: you can redistribute it and/or modify it under the
* terms of the GNU Affero General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option) any
* later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
* along with this program. If not, see
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
*/
import {
@@ -453,6 +454,7 @@ export class DDBClient extends PuterClient {
return;
}
let alreadyExisted = false;
try {
const client = await this.#getDocumentClient();
await client.send(new CreateTableCommand(params));
@@ -460,14 +462,33 @@ export class DDBClient extends PuterClient {
if ((error as Error)?.name !== 'ResourceInUseException') {
throw error;
}
alreadyExisted = true;
}
if (ttlAttribute) {
await this.#deleteExpiredItems(
params.TableName!,
params.KeySchema!,
ttlAttribute,
);
// Only sweep a table that was already there. `CreateTable` returns
// while the table is still CREATING, so scanning it right away races
// its transition to ACTIVE and intermittently throws
// `ResourceNotFoundException` — and a table we just created holds no
// items to expire anyway.
if (ttlAttribute && alreadyExisted) {
try {
await this.#deleteExpiredItems(
params.TableName!,
params.KeySchema!,
ttlAttribute,
);
} catch (error) {
// The sweep is opportunistic cleanup, so a table that isn't
// queryable yet must never fail boot. Emulators differ on
// whether `CreateTable` reports an existing table as in-use,
// which can still land us here on a CREATING table.
if ((error as Error)?.name !== 'ResourceNotFoundException') {
throw error;
}
console.warn(
`[ddb] skipped TTL sweep for ${params.TableName}: table not queryable yet`,
);
}
}
}
@@ -21,7 +21,7 @@ describe('EventClient', () => {
});
afterAll(async () => {
await server.shutdown();
await server?.shutdown();
});
// Each test gets a fresh key so listeners registered by earlier tests
@@ -31,13 +31,14 @@
import bcrypt from 'bcrypt';
import jwt from 'jsonwebtoken';
import { v4 as uuidv4 } from 'uuid';
import { v4 as uuidv4, v5 as uuidv5 } from 'uuid';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import type { EventClient } from '../../clients/event/EventClient.js';
import type { Actor } from '../../core/actor.js';
import { runWithContext } from '../../core/context.js';
import { Context, runWithContext } from '../../core/context.js';
import { HttpError } from '../../core/http/HttpError.js';
import { requireUserActorGate } from '../../core/http/middleware/gates.js';
import type { TokenSource } from '../../core/http/types.js';
import { PuterServer } from '../../server.js';
import { FULL_API_ACCESS } from '../../services/permission/consts.js';
import { setupTestServer } from '../../testUtil.js';
@@ -228,6 +229,7 @@ const makeReq = (
extra: Partial<{
actor: Actor;
token: string;
tokenSource: TokenSource;
headers: Record<string, unknown>;
ip: string;
params: Record<string, string>;
@@ -241,6 +243,7 @@ const makeReq = (
params: extra.params ?? {},
actor: extra.actor,
token: extra.token,
tokenSource: extra.tokenSource,
});
// PermissionService-backed handlers (grants, get-user-app-token) call
@@ -1385,6 +1388,10 @@ describe('AuthController account-lifecycle route gating', () => {
// ── Token grants: user → user / app / group ─────────────────────────
// Mirrors AuthService's namespace for origin-derived app uids, so a test can
// compute the uid an origin would synthesise without an app row.
const APP_ORIGIN_UUID_NAMESPACE = '33de3768-8ee0-43e9-9e73-db192b97a5d8';
describe('AuthController grant flows', () => {
let issuer: { id: number; uuid: string; username: string; email: string };
let target: { id: number; uuid: string; username: string; email: string };
@@ -1529,6 +1536,454 @@ describe('AuthController grant flows', () => {
).toContain(permission);
});
it('grant-user-app: resolves `origin` to the app when app_uid is omitted', async () => {
// The GUI permission dialog and puter.perms.grantOrigin() identify
// third-party sites by origin rather than app uid.
const appName = `tg-origin-${uuidv4()}`;
const origin = `https://${appName}.example.test`;
const app = await server.stores.app.create(
{
name: appName,
title: 'TestGrantOriginApp',
index_url: `${origin}/index.html`,
},
{ ownerUserId: issuer.id },
);
const permission = `service:tg-origin-app:ii:read`;
const res = makeRes();
await inCtx(issuerActor, () =>
controller.handleGrantUserApp(
makeReq(
{ origin, permission, extra: {} },
{ actor: issuerActor },
),
res,
),
);
expect(res.body).toEqual({});
const rows = await server.clients.db.read(
'SELECT p.`permission` FROM `user_to_app_permissions` p ' +
'JOIN `apps` a ON a.`id` = p.`app_id` ' +
'WHERE p.`user_id` = ? AND a.`uid` = ?',
[issuer.id, app.uid],
);
expect(
(rows as Array<{ permission: string }>).map((r) => r.permission),
).toContain(permission);
});
it('revoke-user-app: resolves `origin` to the app when app_uid is omitted', async () => {
// Mirrors the grant-by-origin path: grant via origin, then revoke via
// origin, and assert the permission row is gone.
const appName = `tr-origin-${uuidv4()}`;
const origin = `https://${appName}.example.test`;
const app = await server.stores.app.create(
{
name: appName,
title: 'TestRevokeOriginApp',
index_url: `${origin}/index.html`,
},
{ ownerUserId: issuer.id },
);
const permission = `service:tr-origin-app:ii:read`;
await inCtx(issuerActor, () =>
controller.handleGrantUserApp(
makeReq(
{ origin, permission, extra: {} },
{ actor: issuerActor },
),
makeRes(),
),
);
const res = makeRes();
await inCtx(issuerActor, () =>
controller.handleRevokeUserApp(
makeReq({ origin, permission }, { actor: issuerActor }),
res,
),
);
expect(res.body).toEqual({});
const rows = await server.clients.db.read(
'SELECT p.`permission` FROM `user_to_app_permissions` p ' +
'JOIN `apps` a ON a.`id` = p.`app_id` ' +
'WHERE p.`user_id` = ? AND a.`uid` = ?',
[issuer.id, app.uid],
);
expect(
(rows as Array<{ permission: string }>).map((r) => r.permission),
).not.toContain(permission);
});
it('grant-user-app: 400 on non-string or oversized origin/app_uid/permission', async () => {
const cases = [
{ origin: { host: 'evil' }, permission: 'service:x:ii:read' },
{ origin: 'https://a.test', permission: ['service:x:ii:read'] },
{ app_uid: 12345, permission: 'service:x:ii:read' },
{ origin: `https://${'a'.repeat(5000)}.test`, permission: 'service:x:ii:read' },
];
for (const body of cases) {
await expect(
controller.handleGrantUserApp(
makeReq(body, { actor: issuerActor }),
makeRes(),
),
).rejects.toMatchObject({ statusCode: 400 });
}
});
it('grant/revoke-user-app: an unregistered `origin` cannot be redirected onto an app squatting its synthetic uid', async () => {
// `appUidFromOrigin` synthesises `app-<uuidv5(origin)>` when no app row
// matches the origin, and the permission services resolve their
// identifier as uid-*or-name*. The namespace is a source constant, so
// the synthetic uid is computable offline — if it were passed straight
// through, registering an app under that literal *name* would collect
// grants the user made to the origin.
const origin = `https://unregistered-${uuidv4()}.example`;
const syntheticUid = `app-${uuidv5(origin, APP_ORIGIN_UUID_NAMESPACE)}`;
const squatter = await server.stores.app.create(
{
name: syntheticUid,
title: 'Squatter',
index_url: 'https://squatter.example/index.html',
},
{ ownerUserId: target.id },
);
const permission = 'service:squat:ii:read';
for (const handler of [
'handleGrantUserApp',
'handleRevokeUserApp',
] as const) {
await expect(
inCtx(issuerActor, () =>
controller[handler](
makeReq(
{ origin, permission, extra: {} },
{ actor: issuerActor },
),
makeRes(),
),
),
).rejects.toMatchObject({ statusCode: 404 });
}
const rows = await server.clients.db.read(
'SELECT p.`permission` FROM `user_to_app_permissions` p ' +
'WHERE p.`user_id` = ? AND p.`app_id` = ?',
[issuer.id, squatter.id],
);
expect(rows).toEqual([]);
});
it('grant/revoke-user-app: an `app_uid` sent beside an `origin` cannot redirect the grant away from that origin', async () => {
// The origin is what a consent prompt shows the user, so it has to
// decide who receives the grant. Resolving `origin` only when
// `app_uid` was absent left the squatter guard bypassable by simply
// sending both: the uid won, and it is resolved as uid-*or-name*, so
// the synthetic `app-<uuidv5(origin)>` of an unregistered origin landed
// on whoever registered an app under that literal name.
const origin = `https://unregistered-${uuidv4()}.example`;
const syntheticUid = `app-${uuidv5(origin, APP_ORIGIN_UUID_NAMESPACE)}`;
const squatter = await server.stores.app.create(
{
name: syntheticUid,
title: 'SquatterBesideOrigin',
index_url: 'https://squatter-beside-origin.example/index.html',
},
{ ownerUserId: target.id },
);
const permission = 'service:squat-beside:ii:read';
for (const handler of [
'handleGrantUserApp',
'handleRevokeUserApp',
] as const) {
await expect(
inCtx(issuerActor, () =>
controller[handler](
makeReq(
{
app_uid: syntheticUid,
origin,
permission,
extra: {},
},
{ actor: issuerActor },
),
makeRes(),
),
),
).rejects.toMatchObject({ statusCode: 404 });
}
const rows = await server.clients.db.read(
'SELECT p.`permission` FROM `user_to_app_permissions` p ' +
'WHERE p.`user_id` = ? AND p.`app_id` = ?',
[issuer.id, squatter.id],
);
expect(rows).toEqual([]);
});
it('grant-user-app: a registered `origin` beside an unrelated `app_uid` grants to the origin, not the uid', async () => {
// Same precedence rule, on the path where the origin does resolve: the
// uid travelling beside it must not steer the grant somewhere else.
const appName = `tp-origin-${uuidv4()}`;
const origin = `https://${appName}.example.test`;
const app = await server.stores.app.create(
{
name: appName,
title: 'TestPrecedenceOriginApp',
index_url: `${origin}/index.html`,
},
{ ownerUserId: issuer.id },
);
const other = await server.stores.app.create(
{
name: `tp-other-${uuidv4()}`,
title: 'TestPrecedenceOtherApp',
index_url: `https://tp-other-${uuidv4()}.example.test/index.html`,
},
{ ownerUserId: issuer.id },
);
const permission = 'service:tp-origin:ii:read';
await inCtx(issuerActor, () =>
controller.handleGrantUserApp(
makeReq(
{ app_uid: other.uid, origin, permission, extra: {} },
{ actor: issuerActor },
),
makeRes(),
),
);
const granted = async (appId: number) =>
(
(await server.clients.db.read(
'SELECT p.`permission` FROM `user_to_app_permissions` p ' +
'WHERE p.`user_id` = ? AND p.`app_id` = ?',
[issuer.id, appId],
)) as Array<{ permission: string }>
).map((r) => r.permission);
expect(await granted(app.id)).toContain(permission);
expect(await granted(other.id)).not.toContain(permission);
});
it('grant-user-app: 400 on a non-object `extra`/`meta` instead of committing then faulting', async () => {
// These are forwarded into the audit row and read as objects
// downstream, so a bad value used to surface as a 500 *after* the
// grant row was already written.
const appName = `tm-${uuidv4()}`;
const app = await server.stores.app.create(
{
name: appName,
title: 'TestMetaApp',
index_url: `https://${appName}.example.test/index.html`,
},
{ ownerUserId: issuer.id },
);
const permission = 'service:tm-app:ii:read';
for (const body of [
{ app_uid: app.uid, permission, meta: 'nope' },
{ app_uid: app.uid, permission, meta: [1, 2] },
{ app_uid: app.uid, permission, extra: 'nope' },
]) {
await expect(
inCtx(issuerActor, () =>
controller.handleGrantUserApp(
makeReq(body, { actor: issuerActor }),
makeRes(),
),
),
).rejects.toMatchObject({ statusCode: 400 });
}
// `null` means "absent", matching how the string params are treated,
// and must succeed rather than fault after the write.
const res = makeRes();
await inCtx(issuerActor, () =>
controller.handleGrantUserApp(
makeReq(
{ app_uid: app.uid, permission, extra: null, meta: null },
{ actor: issuerActor },
),
res,
),
);
expect(res.body).toEqual({});
});
it('grant-user-app: 400 on a `permission` wider than the column it lands in', async () => {
// 300 chars is under the 4096 input cap but over `varchar(255)`, so it
// used to reach the INSERT and fault on MySQL/Postgres. The check runs
// after the rewrite, so this has to be a permission no rewriter
// shortens — and it has to reject before the app is resolved, since
// this uid names no app.
await expect(
inCtx(issuerActor, () =>
controller.handleGrantUserApp(
makeReq(
{
app_uid: `app-${uuidv4()}`,
permission: `service:${'a'.repeat(300)}:ii:read`,
},
{ actor: issuerActor },
),
makeRes(),
),
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('grant-user-app: an oversized permission that a rewriter shortens is accepted', async () => {
// The width that matters is the rewritten string's, not the caller's.
// In production this is `fs:/deep/path:read` collapsing to
// `fs:<uuid>:read` — a ~45-char row however deep the path is. Measuring
// the raw input instead rejected grants whose stored value was
// comfortably inside the column, and the permission dialog dead-ended
// on "please try again" for any deeply nested file. Exercised here
// through a rewriter of our own, since the real fs rewriter resolves
// the path through the fsentry store and this suite has no entries.
const appName = `tlp-${uuidv4()}`;
const app = await server.stores.app.create(
{
name: appName,
title: 'TestLongPathApp',
index_url: `https://${appName}.example.test/index.html`,
},
{ ownerUserId: issuer.id },
);
const prefix = `tlprw-${uuidv4()}`;
const longPermission = `${prefix}:${'a'.repeat(300)}:read`;
const shortPermission = `${prefix}:${uuidv4()}:read`;
expect(longPermission.length).toBeGreaterThan(255);
expect(shortPermission.length).toBeLessThanOrEqual(255);
server.services.permission.registerRewriter({
id: `test-shorten-${prefix}`,
matches: (permission: string) => permission === longPermission,
rewrite: async () => shortPermission,
});
const res = makeRes();
await inCtx(issuerActor, () =>
controller.handleGrantUserApp(
makeReq(
{ app_uid: app.uid, permission: longPermission },
{ actor: issuerActor },
),
res,
),
);
expect(res.body).toEqual({});
const storedPermissions = async () =>
(
(await server.clients.db.read(
'SELECT p.`permission` FROM `user_to_app_permissions` p ' +
'WHERE p.`user_id` = ? AND p.`app_id` = ?',
[issuer.id, app.id],
)) as Array<{ permission: string }>
).map((r) => r.permission);
// What landed in the column is the short, rewritten form.
expect(await storedPermissions()).toContain(shortPermission);
// Revoke has to accept the same string grant did, or the dialog's
// withdrawal of an uncertain grant can never undo one of these.
const revokeRes = makeRes();
await inCtx(issuerActor, () =>
controller.handleRevokeUserApp(
makeReq(
{ app_uid: app.uid, permission: longPermission },
{ actor: issuerActor },
),
revokeRes,
),
);
expect(revokeRes.body).toEqual({});
expect(await storedPermissions()).not.toContain(shortPermission);
});
it('revoke-user-app: undoes a grant whose rewrite only resolves while granting', async () => {
// `app-root-dir:<uid>:<mode>` is a pseudo-permission: its rewriter
// resolves it to a real `fs:<root_uid>:<mode>` only while a user-app
// grant is being written, and deliberately resolves to a match-nothing
// sentinel at every other time so a scan can't match through the fs
// path. Revoke shares that rewrite, so it used to aim the DELETE at the
// sentinel and silently remove nothing — leaving the fs permission live
// while the caller was told the revoke succeeded. The permission
// dialog's withdrawal of an uncertain grant runs through exactly this
// path, so a user who answered "Don't Allow" kept the grant.
//
// Modelled with a rewriter of our own, shaped like the real one: the
// production rewriter resolves an app's root dir through the subdomain
// and fsentry stores, and this suite has neither.
const appName = `tard-${uuidv4()}`;
const app = await server.stores.app.create(
{
name: appName,
title: 'TestAppRootDirApp',
index_url: `https://${appName}.example.test/index.html`,
},
{ ownerUserId: issuer.id },
);
const prefix = `tardrw-${uuidv4()}`;
const pseudoPermission = `${prefix}:${app.uid}:write`;
const resolvedPermission = `fs:${uuidv4()}:write`;
const NOTHING = 'nothing-in-particular';
server.services.permission.registerRewriter({
id: `test-grant-only-${prefix}`,
matches: (permission: string) => permission.startsWith(`${prefix}:`),
// The real rewriter's condition, verbatim.
rewrite: async (permission: string) =>
Context.get('is_grant_user_app_permission')
? resolvedPermission
: NOTHING,
});
const storedPermissions = async () =>
(
(await server.clients.db.read(
'SELECT p.`permission` FROM `user_to_app_permissions` p ' +
'WHERE p.`user_id` = ? AND p.`app_id` = ?',
[issuer.id, app.id],
)) as Array<{ permission: string }>
).map((r) => r.permission);
await inCtx(issuerActor, () =>
controller.handleGrantUserApp(
makeReq(
{ app_uid: app.uid, permission: pseudoPermission },
{ actor: issuerActor },
),
makeRes(),
),
);
// The grant stored the *resolved* permission, not the pseudo one.
expect(await storedPermissions()).toContain(resolvedPermission);
const revokeRes = makeRes();
await inCtx(issuerActor, () =>
controller.handleRevokeUserApp(
makeReq(
{ app_uid: app.uid, permission: pseudoPermission },
{ actor: issuerActor },
),
revokeRes,
),
);
expect(revokeRes.body).toEqual({});
// Revoking by the same string the caller granted has to remove the row
// that grant actually wrote.
expect(await storedPermissions()).not.toContain(resolvedPermission);
// And it must not have written the sentinel as a row of its own.
expect(await storedPermissions()).not.toContain(NOTHING);
});
it('grant-user-group: 404 when the group does not exist', async () => {
await expect(
controller.handleGrantUserGroup(
@@ -3960,25 +4415,88 @@ describe('AuthController.handleCheckPermissions + handleListPermissions', () =>
]);
});
it('list-permissions: handler runs and returns shape (the source SQL references `app_uid` and may throw on real installs — we catch and assert either branch)', async () => {
const { actor } = await makeUserAndActor();
it('list-permissions: returns the shape and includes a user→app grant with its app_uid', async () => {
const { user, actor } = await makeUserAndActor();
const app = await server.stores.app.create(
{
name: `tl-${uuidv4()}`,
title: 'TestListPermsApp',
index_url: 'https://list-perms.example.test/index.html',
},
{ ownerUserId: user.id },
);
const permission = 'service:tl-app:ii:read';
await inCtx(actor, () =>
controller.handleGrantUserApp(
makeReq(
{ app_uid: app.uid, permission, extra: {} },
{ actor },
),
makeRes(),
),
);
// A user→user grant must show up under `myself_to_user` for the
// issuer and `user_to_myself` for the holder. Grants gate on
// `manage:<permission>`, so bootstrap that flag first (mirrors the
// grant-user-user persistence test).
const { user: holder, actor: holderActor } = await makeUserAndActor();
const userPermission = 'service:tl-user:ii:read';
await server.stores.permission.setFlatUserPerm(
user.id,
`manage:${userPermission}`,
{
permission: `manage:${userPermission}`,
deleted: false,
issuer_user_id: user.id,
} as never,
);
await inCtx(actor, () =>
controller.handleGrantUserUser(
makeReq(
{
target_username: holder.username,
permission: userPermission,
extra: {},
},
{ actor },
),
makeRes(),
),
);
const res = makeRes();
try {
await controller.handleListPermissions(makeReq({}, { actor }), res);
const body = res.body as {
myself_to_app: unknown[];
myself_to_user: unknown[];
user_to_myself: unknown[];
};
expect(Array.isArray(body.myself_to_app)).toBe(true);
expect(Array.isArray(body.myself_to_user)).toBe(true);
expect(Array.isArray(body.user_to_myself)).toBe(true);
} catch (e) {
// The current schema uses `app_id` in user_to_app_permissions.
// If the SQL fails because of the schema mismatch, surface the
// error message clearly so future fixes flip this branch off.
expect((e as Error).message).toMatch(/app_uid|no such column/);
}
await controller.handleListPermissions(makeReq({}, { actor }), res);
const body = res.body as {
myself_to_app: Array<{ app_uid: string; permission: string }>;
myself_to_user: Array<{ user: string; permission: string }>;
user_to_myself: unknown[];
};
expect(Array.isArray(body.user_to_myself)).toBe(true);
expect(body.myself_to_app).toContainEqual(
expect.objectContaining({ app_uid: app.uid, permission }),
);
expect(body.myself_to_user).toContainEqual(
expect.objectContaining({
user: holder.username,
permission: userPermission,
}),
);
const holderRes = makeRes();
await controller.handleListPermissions(
makeReq({}, { actor: holderActor }),
holderRes,
);
expect(
(holderRes.body as { user_to_myself: Array<{ user: string; permission: string }> })
.user_to_myself,
).toContainEqual(
expect.objectContaining({
user: user.username,
permission: userPermission,
}),
);
});
});
@@ -4148,6 +4666,174 @@ describe('AuthController dev-app permission flows', () => {
),
).rejects.toMatchObject({ statusCode: 400 });
});
it('grant/revoke-dev-app: an unregistered `origin` cannot be redirected onto an app squatting its synthetic uid', async () => {
// Same hole the user-app handlers close: `appUidFromOrigin`
// synthesises `app-<uuidv5(origin)>` for an origin with no app row,
// and the permission services resolve their identifier as
// uid-*or-name*. A dev-app grant is scanned with the *issuer's*
// authority for anyone running as that app, so landing one on a
// squatter hands over this user's permission. Without a squatter the
// origin 404s anyway, so requiring a registered app costs nothing.
const { user, actor } = await makeUserAndActor();
const origin = `https://unregistered-dev-${uuidv4()}.example`;
const syntheticUid = `app-${uuidv5(origin, APP_ORIGIN_UUID_NAMESPACE)}`;
const squatter = await server.stores.app.create(
{
name: syntheticUid,
title: 'DevSquatter',
index_url: 'https://dev-squatter.example/index.html',
},
{ ownerUserId: user.id },
);
const permission = 'service:dev-squat:ii:read';
// Let the grant past `canManagePermission`, so a failure here can only
// be the app-resolution guard rather than a missing manage right.
await server.stores.permission.setFlatUserPerm(
user.id,
`manage:${permission}`,
{
permission: `manage:${permission}`,
deleted: false,
issuer_user_id: user.id,
} as never,
);
for (const handler of [
'handleGrantDevApp',
'handleRevokeDevApp',
] as const) {
await expect(
inCtx(actor, () =>
controller[handler](
makeReq({ origin, permission }, { actor }),
makeRes(),
),
),
).rejects.toMatchObject({ statusCode: 404 });
}
const rows = await server.clients.db.read(
'SELECT `permission` FROM `dev_to_app_permissions` ' +
'WHERE `user_id` = ? AND `app_id` = ?',
[user.id, squatter.id],
);
expect(rows).toEqual([]);
});
it('grant-dev-app: a registered `origin` still resolves to its app', async () => {
// The guard above must not cost the legitimate case: an origin that
// really does name an app still grants to it.
const { user, actor } = await makeUserAndActor();
const appName = `dev-origin-${uuidv4()}`;
const origin = `https://${appName}.example.test`;
const app = await server.stores.app.create(
{
name: appName,
title: 'DevOriginApp',
index_url: `${origin}/index.html`,
},
{ ownerUserId: user.id },
);
const permission = 'service:dev-origin:ii:read';
await server.stores.permission.setFlatUserPerm(
user.id,
`manage:${permission}`,
{
permission: `manage:${permission}`,
deleted: false,
issuer_user_id: user.id,
} as never,
);
const res = makeRes();
await inCtx(actor, () =>
controller.handleGrantDevApp(
makeReq({ origin, permission }, { actor }),
res,
),
);
expect(res.body).toEqual({});
const rows = (await server.clients.db.read(
'SELECT `permission` FROM `dev_to_app_permissions` ' +
'WHERE `user_id` = ? AND `app_id` = ?',
[user.id, app.id],
)) as Array<{ permission: string }>;
expect(rows.map((r) => r.permission)).toContain(permission);
});
it('revoke-dev-app: `*` revokes everything exactly once', async () => {
// The `*` arm used to fall through: after `revokeDevAppAll` the
// handler also ran `revokeDevAppPermission(…, '*')` — a no-op DELETE
// for a row named literally `*` plus a second `revoke` audit entry.
// The user-app twin if/elses the two arms; this pins the parity.
const { user, actor } = await makeUserAndActor();
const appName = `dev-star-${uuidv4()}`;
const app = await server.stores.app.create(
{
name: appName,
title: 'DevStarApp',
index_url: `https://${appName}.example.test/index.html`,
},
{ ownerUserId: user.id },
);
const permission = 'service:dev-star:ii:read';
await server.stores.permission.setFlatUserPerm(
user.id,
`manage:${permission}`,
{
permission: `manage:${permission}`,
deleted: false,
issuer_user_id: user.id,
} as never,
);
await inCtx(actor, () =>
controller.handleGrantDevApp(
makeReq({ app_uid: app.uid, permission }, { actor }),
makeRes(),
),
);
const res = makeRes();
await inCtx(actor, () =>
controller.handleRevokeDevApp(
makeReq({ app_uid: app.uid, permission: '*' }, { actor }),
res,
),
);
expect(res.body).toEqual({});
const rows = await server.clients.db.read(
'SELECT `permission` FROM `dev_to_app_permissions` ' +
'WHERE `user_id` = ? AND `app_id` = ?',
[user.id, app.id],
);
expect(rows).toEqual([]);
// The audit write is fire-and-forget, so wait for it to land — and
// then a beat longer, since the defect here is a *second* row.
let audits: Array<{ permission: string; action: string }> = [];
const readAudits = async () =>
(await server.clients.db.read(
'SELECT `permission`, `action` ' +
'FROM `audit_dev_to_app_permissions` ' +
'WHERE `user_id_keep` = ? AND `app_id_keep` = ? ' +
"AND `action` = 'revoke'",
[user.id, app.id],
)) as Array<{ permission: string; action: string }>;
for (let i = 0; i < 100 && audits.length === 0; i++) {
audits = await readAudits();
if (audits.length === 0)
await new Promise((r) => setTimeout(r, 10));
}
await new Promise((r) => setTimeout(r, 50));
audits = await readAudits();
expect(audits).toEqual([{ permission: '*', action: 'revoke' }]);
});
});
// ── App origin resolution ──────────────────────────────────────────
@@ -4462,7 +5148,10 @@ describe('AuthController.handleGetGuiToken + handleSessionSyncCookie', () => {
const { user, actor } = await makeUserAndActor();
// No session → 400.
const r1 = makeRes();
await controller.handleSessionSyncCookie(makeReq({}, { actor }), r1);
await controller.handleSessionSyncCookie(
makeReq({}, { actor, tokenSource: 'header' }),
r1,
);
expect(r1.statusCode).toBe(400);
// Bound session → 204 with the session cookie set.
@@ -4478,12 +5167,43 @@ describe('AuthController.handleGetGuiToken + handleSessionSyncCookie', () => {
const r2 = makeRes();
await controller.handleSessionSyncCookie(
makeReq({}, { actor: sessionedActor }),
makeReq({}, { actor: sessionedActor, tokenSource: 'header' }),
r2,
);
expect(r2.statusCode).toBe(204);
expect(r2.cookies['puter_auth_token']).toBeDefined();
});
it('session/sync-cookie: refuses a token that arrived anywhere but the Authorization header', async () => {
const { user, actor } = await makeUserAndActor();
const sessionRes = await server.services.auth.createSessionToken(
user,
{},
);
const sessionUid = (sessionRes.session as { uuid: string }).uuid;
const sessionedActor = {
...actor,
session: { uid: sessionUid },
} as Actor;
for (const tokenSource of [
'query',
'cookie',
'body',
'x-api-key',
'handshake',
undefined,
] as (TokenSource | undefined)[]) {
const res = makeRes();
await expect(
controller.handleSessionSyncCookie(
makeReq({}, { actor: sessionedActor, tokenSource }),
res,
),
).rejects.toMatchObject({ statusCode: 401 });
expect(res.cookies['puter_auth_token']).toBeUndefined();
}
});
});
// ── Delete own user ────────────────────────────────────────────────
@@ -5495,7 +6215,7 @@ describe('AuthController.handleGetGuiToken / handleSessionSyncCookie additional
const res = makeRes();
await controller.handleSessionSyncCookie(
makeReq({}, { actor: sessionedActor }),
makeReq({}, { actor: sessionedActor, tokenSource: 'header' }),
res,
);
expect(res.statusCode).toBe(404);
+136 -18
View File
@@ -924,7 +924,8 @@ export class AuthController extends PuterController {
is_temp: user!.password === null && user!.email === null,
ip:
(req?.headers?.['x-forwarded-for'] as
string | undefined) ||
| string
| undefined) ||
(
req as unknown as {
connection?: { remoteAddress?: string };
@@ -2672,12 +2673,95 @@ export class AuthController extends PuterController {
res.json({});
}
/**
* Shared input validation for the user-app grant/revoke handlers, which
* accept a caller-supplied `origin` as an alternative to `app_uid`. All
* parameters are optional-but-typed: presence is enforced by the handlers'
* own `app_uid`/`permission` checks after origin resolution.
*
* These are bounded only against absurd input. The width of the column a
* permission lands in is enforced by the permission service instead, on the
* _rewritten_ string: `fs:/path:mode` is rewritten to `fs:<uuid>:mode`
* before it is stored, so a deep path is a ~45-character row and must not
* be rejected for the length of the path the caller typed.
*/
#validateAppPermissionParams(params: {
app_uid?: unknown;
origin?: unknown;
permission?: unknown;
extra?: unknown;
meta?: unknown;
}): void {
const MAX_LEN = 4096;
for (const key of ['app_uid', 'origin', 'permission'] as const) {
const value = params[key];
if (value === undefined || value === null) continue;
if (typeof value !== 'string' || value.length > MAX_LEN) {
throw new HttpError(400, `Invalid \`${key}\``, {
legacyCode: 'bad_request',
});
}
}
// `extra` and `meta` are forwarded into the audit row and read as
// objects downstream. A non-object would fault *after* the grant is
// committed, so reject it up front. `null` is treated as absent, the
// same as the string parameters above.
for (const key of ['extra', 'meta'] as const) {
const value = params[key];
if (value === undefined || value === null) continue;
if (typeof value !== 'object' || Array.isArray(value)) {
throw new HttpError(400, `Invalid \`${key}\``, {
legacyCode: 'bad_request',
});
}
}
}
/**
* Resolves a caller-supplied `origin` to the uid of a _registered_ app.
*
* `appUidFromOrigin` synthesises a deterministic `app-<uuidv5>` uid for an
* origin that has no app row yet, and the permission services resolve their
* identifier as uid-_or-name_. Passing a synthetic uid straight through
* would therefore let whoever registered an app under that literal name
* collect a grant the user made to the origin — the uid is derived from a
* published namespace constant, so it can be computed and squatted offline.
* Only a uid that names an existing app row is accepted.
*
* An `origin` supplied alongside an `app_uid` takes precedence over it (see
* the grant/revoke handlers). The origin is what a consent prompt shows the
* user, so it — not a uid travelling beside it — has to decide who receives
* the grant; otherwise a caller could name one app on screen and grant to
* another. No caller sends both with different intent.
*/
async #registeredAppUidFromOrigin(origin: string): Promise<string> {
const uid = await this.services.auth.appUidFromOrigin(origin);
const app = await this.stores.app.getByUid(uid);
if (!app) {
throw new HttpError(404, `entity_not_found: app:${uid}`, {
legacyCode: 'subject_does_not_exist',
});
}
return app.uid;
}
@Post('/auth/grant-user-app', {
subdomain: 'api',
requireUserActor: true,
})
async handleGrantUserApp(req: Request, res: Response): Promise<void> {
const { app_uid, permission, extra, meta } = req.body;
let { app_uid } = req.body;
const { origin, permission, extra, meta } = req.body;
this.#validateAppPermissionParams({
app_uid,
origin,
permission,
extra,
meta,
});
if (origin) {
app_uid = await this.#registeredAppUidFromOrigin(origin);
}
if (!app_uid || !permission) {
throw new HttpError(400, 'Missing `app_uid` or `permission`', {
legacyCode: 'bad_request',
@@ -2687,8 +2771,8 @@ export class AuthController extends PuterController {
req.actor!,
app_uid,
permission,
extra,
meta,
extra ?? undefined,
meta ?? undefined,
);
res.json({});
}
@@ -2748,7 +2832,17 @@ export class AuthController extends PuterController {
requireUserActor: true,
})
async handleRevokeUserApp(req: Request, res: Response): Promise<void> {
const { app_uid, permission, meta } = req.body;
let { app_uid } = req.body;
const { origin, permission, meta } = req.body;
this.#validateAppPermissionParams({
app_uid,
origin,
permission,
meta,
});
if (origin) {
app_uid = await this.#registeredAppUidFromOrigin(origin);
}
if (!app_uid || !permission) {
throw new HttpError(400, 'Missing `app_uid` or `permission`', {
legacyCode: 'bad_request',
@@ -2758,14 +2852,14 @@ export class AuthController extends PuterController {
await this.services.permission.revokeUserAppAll(
req.actor!,
app_uid,
meta,
meta ?? undefined,
);
} else {
await this.services.permission.revokeUserAppPermission(
req.actor!,
app_uid,
permission,
meta,
meta ?? undefined,
);
}
res.json({});
@@ -2908,7 +3002,15 @@ export class AuthController extends PuterController {
let { app_uid } = req.body;
const { origin, permission, extra, meta } = req.body;
if (origin && !app_uid) {
app_uid = await this.services.auth.appUidFromOrigin(origin);
// Registered apps only, for the same reason the user-app handlers
// insist on it: a synthesised `app-<uuidv5(origin)>` is resolved
// downstream as uid-*or-name*, so it would land on whoever
// registered an app under that literal name. A dev-app grant is
// scanned with the issuer's authority for anyone running as that
// app, so that hands this user's permission to the squatter.
// Without a squatter the synthetic uid resolves to nothing and
// this 404s regardless, so nothing legitimate changes.
app_uid = await this.#registeredAppUidFromOrigin(origin);
}
if (!app_uid || !permission) {
throw new HttpError(400, 'Missing `app_uid` or `permission`', {
@@ -2933,7 +3035,8 @@ export class AuthController extends PuterController {
let { app_uid } = req.body;
const { origin, permission, meta } = req.body;
if (origin && !app_uid) {
app_uid = await this.services.auth.appUidFromOrigin(origin);
// Registered apps only — see handleGrantDevApp.
app_uid = await this.#registeredAppUidFromOrigin(origin);
}
if (!app_uid || !permission) {
throw new HttpError(400, 'Missing `app_uid` or `permission`', {
@@ -2946,13 +3049,14 @@ export class AuthController extends PuterController {
app_uid,
meta,
);
} else {
await this.services.permission.revokeDevAppPermission(
req.actor!,
app_uid,
permission,
meta,
);
}
await this.services.permission.revokeDevAppPermission(
req.actor!,
app_uid,
permission,
meta,
);
res.json({});
}
@@ -2968,17 +3072,22 @@ export class AuthController extends PuterController {
const [appPerms, userPermsOut, userPermsIn] = await Promise.all([
db.read(
'SELECT `app_uid`, `permission`, `extra` FROM `user_to_app_permissions` WHERE `user_id` = ?',
// The permissions table stores the numeric `app_id` FK; the
// public shape exposes the app's `uid`.
'SELECT a.`uid` AS app_uid, p.`permission`, p.`extra` ' +
'FROM `user_to_app_permissions` p ' +
'JOIN `apps` a ON a.`id` = p.`app_id` ' +
'WHERE p.`user_id` = ?',
[userId],
),
db.read(
'SELECT u.`username`, p.`permission`, p.`extra` FROM `user_to_user_permissions` p ' +
'JOIN `user` u ON u.`id` = p.`target_user_id` WHERE p.`issuer_user_id` = ?',
'JOIN `user` u ON u.`id` = p.`holder_user_id` WHERE p.`issuer_user_id` = ?',
[userId],
),
db.read(
'SELECT u.`username`, p.`permission`, p.`extra` FROM `user_to_user_permissions` p ' +
'JOIN `user` u ON u.`id` = p.`issuer_user_id` WHERE p.`target_user_id` = ?',
'JOIN `user` u ON u.`id` = p.`issuer_user_id` WHERE p.`holder_user_id` = ?',
[userId],
),
]);
@@ -3613,6 +3722,15 @@ export class AuthController extends PuterController {
allowUnconfirmed: true,
})
async handleSessionSyncCookie(req: Request, res: Response): Promise<void> {
// This route installs a session cookie, so the token has to come from
// page script on our own origin rather than from the URL.
if (req.tokenSource !== 'header') {
throw new HttpError(
401,
'This endpoint requires an Authorization header.',
{ legacyCode: 'token_auth_failed' },
);
}
if (!req.actor?.session?.uid) {
res.status(400).end();
return;
@@ -533,10 +533,10 @@ export class DriverController extends PuterController {
// the live instance, so lifecycle hooks / inherited framework
// methods can never be dispatched.
this.#callableMethods.set(instance, resolveCallableMethods(instance));
// Register each alias pointing at the same instance. Legacy puter-js
// calls that pass a provider id in the `driver` slot (e.g. the TTS
// module sends `aws-polly` / `openai-tts` / `elevenlabs-tts` instead
// of the unified `ai-tts`) resolve here; the handler sets
// Register each alias pointing at the same instance. Calls that pass
// a provider id in the `driver` slot (e.g. `aws-polly` or
// `openai-tts` instead of the unified `ai-tts`, as SDK bundles
// predating the unified drivers do) resolve here; the handler sets
// Context.driverName to the alias so the method can route to the
// right internal provider.
for (const alias of meta.aliases) {
@@ -2910,4 +2910,24 @@ describe('LegacyFSController GET /get-launch-apps', () => {
expect(entry?.index_url).toBe('https://dev-owned-domain.example/');
expect(entry?.privateAccess).toBeUndefined();
});
// The rows are fetched in one batched lookup, which returns a map — the
// handler has to re-impose the recency order the uid list carries.
it('preserves the most-recent-first order of the underlying uid list', async () => {
const { actor, userId } = await makeUser();
const first = await makeHostedApp(userId, 'unregistered-a');
const second = await makeHostedApp(userId, 'unregistered-b');
const third = await makeHostedApp(userId, 'unregistered-c');
await recordOpen(userId, first.uid);
await recordOpen(userId, second.uid);
await recordOpen(userId, third.uid);
const recentUids = await server.stores.app.getRecentAppOpens(userId, {
limit: 10,
});
const returned = (await fetchRecent(actor)).map((a) => a.uuid);
expect(returned).toEqual(recentUids);
});
});
@@ -167,7 +167,8 @@ export class LegacyFSController extends PuterController {
router.get('/get-launch-apps', apiOptions, async (req, res) => {
const recommendedSvc = this.services.recommendedApps as unknown as
{ getRecommendedApps?: () => Promise<unknown[]> } | undefined;
| { getRecommendedApps?: () => Promise<unknown[]> }
| undefined;
const recommended = recommendedSvc?.getRecommendedApps
? await recommendedSvc.getRecommendedApps()
: [];
@@ -184,48 +185,60 @@ export class LegacyFSController extends PuterController {
) => Promise<string[]>;
}
).getRecentAppOpens?.(userId, { limit: 10 })) ?? [];
const apps: unknown[] = [];
for (const uid of recentUids) {
const app = await (
this.stores.app as unknown as {
getByUid: (
uid: string,
) => Promise<Record<string, unknown> | null>;
}
).getByUid(uid);
if (app) {
// Don't hand out an index_url whose puter-hosted
// backing is gone or reclaimed. The taskbar launches
// recents by name (so AppDriver's guard applies), but
// this list is a launch-metadata producer like any
// other — a future consumer reading index_url
// straight off it shouldn't inherit a stale origin.
const backingGone =
await hostedIndexUrlBackingIsUnavailable({
app,
subdomainStore: this.stores.subdomain,
config: this.config,
}).catch(() => true);
apps.push({
uuid: app.uid,
name: app.name,
title: app.title,
icon: app.icon ?? null,
godmode: Boolean(app.godmode),
maximize_on_start: Boolean(app.maximize_on_start),
index_url: backingGone ? null : app.index_url,
...(backingGone
? { privateAccess: buildHostedBackingDenial() }
: {}),
// An app with no owner isn't owned by a Puter user —
// it's an "external" (origin-bootstrapped) app.
external:
app.owner_user_id == null ||
app.owner_user_id === '',
});
// One batched read for the rows, then the backing checks
// concurrently. Serially awaiting a lookup per uid put ~2
// round trips of latency on every desktop boot.
const appsByUid = await (
this.stores.app as unknown as {
getByUids: (
uids: string[],
) => Promise<Map<string, Record<string, unknown>>>;
}
}
recent = apps;
).getByUids(recentUids);
// `recentUids` is ordered most-recent-first; preserve it.
const orderedApps = recentUids
.map((uid) => appsByUid.get(uid))
.filter((app): app is Record<string, unknown> =>
Boolean(app),
);
// Don't hand out an index_url whose puter-hosted backing is
// gone or reclaimed. The taskbar launches recents by name (so
// AppDriver's guard applies), but this list is a
// launch-metadata producer like any other — a future consumer
// reading index_url straight off it shouldn't inherit a stale
// origin.
const backingGoneFlags = await Promise.all(
orderedApps.map((app) =>
hostedIndexUrlBackingIsUnavailable({
app,
subdomainStore: this.stores.subdomain,
config: this.config,
}).catch(() => true),
),
);
recent = orderedApps.map((app, index) => {
const backingGone = backingGoneFlags[index];
return {
uuid: app.uid,
name: app.name,
title: app.title,
icon: app.icon ?? null,
godmode: Boolean(app.godmode),
maximize_on_start: Boolean(app.maximize_on_start),
index_url: backingGone ? null : app.index_url,
...(backingGone
? { privateAccess: buildHostedBackingDenial() }
: {}),
// An app with no owner isn't owned by a Puter user —
// it's an "external" (origin-bootstrapped) app.
external:
app.owner_user_id == null ||
app.owner_user_id === '',
};
});
}
res.json({ recommended, recent });
@@ -639,7 +652,9 @@ export class LegacyFSController extends PuterController {
// Trash, and `null`/`{}` when restoring. See
// `src/gui/src/helpers.js` → `window.move_items`.
newMetadata: (body.new_metadata ?? undefined) as
Record<string, unknown> | null | undefined,
| Record<string, unknown>
| null
| undefined,
});
const oldPath = source.path;
await this.#emitGuiEvent('outer.gui.item.moved', moved, {
@@ -1068,7 +1083,8 @@ export class LegacyFSController extends PuterController {
}
type SignedOrEmpty =
(SignedFile & { path?: string }) | Record<string, never>;
| (SignedFile & { path?: string })
| Record<string, never>;
const result: { signatures: SignedOrEmpty[]; token?: string } = {
signatures: [],
};
@@ -1608,7 +1624,10 @@ export class LegacyFSController extends PuterController {
const subjectRef = body.subject;
const appRef = body.app;
const mode = (getString(body, 'mode') ?? 'read') as
'see' | 'list' | 'read' | 'write';
| 'see'
| 'list'
| 'read'
| 'write';
if (!subjectRef || !appRef)
throw new HttpError(400, '`subject` and `app` are required', {
legacyCode: 'bad_request',
@@ -314,6 +314,33 @@ describe('HomepageController GET /app/:name', () => {
expect(html).not.toContain(secretUrl);
expect(html).not.toContain('owner_user_id');
});
it('omits index_url even for a public app — the shell is not the launch authority', async () => {
const { userId } = await makeUser();
const name = `pub-${Math.random().toString(36).slice(2, 10)}`;
const indexUrl = `https://public-${name}.example.com/`;
await server.stores.app.create(
{ name, title: 'Public App', index_url: indexUrl },
{ ownerUserId: userId },
);
const { res, captured } = makeRes();
await callRoute(
'get',
'/app/:name',
makeReq({ params: { name }, path: `/app/${name}` }),
res,
);
expect(captured.statusCode).toBe(200);
const html = String(captured.body);
expect(html).toContain('Public App');
// The GUI re-reads the app through the driver before launching, which
// is where the entitlement gate and hosted-backing guard run. Baking
// a launch URL into server-rendered HTML buys nothing and costs the
// lookups those guards require.
expect(html).not.toContain(indexUrl);
});
});
// ── /show/* ─────────────────────────────────────────────────────────
@@ -3,23 +3,25 @@
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
* Puter is free software: you can redistribute it and/or modify it under the
* terms of the GNU Affero General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option) any
* later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
* along with this program. If not, see
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
*/
import express from 'express';
import path from 'node:path';
import { PuterController } from '../types.js';
import { toAppShellView } from '../../util/appShellView.js';
import type { PuterRouter } from '../../core/http/PuterRouter';
import type {
PuterHomepageService,
@@ -32,8 +34,8 @@ import type {
* under `<gui_assets_root>/src` for non-dist/src paths (images, fonts, lib
* files referenced from the shell).
*
* All root-subdomain-only. Registered last in the controller list so the
* static catch-all doesn't shadow specific API routes.
* All root-subdomain-only. Registered last in the controller list so the static
* catch-all doesn't shadow specific API routes.
*/
export class HomepageController extends PuterController {
registerRoutes(router: PuterRouter) {
@@ -102,17 +104,11 @@ export class HomepageController extends PuterController {
: (app.metadata as Record<string, unknown> | null)) ??
{};
// Never bake the raw store row into the shell — it exposes
// `index_url`, `owner_user_id`, and admin flags. Run it
// through the authoritative serializer, which redacts to a
// safe field subset and drops `index_url` for a private app
// this actor (possibly anonymous) isn't entitled to.
const actor =
(req as express.Request & { actor?: unknown }).actor ??
null;
const clientApp = await this.drivers.apps.toClientView(
app,
actor,
);
// `index_url`, `owner_user_id`, and moderation flags. The
// shell only needs a preview; the GUI re-reads the app
// through the apps driver before launching, and that read is
// where the entitlement gate and hosted-backing guard run.
const clientApp = toAppShellView(app);
await sendShell(req, res, {
title: String(app.title ?? name),
description: String(app.description ?? ''),
@@ -329,6 +329,54 @@ describe('OIDCController GET /auth/oidc/:provider/start', () => {
expect(decoded).toMatchObject({ referrer: 'http://ref.test' });
});
it('bakes a whitelisted return_to (/app/<name>) into the state redirect_uri', async () => {
const { res, captured } = makeRes();
await callRoute(
'get',
'/auth/oidc/:provider/start',
makeReq({
params: { provider: 'custom' },
query: { return_to: '/app/my-App_2' },
}),
res,
);
const state = new URL(captured.redirectUrl ?? '').searchParams.get(
'state',
);
const decoded = oidc().verifyState(state!);
expect(String(decoded?.redirect_uri)).toBe(
`${TEST_ORIGIN}/app/my-App_2`,
);
});
it('ignores a non-whitelisted return_to', async () => {
const bad_values = [
'/app/evil/extra',
'/app/',
'/app/name?x=1',
'//evil.test',
'/settings',
`/app/${'a'.repeat(101)}`,
];
for (const return_to of bad_values) {
const { res, captured } = makeRes();
await callRoute(
'get',
'/auth/oidc/:provider/start',
makeReq({
params: { provider: 'custom' },
query: { return_to },
}),
res,
);
const state = new URL(
captured.redirectUrl ?? '',
).searchParams.get('state');
const decoded = oidc().verifyState(state!);
expect(String(decoded?.redirect_uri)).toBe(TEST_ORIGIN);
}
});
it('signs revalidate-flow state with user_uuid + flow=revalidate', async () => {
const userUuid = uuidv4();
const { res, captured } = makeRes();
@@ -621,6 +669,76 @@ describe('OIDCController login callback', () => {
expect(captured.cookies).toHaveLength(0);
});
it('redirects back to an /app/<name> landing after sign-in', async () => {
const state = oidc().signState({
provider: 'custom',
redirect_uri: `${TEST_ORIGIN}/app/some-app`,
});
vi.spyOn(oidc(), 'exchangeCodeForTokens').mockResolvedValue({
access_token: 'access',
id_token: 'id',
} as never);
vi.spyOn(oidc(), 'getUserInfo').mockResolvedValue({
sub: `sub-${Math.random().toString(36).slice(2, 8)}`,
email: `oidc-${Math.random().toString(36).slice(2, 8)}@test.local`,
email_verified: true,
} as never);
const { res, captured } = makeRes();
await callRoute(
'get',
'/auth/oidc/callback/login',
makeReq({ query: { code: 'c', state } }),
res,
);
expect(captured.cookies).toHaveLength(1);
expect(captured.redirectUrl).toBe(`${TEST_ORIGIN}/app/some-app`);
});
it('keeps an /app/<name> landing in the error redirect (suspended user)', async () => {
const sub = `sub-${Math.random().toString(36).slice(2, 8)}`;
const email = `sus-app-${Math.random().toString(36).slice(2, 8)}@test.local`;
const created = await runWithContext(
{ req: makeReq({}) },
() =>
oidc().createUserFromOIDC('custom', {
sub,
email,
email_verified: true,
}),
);
expect(created.success).toBe(true);
await server.stores.user.update(created.user!.id, { suspended: 1 });
const state = oidc().signState({
provider: 'custom',
redirect_uri: `${TEST_ORIGIN}/app/some-app`,
});
vi.spyOn(oidc(), 'exchangeCodeForTokens').mockResolvedValue({
access_token: 'access',
id_token: 'id',
} as never);
vi.spyOn(oidc(), 'getUserInfo').mockResolvedValue({
sub,
email,
email_verified: true,
} as never);
const { res, captured } = makeRes();
await callRoute(
'get',
'/auth/oidc/callback/login',
makeReq({ query: { code: 'c', state } }),
res,
);
expect(captured.redirectStatus).toBe(302);
const url = new URL(captured.redirectUrl ?? '');
expect(url.pathname).toBe('/app/some-app');
expect(url.searchParams.get('auth_error')).toBe('1');
expect(url.searchParams.get('action')).toBe('login');
expect(captured.cookies).toHaveLength(0);
});
it('appends oidc_login=true and uses popup-style URL when state is from a popup flow', async () => {
const sub = `sub-${Math.random().toString(36).slice(2, 8)}`;
const email = `oidc-${Math.random().toString(36).slice(2, 8)}@test.local`;
+50 -23
View File
@@ -3,18 +3,19 @@
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
* Puter is free software: you can redistribute it and/or modify it under the
* terms of the GNU Affero General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option) any
* later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
* along with this program. If not, see
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
*/
import crypto from 'node:crypto';
@@ -47,6 +48,17 @@ const ALLOWED_ERRORS = [
'signup_blocked',
] as const;
// GUI pages an OIDC flow may return to: /desktop, /dashboard, and direct app
// landings (/app/<name>, mirroring APP_NAME_REGEX in AppDriver). Strict
// whitelist — never a client-supplied URL (no open redirect).
function isWhitelistedReturnPath(path: string): boolean {
return (
path === '/desktop' ||
path === '/dashboard' ||
/^\/app\/[a-zA-Z0-9_-]{1,100}$/.test(path)
);
}
function buildErrorRedirectUrl(
origin: string,
sourceFlow: string,
@@ -62,6 +74,20 @@ function buildErrorRedirectUrl(
? message
: 'unauthorized';
// Land back on the whitelisted page the flow started from (e.g. an
// /app/<name> landing) so the retry — and the eventual success — keeps
// the user's destination. redirect_uri comes from the signed state and
// was built server-side, but re-check the path against the whitelist.
let pagePath = '/';
if (typeof stateDecoded?.redirect_uri === 'string') {
try {
const statePath = new URL(stateDecoded.redirect_uri).pathname;
if (isWhitelistedReturnPath(statePath)) pagePath = statePath;
} catch {
// unparsable redirect_uri: fall back to the root page
}
}
let params: URLSearchParams;
if (stateDecoded?.embedded_in_popup && stateDecoded?.msg_id != null) {
params = new URLSearchParams({
@@ -84,7 +110,7 @@ function buildErrorRedirectUrl(
if (requestCode) {
params.set('request_code', requestCode);
}
return `${base}/?${params.toString()}`;
return `${base}${pagePath}?${params.toString()}`;
}
function appendQueryParam(url: string, key: string, value: string): string {
@@ -101,8 +127,8 @@ function constantTimeEqual(a: string, b: string): boolean {
}
/**
* True iff `target` parses as a URL whose origin equals `origin`. Used to
* clamp OIDC redirect targets — `startsWith` would accept
* True iff `target` parses as a URL whose origin equals `origin`. Used to clamp
* OIDC redirect targets — `startsWith` would accept
* `https://puter.com.evil.com` against `https://puter.com`.
*/
function isSameOrigin(target: string, origin: string): boolean {
@@ -166,15 +192,15 @@ export class OIDCController extends PuterController {
let appRedirectUri = flowRedirects[flow] ?? (origin || '/');
// Optional GUI return path so login started from /desktop or
// /dashboard lands back there. Strict whitelist — never a
// client-supplied URL (no open redirect).
// Optional GUI return path so login started from /desktop,
// /dashboard, or an /app/<name> landing lands back there.
const rawReturnTo = Array.isArray(req.query.return_to)
? req.query.return_to[0]
: req.query.return_to;
if (
(flow === 'login' || flow === 'signup') &&
(rawReturnTo === '/desktop' || rawReturnTo === '/dashboard')
typeof rawReturnTo === 'string' &&
isWhitelistedReturnPath(rawReturnTo)
) {
appRedirectUri = `${origin}${rawReturnTo}`;
}
@@ -497,13 +523,14 @@ if (window.opener) {
/**
* Resolve an OIDC callback to a Puter user. In order:
* 1. Existing link on (provider, sub) → that user.
* 2. Email matches an existing account whose email is CONFIRMED →
* link (provider, sub) to that user.
* 3. Email matches an account whose email is UNCONFIRMED → refuse.
* We don't know who owns an unconfirmed address, so linking would
* let whoever controls the OIDC identity hijack a pending signup.
* 4. Otherwise create a new user and link.
*
* 1. Existing link on (provider, sub) → that user.
* 2. Email matches an existing account whose email is CONFIRMED → link
* (provider, sub) to that user.
* 3. Email matches an account whose email is UNCONFIRMED → refuse. We don't
* know who owns an unconfirmed address, so linking would let whoever
* controls the OIDC identity hijack a pending signup.
* 4. Otherwise create a new user and link.
*
* Step 2 also requires `email_verified !== false` on the OIDC side,
* otherwise a malicious IdP could claim someone else's email.
+34 -20
View File
@@ -3,32 +3,34 @@
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
* Puter is free software: you can redistribute it and/or modify it under the
* terms of the GNU Affero General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option) any
* later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
* along with this program. If not, see
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
*/
import type { Actor } from '../actor';
import type { TokenSource } from './types';
/**
* Global Express.Request augmentation for v2.
*
* Every field declared here is populated by *global* middleware installed by
* `PuterServer` (auth probe, body parser, etc.). Per-route fields stay local
* to their handlers via `TypedRequest<O>` instead.
* Every field declared here is populated by _global_ middleware installed by
* `PuterServer` (auth probe, body parser, etc.). Per-route fields stay local to
* their handlers via `TypedRequest<O>` instead.
*
* This module is import-only — it has no runtime exports. Files that consume
* the augmented `Request` should `import './expressAugmentation'` (or any
* file that imports it transitively) so TypeScript loads the declaration.
* the augmented `Request` should `import './expressAugmentation'` (or any file
* that imports it transitively) so TypeScript loads the declaration.
*/
declare global {
@@ -40,6 +42,12 @@ declare global {
/** The raw token string, if one was presented and parsed. */
token?: string;
/**
* Which request slot `token` came out of. Set alongside `token`;
* routes that mint browser credentials gate on it.
*/
tokenSource?: TokenSource;
tokenAuthFailed?: boolean;
/**
@@ -54,8 +62,8 @@ declare global {
auth_id?: string;
/**
* Short-lived server-signed JWT that proves the bearer was
* identified as `auth_id` by the rejected session. The GUI
* must echo this back (not the raw `auth_id`) on the next
* identified as `auth_id` by the rejected session. The GUI must
* echo this back (not the raw `auth_id`) on the next
* login/signup so the controller can rebind to the same user.
*/
reauth_token?: string;
@@ -70,7 +78,10 @@ declare global {
device: { vendor?: string; model?: string; type?: string };
};
/** True when the request's Host is a custom domain (not one of the configured Puter domains). */
/**
* True when the request's Host is a custom domain (not one of the
* configured Puter domains).
*/
is_custom_domain?: boolean;
/**
@@ -83,12 +94,15 @@ declare global {
/**
* Client-supplied device fingerprint (ThumbmarkJS hash) from the
* body or `x-puter-device-fingerprint` header, populated by the
* global fingerprint middleware. Present only when the client sent a
* well-shaped value; spoofable but stable per device across IPs.
* global fingerprint middleware. Present only when the client sent
* a well-shaped value; spoofable but stable per device across IPs.
*/
deviceFingerprint?: string;
/** Parsed cookies, populated by the global `cookie-parser` middleware. */
/**
* Parsed cookies, populated by the global `cookie-parser`
* middleware.
*/
cookies?: Record<string, string>;
}
}
@@ -262,6 +262,46 @@ describe('createAuthProbe — token extraction precedence', () => {
});
});
describe('createAuthProbe — tokenSource', () => {
const cases: Array<[string, Parameters<typeof makeReq>[0]]> = [
['body', { body: { auth_token: 'tok' } }],
['header', { headers: { authorization: 'Bearer tok' } }],
['x-api-key', { headers: { 'x-api-key': 'tok' } }],
['cookie', { cookieHeader: 'puter_token=tok' }],
['query', { query: { auth_token: 'tok' } }],
['handshake', { handshakeQuery: { auth_token: 'tok' } }],
];
const actor: Actor = { user: { uuid: 'u-1' } };
it.each(cases)('records %s', async (expected, init) => {
const stub = makeStubAuth(actor);
const probe = createAuthProbe({
authService: stub.service,
cookieName: 'puter_token',
});
const { req } = await runProbe(probe, makeReq(init));
expect(req.tokenSource).toBe(expected);
});
it('is left unset when no token is presented', async () => {
const stub = makeStubAuth(actor);
const probe = createAuthProbe({ authService: stub.service });
const { req } = await runProbe(probe, makeReq({}));
expect(req.tokenSource).toBeUndefined();
});
it('is left unset when the token fails to authenticate', async () => {
const stub = makeStubAuth(null);
const probe = createAuthProbe({ authService: stub.service });
const { req } = await runProbe(
probe,
makeReq({ headers: { authorization: 'Bearer tok' } }),
);
expect(req.tokenSource).toBeUndefined();
});
});
describe('createAuthProbe — header parsing', () => {
it("strips 'Bearer ' (case-insensitive) from the Authorization value", async () => {
const stub = makeStubAuth();
+42 -31
View File
@@ -3,18 +3,19 @@
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
* Puter is free software: you can redistribute it and/or modify it under the
* terms of the GNU Affero General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option) any
* later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
* along with this program. If not, see
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
*/
import type { Request, RequestHandler } from 'express';
@@ -22,6 +23,7 @@ import type {
AuthService,
ReauthReason,
} from '../../../services/auth/AuthService';
import type { TokenSource } from '../types';
// Ensure the `Request.actor` / `Request.token` augmentation is in scope
// wherever this middleware is imported.
@@ -29,27 +31,31 @@ import '../expressAugmentation';
interface AuthProbeOptions {
authService: AuthService;
/** Name of the session cookie to inspect. Falls back to `config.cookie_name`. */
/**
* Name of the session cookie to inspect. Falls back to
* `config.cookie_name`.
*/
cookieName?: string;
}
/**
* Non-enforcing auth probe. Runs globally (installed by `PuterServer`) on
* every request, tries to locate a token in the usual places, and — if one
* is present and valid — attaches an `Actor` to `req.actor`.
* Non-enforcing auth probe. Runs globally (installed by `PuterServer`) on every
* request, tries to locate a token in the usual places, and — if one is present
* and valid — attaches an `Actor` to `req.actor`.
*
* Key property: this middleware **never rejects**. Missing tokens, malformed
* tokens, expired tokens, tokens pointing at deleted users — all result in
* `req.actor` being left undefined. Per-route gates decide whether absence
* is acceptable.
* `req.actor` being left undefined. Per-route gates decide whether absence is
* acceptable.
*
* Token lookup order:
* 1. `req.body.auth_token`
* 2. `Authorization: Bearer <token>` header
* 3. `x-api-key` header — third-party SDK convention (Anthropic etc.)
* 4. Session cookie, only when the browser request is same-origin
* 5. `?auth_token=...` query param
* 6. Socket handshake query (for ws upgrades that pass through HTTP first)
*
* 1. `req.body.auth_token`
* 2. `Authorization: Bearer <token>` header
* 3. `x-api-key` header — third-party SDK convention (Anthropic etc.)
* 4. Session cookie, only when the browser request is same-origin
* 5. `?auth_token=...` query param
* 6. Socket handshake query (for ws upgrades that pass through HTTP first)
*/
export const createAuthProbe = (opts: AuthProbeOptions): RequestHandler => {
const { authService, cookieName } = opts;
@@ -60,11 +66,12 @@ export const createAuthProbe = (opts: AuthProbeOptions): RequestHandler => {
return;
}
const token = extractToken(req, cookieName);
if (!token) {
const extracted = extractToken(req, cookieName);
if (!extracted) {
next();
return;
}
const { token, source } = extracted;
try {
// Thread the request IP and User-Agent into authenticate so
@@ -103,6 +110,7 @@ export const createAuthProbe = (opts: AuthProbeOptions): RequestHandler => {
if (result.actor) {
req.actor = result.actor;
req.token = token;
req.tokenSource = source;
} else if (result.invalid) {
req.tokenAuthFailed = true;
}
@@ -117,12 +125,15 @@ export const createAuthProbe = (opts: AuthProbeOptions): RequestHandler => {
* Token extraction logic covering the request sources clients use to
* authenticate.
*/
const extractToken = (req: Request, cookieName?: string): string | null => {
const extractToken = (
req: Request,
cookieName?: string,
): { token: string; source: TokenSource } | null => {
// 1. Body (`{ "auth_token": "..." }`)
const bodyToken = (req.body as { auth_token?: unknown } | undefined)
?.auth_token;
if (typeof bodyToken === 'string' && bodyToken.length > 0) {
return stripBearer(bodyToken);
return { token: stripBearer(bodyToken), source: 'body' };
}
// 2. Authorization header. Reject `Basic ...` (not our scheme) and
@@ -138,7 +149,7 @@ const extractToken = (req: Request, cookieName?: string): string | null => {
) {
const stripped = authHeader.replace(/^Bearer\s+/i, '').trim();
if (stripped.length > 0 && stripped !== 'undefined') {
return stripped;
return { token: stripped, source: 'header' };
}
}
@@ -148,7 +159,7 @@ const extractToken = (req: Request, cookieName?: string): string | null => {
const xApiKey =
typeof req.header === 'function' ? req.header('x-api-key') : undefined;
if (typeof xApiKey === 'string' && xApiKey.length > 0) {
return stripBearer(xApiKey);
return { token: stripBearer(xApiKey), source: 'x-api-key' };
}
// 4. Cookie (set by login flow for session tokens). Do not let an
@@ -165,12 +176,12 @@ const extractToken = (req: Request, cookieName?: string): string | null => {
if (cookieName) {
const cookieToken = req.cookies?.[cookieName];
if (typeof cookieToken === 'string' && cookieToken.length > 0) {
return stripBearer(cookieToken);
return { token: stripBearer(cookieToken), source: 'cookie' };
}
}
const v2Token = req.cookies?.puter_token_v2;
if (typeof v2Token === 'string' && v2Token.length > 0) {
return stripBearer(v2Token);
return { token: stripBearer(v2Token), source: 'cookie' };
}
}
@@ -178,7 +189,7 @@ const extractToken = (req: Request, cookieName?: string): string | null => {
const queryToken = (req.query as { auth_token?: unknown } | undefined)
?.auth_token;
if (typeof queryToken === 'string' && queryToken.length > 0) {
return stripBearer(queryToken);
return { token: stripBearer(queryToken), source: 'query' };
}
// 6. Socket handshake (for websocket upgrades that pass through HTTP).
@@ -187,7 +198,7 @@ const extractToken = (req: Request, cookieName?: string): string | null => {
).handshake;
const handshakeToken = handshake?.query?.auth_token;
if (typeof handshakeToken === 'string' && handshakeToken.length > 0) {
return stripBearer(handshakeToken);
return { token: stripBearer(handshakeToken), source: 'handshake' };
}
return null;
+125 -101
View File
@@ -3,23 +3,37 @@
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
* Puter is free software: you can redistribute it and/or modify it under the
* terms of the GNU Affero General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option) any
* later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
* along with this program. If not, see
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
*/
import type { NextFunction, Request, RequestHandler, Response } from 'express';
import type { Actor } from '../actor';
/**
* Which request slot the auth probe found its token in, recorded on
* `req.tokenSource`. Routes that hand the browser durable credentials assert on
* it instead of accepting any token that authenticates.
*/
export type TokenSource =
| 'body'
| 'header'
| 'x-api-key'
| 'cookie'
| 'query'
| 'handshake';
/**
* Every route method PuterRouter exposes. Mirrors the express router surface
* plus WebDAV verbs that some endpoints still use.
@@ -47,7 +61,8 @@ export type RouteMethod =
/**
* Path shape accepted by express route methods. Kept permissive rather than
* re-exporting express's internal `PathParams` (which isn't stable public API).
* re-exporting express's internal `PathParams` (which isn't stable public
* API).
*/
export type RoutePath = string | RegExp | Array<string | RegExp>;
@@ -61,20 +76,20 @@ export type RoutePath = string | RegExp | Array<string | RegExp>;
* requireUserActor → adminOnly → allowedAppIds →
* caller `middleware: []` → handler
*
* `requireUserActor`, `adminOnly`, and `allowedAppIds` all imply
* `requireAuth`; the materializer dedupes so only one auth gate ends up
* in the chain. Commented-out slots are reserved for the next chunks
* (body parsing, post-auth gates, timing).
* `requireUserActor`, `adminOnly`, and `allowedAppIds` all imply `requireAuth`;
* the materializer dedupes so only one auth gate ends up in the chain.
* Commented-out slots are reserved for the next chunks (body parsing, post-auth
* gates, timing).
*/
/** One rate-limit window. See `RouteOptions.rateLimit` for semantics. */
export interface RouteRateLimit {
limit: number;
window: number;
/**
* Per-subscription overrides for `limit` (`SubscriptionPolicy.id`
* → cap). Same mechanic as `concurrent.bySubscription`: resolved
* via the metering service per request; falls back to the base
* `limit` when there's no actor / no metering / no match.
* Per-subscription overrides for `limit` (`SubscriptionPolicy.id` → cap).
* Same mechanic as `concurrent.bySubscription`: resolved via the metering
* service per request; falls back to the base `limit` when there's no actor
* / no metering / no match.
*/
bySubscription?: Record<string, number>;
key?: 'fingerprint' | 'ip' | 'user' | ((req: Request) => string);
@@ -83,20 +98,26 @@ export interface RouteRateLimit {
}
export interface RouteOptions {
/** Extra per-route middleware. Applied after built-in gates, before the handler. */
/**
* Extra per-route middleware. Applied after built-in gates, before the
* handler.
*/
middleware?: RequestHandler[];
/**
* Subdomain routing. If set, the route only matches requests whose
* leftmost subdomain is in this list (via `next('route')` skip).
* Subdomain routing. If set, the route only matches requests whose leftmost
* subdomain is in this list (via `next('route')` skip).
*
* If omitted, verb-routes (get/post/etc.) are restricted to the root
* origin only (no subdomain). Pass `'*'` to explicitly match ANY
* subdomain/root. `use()` middleware is not gated by default.
* If omitted, verb-routes (get/post/etc.) are restricted to the root origin
* only (no subdomain). Pass `'*'` to explicitly match ANY subdomain/root.
* `use()` middleware is not gated by default.
*/
subdomain?: string | string[];
/** Reject anonymous + suspended-user requests with 401/403. Only allows user and app actors */
/**
* Reject anonymous + suspended-user requests with 401/403. Only allows user
* and app actors
*/
requireAuth?: boolean;
/** Reject app/access-token actors. Implies `requireAuth`. */
@@ -117,15 +138,15 @@ export interface RouteOptions {
allowAccessToken?: boolean;
/**
* Reject bare user-session actors ("root" tokens — no app, no access
* token) with 403 `app_or_api_token_required`. Implies `requireAuth`.
* Use on API surfaces that must only be driven by a delegated
* credential (app/worker token or a dashboard-minted API token), so a
* copied session token can't double as an API credential. Compose with
* `requireUserActor` + `allowFullAccessToken` to further narrow to
* API tokens only (apps stay out too). Worker sessions
* (`session.kind === 'worker'`) always pass — a deployed worker is a
* delegated, revocable credential, never a root token.
* Reject bare user-session actors ("root" tokens — no app, no access token)
* with 403 `app_or_api_token_required`. Implies `requireAuth`. Use on API
* surfaces that must only be driven by a delegated credential (app/worker
* token or a dashboard-minted API token), so a copied session token can't
* double as an API credential. Compose with `requireUserActor` +
* `allowFullAccessToken` to further narrow to API tokens only (apps stay
* out too). Worker sessions (`session.kind === 'worker'`) always pass — a
* deployed worker is a delegated, revocable credential, never a root
* token.
*/
noUserSession?: boolean;
@@ -134,11 +155,11 @@ export interface RouteOptions {
* extras in this array. `true` means just `admin`/`system`; an array adds
* to that pair (does not replace it). Implies `requireAuth`.
*
* Also requires a *root token* (an actor with no app anywhere in its
* token chain), so an admin acting through a third-party app can't reach
* the route. Pair with `allowedAppIds` to make an admin route reachable
* by specific apps: the combination admits a root token OR a token
* scoped to an allowed app.
* Also requires a _root token_ (an actor with no app anywhere in its token
* chain), so an admin acting through a third-party app can't reach the
* route. Pair with `allowedAppIds` to make an admin route reachable by
* specific apps: the combination admits a root token OR a token scoped to
* an allowed app.
*
* Does NOT imply `requireUserActor` — a root token still includes an
* admin's full-access personal access token, not only browser sessions.
@@ -146,16 +167,19 @@ export interface RouteOptions {
*/
adminOnly?: boolean | string[];
/** Reject unless the actor is acting through one of these apps. Implies `requireAuth`. */
/**
* Reject unless the actor is acting through one of these apps. Implies
* `requireAuth`.
*/
allowedAppIds?: string[];
/**
* Allow users whose account is pending email confirmation to access
* this route. By default, any authenticated route rejects users where
* `requires_email_confirmation && !email_confirmed` with 403. Set
* this to `true` on essential flows that must remain accessible
* before confirmation: logout, email-confirm, whoami, save-account,
* anti-CSRF token, etc.
* Allow users whose account is pending email confirmation to access this
* route. By default, any authenticated route rejects users where
* `requires_email_confirmation && !email_confirmed` with 403. Set this to
* `true` on essential flows that must remain accessible before
* confirmation: logout, email-confirm, whoami, save-account, anti-CSRF
* token, etc.
*
* Only meaningful when the route also requires authentication (via
* `requireAuth`, `requireUserActor`, `adminOnly`, or `allowedAppIds`).
@@ -168,8 +192,8 @@ export interface RouteOptions {
* `config.strict_email_verification_required` is falsy, so self-hosted
* deployments can opt in via config. Implies `requireAuth` but NOT
* `requireUserActor` — app-under-user actors also carry a `.user`, so
* verification applies uniformly whether the user acts directly or
* through an app.
* verification applies uniformly whether the user acts directly or through
* an app.
*/
requireVerified?: boolean;
@@ -179,14 +203,15 @@ export interface RouteOptions {
* the raw bytes on `req.rawBody` for signature-verification use cases.
*
* Use this option only when a route needs different parser settings:
* - `false` — opt out of parsing entirely (rare; the route reads the
* raw stream itself, e.g. some webhook proxies). The global parser
* will still have already run if the content-type was JSON, so this
* is mostly useful for routes that accept *non*-JSON body shapes
* and want to ensure no further parsers attach.
* - `{ limit, type }` — override the limit (e.g., for ML endpoints
* that legitimately need 100mb) or the matched content-type list
* (e.g., to ALSO accept `application/x-ndjson`).
*
* - `false` — opt out of parsing entirely (rare; the route reads the raw
* stream itself, e.g. some webhook proxies). The global parser will still
* have already run if the content-type was JSON, so this is mostly useful
* for routes that accept _non_-JSON body shapes and want to ensure no
* further parsers attach.
* - `{ limit, type }` — override the limit (e.g., for ML endpoints that
* legitimately need 100mb) or the matched content-type list (e.g., to
* ALSO accept `application/x-ndjson`).
*/
bodyJson?: false | { limit?: string; type?: string | string[] };
@@ -211,66 +236,64 @@ export interface RouteOptions {
bodyUrlencoded?: boolean | { limit?: string; extended?: boolean };
/**
* Require captcha verification. When `true`, the route rejects
* requests that don't carry valid `captchaToken` + `captchaAnswer`
* fields in the body. No-op when captcha is disabled in config.
* Require captcha verification. When `true`, the route rejects requests
* that don't carry valid `captchaToken` + `captchaAnswer` fields in the
* body. No-op when captcha is disabled in config.
*/
captcha?: boolean;
/**
* Require a valid one-time anti-CSRF token in `req.body.anti_csrf`.
* The token is consumed on use. Requires authentication (keyed by
* user uuid).
* Require a valid one-time anti-CSRF token in `req.body.anti_csrf`. The
* token is consumed on use. Requires authentication (keyed by user uuid).
*/
antiCsrf?: boolean;
/**
* Per-route rate limiting. In-memory sliding window keyed by
* request identity.
* Per-route rate limiting. In-memory sliding window keyed by request
* identity.
*
* `key` controls how requests are bucketed:
* - `'fingerprint'` (default) — network hash (IP + headers),
* refined by the client's device fingerprint when one was
* supplied. Safe for shared IPs (offices, VPNs): each device
* gets its own bucket instead of the whole network sharing one.
* - `'ip'` — bare IP address.
* - `'user'` — actor's user ID. Use for authenticated routes
* where you want per-account limits.
* - `(req) => string` — custom key function.
*
* `scope` is an optional namespace prefix to isolate counters
* between routes that share the same key strategy. Defaults to
* the route path.
* - `'fingerprint'` (default) — network hash (IP + headers), refined by the
* client's device fingerprint when one was supplied. Safe for shared IPs
* (offices, VPNs): each device gets its own bucket instead of the whole
* network sharing one.
* - `'ip'` — bare IP address.
* - `'user'` — actor's user ID. Use for authenticated routes where you want
* per-account limits.
* - `(req) => string` — custom key function.
*
* `backend` selects the storage backend ('memory' / 'redis' / 'kv').
* All registered backends stay co-resident at runtime, so different
* routes can pick whichever fits their access pattern. Omitting
* `backend` uses the server-wide default (`config.rate_limit.backend`).
* `scope` is an optional namespace prefix to isolate counters between
* routes that share the same key strategy. Defaults to the route path.
*
* An array applies every limit independently (a request must pass all
* of them). Use this to pair a per-client budget with a coarser
* backstop on a different key — e.g. per-fingerprint for fairness on
* shared IPs, plus per-IP so rotating headers can't mint fresh
* fingerprint buckets indefinitely. Give each entry its own `scope`
* so the counters don't collide.
* `backend` selects the storage backend ('memory' / 'redis' / 'kv'). All
* registered backends stay co-resident at runtime, so different routes can
* pick whichever fits their access pattern. Omitting `backend` uses the
* server-wide default (`config.rate_limit.backend`).
*
* An array applies every limit independently (a request must pass all of
* them). Use this to pair a per-client budget with a coarser backstop on a
* different key — e.g. per-fingerprint for fairness on shared IPs, plus
* per-IP so rotating headers can't mint fresh fingerprint buckets
* indefinitely. Give each entry its own `scope` so the counters don't
* collide.
*/
rateLimit?: RouteRateLimit | RouteRateLimit[];
/**
* Concurrent in-flight limiting. Caps how many requests for this
* key are simultaneously in flight, rather than how many fire per
* window. A slot is acquired before the handler runs and released
* on `res.finish` / `res.close` — aborted requests still give their
* slot back.
* Concurrent in-flight limiting. Caps how many requests for this key are
* simultaneously in flight, rather than how many fire per window. A slot is
* acquired before the handler runs and released on `res.finish` /
* `res.close` — aborted requests still give their slot back.
*
* { concurrent: { limit: 5, key: 'user' } }
* { concurrent: { limit: 5, bySubscription: { user_free: 2 } } }
* { concurrent: { limit: 5, key: 'user' } } { concurrent: { limit: 5,
* bySubscription: { user_free: 2 } } }
*
* `bySubscription` overrides the base `limit` per subscription tier
* (`SubscriptionPolicy.id`) — `user_free`, `temp_free`, `unlimited`
* out of the box. Requires the metering service to be wired into
* the rate-limit module (`configureRateLimit({ metering: ... })`)
* and an authenticated actor; otherwise the base `limit` applies.
* (`SubscriptionPolicy.id`) — `user_free`, `temp_free`, `unlimited` out of
* the box. Requires the metering service to be wired into the rate-limit
* module (`configureRateLimit({ metering: ... })`) and an authenticated
* actor; otherwise the base `limit` applies.
*
* `key`, `scope`, `backend` parallel the `rateLimit` option exactly;
* there's no `window` — that's the whole point of the second gate.
@@ -292,7 +315,8 @@ export interface RouteOptions {
/**
* Normalized route record produced by PuterRouter (and the class/method
* decorators). `path` is omitted only for `router.use(handler)` / `use(options, handler)`.
* decorators). `path` is omitted only for `router.use(handler)` / `use(options,
* handler)`.
*/
export interface RouteDescriptor {
method: RouteMethod;
@@ -303,8 +327,8 @@ export interface RouteDescriptor {
/**
* Shape stored on decorated controller prototypes by `@Get` / `@Post` / etc.
* `handler` is the method reference — still unbound at decoration time;
* the installed `registerRoutes` binds it to the instance at walk time.
* `handler` is the method reference — still unbound at decoration time; the
* installed `registerRoutes` binds it to the instance at walk time.
*/
export interface CollectedRoute {
method: RouteMethod;
@@ -332,8 +356,8 @@ export const PREFIX_METADATA_KEY = '__puterControllerPrefix' as const;
/**
* `true` iff the materializer will run an auth gate before the handler.
* Branches match readonly *and* mutable arrays so callers don't need
* `as const` on every options literal.
* Branches match readonly _and_ mutable arrays so callers don't need `as const`
* on every options literal.
*/
export type AuthRequired<O extends RouteOptions> = O extends {
requireAuth: true;
@@ -38,6 +38,7 @@ import { DeepSeekProvider } from './providers/deepseek/DeepSeekProvider.js';
import { FakeChatProvider } from './providers/FakeChatProvider.js';
import { GeminiChatProvider } from './providers/gemini/GeminiChatProvider.js';
import { GroqAIProvider } from './providers/groq/GroqAIProvider.js';
import { InfronProvider } from './providers/infron/InfronProvider.js';
import { MiniMaxProvider } from './providers/minimax/MiniMaxProvider.js';
import { MistralAIProvider } from './providers/mistral/MistralAiProvider.js';
import { MoonshotProvider } from './providers/moonshot/MoonshotProvider.js';
@@ -987,6 +988,18 @@ export class ChatCompletionDriver extends PuterDriver {
);
}
const infron = providers['infron'];
const infronKey = readKey(infron);
if (infronKey) {
this.#providers['infron'] = new InfronProvider(
{
apiKey: infronKey,
apiBaseUrl: infron?.apiBaseUrl as string | undefined,
},
metering,
);
}
// Fake provider — always available for testing
this.#providers['fake-chat'] = new FakeChatProvider();
}
@@ -994,7 +1007,7 @@ export class ChatCompletionDriver extends PuterDriver {
// -- Model map ---------------------------------------------------
async #buildModelMap() {
const AGGREGATORS = new Set(['together-ai', 'openrouter']);
const AGGREGATORS = new Set(['together-ai', 'openrouter', 'infron']);
for (const providerName in this.#providers) {
const provider = this.#providers[providerName];
@@ -0,0 +1,59 @@
/**
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/**
* Integration test for the Infron aggregator.
*
* Routes through Infron to a tiny upstream model
* (`deepseek/deepseek-v4-flash`). Skipped when
* `PUTER_TEST_AI_INFRON_API_KEY` is unset.
*/
import { describe, expect, it } from 'vitest';
import {
INTEGRATION_TEST_TIMEOUT_MS,
makeMeteringStub,
optionalEnv,
skipUnlessEnv,
withTestActor,
} from '../../../integrationTestUtil.js';
import { InfronProvider } from './InfronProvider.js';
const ENV_VAR = 'PUTER_TEST_AI_INFRON_API_KEY';
describe.skipIf(skipUnlessEnv(ENV_VAR))('InfronProvider (integration)', () => {
it('returns a non-empty completion via Infron', { timeout: INTEGRATION_TEST_TIMEOUT_MS }, async () => {
const provider = new InfronProvider(
{ apiKey: optionalEnv(ENV_VAR)! },
makeMeteringStub(),
);
const result = await withTestActor(() =>
provider.complete({
model: 'infron:deepseek/deepseek-v4-flash',
messages: [{ role: 'user', content: 'Say hi in one word.' }],
max_tokens: 16,
}),
);
const text = (result as { message?: { content?: string } }).message
?.content;
expect(typeof text === 'string' && text.length > 0).toBe(true);
});
});
@@ -0,0 +1,483 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/**
* Offline unit tests for InfronProvider.
*
* Boots a real PuterServer (in-memory sqlite + dynamo + s3 + mock
* redis) and constructs InfronProvider directly against the live
* wired `MeteringService` so the recording side is exercised end-to-
* end. Infron is OpenAI-compatible, so the OpenAI SDK is mocked at
* the module boundary; the model catalog is fetched via `axios`
* which is mocked at its module boundary too. Both are the real
* network egress points. Each test clears the kv-cached model list.
* The companion integration test (InfronProvider.integration.test.ts)
* exercises the real Infron endpoint.
*/
import { Writable } from 'node:stream';
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
type MockInstance,
} from 'vitest';
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
import { PuterServer } from '../../../../server.js';
import { setupTestServer } from '../../../../testUtil.js';
import { kv } from '../../../../util/kvSingleton.js';
import { withTestActor } from '../../../integrationTestUtil.js';
import { AIChatStream } from '../../utils/Streaming.js';
import { InfronProvider } from './InfronProvider.js';
// ── OpenAI SDK mock ─────────────────────────────────────────────────
const { createMock, openAICtor } = vi.hoisted(() => ({
createMock: vi.fn(),
openAICtor: vi.fn(),
}));
vi.mock('openai', () => {
const OpenAICtor = vi.fn().mockImplementation(function (
this: Record<string, unknown>,
opts: unknown,
) {
openAICtor(opts);
this.chat = { completions: { create: createMock } };
});
return { OpenAI: OpenAICtor, default: { OpenAI: OpenAICtor } };
});
// ── axios mock (model catalog endpoint) ─────────────────────────────
const { axiosRequestMock } = vi.hoisted(() => ({
axiosRequestMock: vi.fn(),
}));
vi.mock('axios', () => ({
default: { request: axiosRequestMock },
request: axiosRequestMock,
}));
// ── Test harness ────────────────────────────────────────────────────
let server: PuterServer;
let recordSpy: MockInstance<MeteringService['utilRecordUsageObject']>;
const KV_KEY = 'infronChat:models';
// Prices are USD per million tokens (Infron catalog convention).
const SAMPLE_API_MODELS = [
{
id: 'deepseek/deepseek-v4-flash',
display_name: 'DeepSeek: DeepSeek V4 Flash',
category_type: 'LLM',
supported_endpoint_types: ['openai'],
context_length: 1000000,
max_output_tokens: 384000,
min_prompt_price: 10,
min_completion_price: 30,
},
{
id: 'anthropic/claude-haiku-4.5',
display_name: 'Anthropic: Claude Haiku 4.5',
category_type: 'LLM',
supported_endpoint_types: ['openai'],
context_length: 200000,
max_output_tokens: 8192,
min_prompt_price: 2,
min_completion_price: 10,
},
{
// Non-chat modality — filtered out.
id: 'black-forest-labs/flux-2.1',
display_name: 'FLUX 2.1',
category_type: 'Text to Image',
supported_endpoint_types: ['openai'],
min_request_price: 0.04,
},
{
// Display-only entries are not callable — filtered out.
id: 'example/display-only-model',
display_name: 'Display Only',
category_type: 'LLM',
is_display_only: true,
supported_endpoint_types: ['openai'],
},
];
const seedModelsCache = () =>
axiosRequestMock.mockResolvedValue({ data: { data: SAMPLE_API_MODELS } });
beforeAll(async () => {
server = await setupTestServer();
});
afterAll(async () => {
await server?.shutdown();
});
const makeProvider = () => {
const provider = new InfronProvider(
{ apiKey: 'test-key' },
server.services.metering,
);
return { provider };
};
const asAsyncIterable = <T>(items: T[]): AsyncIterable<T> => ({
async *[Symbol.asyncIterator]() {
for (const item of items) {
yield item;
}
},
});
const makeCapturingChatStream = () => {
const chunks: string[] = [];
const sink = new Writable({
write(chunk, _enc, cb) {
chunks.push(chunk.toString('utf8'));
cb();
},
});
const chatStream = new AIChatStream({ stream: sink });
return {
chatStream,
events: () =>
chunks
.join('')
.split('\n')
.filter(Boolean)
.map((line) => JSON.parse(line)),
};
};
beforeEach(() => {
createMock.mockReset();
openAICtor.mockReset();
axiosRequestMock.mockReset();
seedModelsCache();
kv.del(KV_KEY);
recordSpy = vi.spyOn(server.services.metering, 'utilRecordUsageObject');
});
afterEach(() => {
vi.restoreAllMocks();
kv.del(KV_KEY);
});
// ── Construction ────────────────────────────────────────────────────
describe('InfronProvider construction', () => {
it('points the OpenAI SDK at the Infron base URL with the configured key', () => {
makeProvider();
expect(openAICtor).toHaveBeenCalledTimes(1);
expect(openAICtor).toHaveBeenCalledWith({
apiKey: 'test-key',
baseURL: 'https://llm.onerouter.pro/v1',
});
});
it('honours an apiBaseUrl override', () => {
new InfronProvider(
{
apiKey: 'test-key',
apiBaseUrl: 'https://custom.infron.example/v1',
},
server.services.metering,
);
expect(openAICtor).toHaveBeenLastCalledWith({
apiKey: 'test-key',
baseURL: 'https://custom.infron.example/v1',
});
});
});
// ── Model catalog ───────────────────────────────────────────────────
describe('InfronProvider model catalog', () => {
it('returns the infron-prefixed default model id', () => {
const { provider } = makeProvider();
expect(provider.getDefaultModel()).toBe(
'infron:deepseek/deepseek-v4-flash',
);
});
it('sends the API key as a bearer token on the catalog fetch', async () => {
const { provider } = makeProvider();
await provider.models();
const [args] = axiosRequestMock.mock.calls[0]!;
expect(args.url).toBe('https://llm.onerouter.pro/v1/models');
expect(args.headers).toMatchObject({
Authorization: 'Bearer test-key',
});
});
it('list() prefixes ids with infron: and filters non-chat and display-only entries', async () => {
const { provider } = makeProvider();
const ids = await provider.list();
expect(ids).toContain('infron:deepseek/deepseek-v4-flash');
expect(ids).toContain('infron:anthropic/claude-haiku-4.5');
expect(ids).not.toContain('infron:black-forest-labs/flux-2.1');
expect(ids).not.toContain('infron:example/display-only-model');
});
it('caches the model list in kv after the first axios round-trip', async () => {
const { provider } = makeProvider();
await provider.models();
await provider.models();
// Second call should be a cache hit, not a second axios request.
expect(axiosRequestMock).toHaveBeenCalledTimes(1);
});
it('converts USD-per-million-token prices to microcents per token', async () => {
const { provider } = makeProvider();
const models = await provider.models();
// $10/M tokens → 10 * 100 = 1000 microcents per token.
expect(models).toEqual(
expect.arrayContaining([
expect.objectContaining({
id: 'infron:deepseek/deepseek-v4-flash',
costs: expect.objectContaining({
tokens: 1_000_000,
prompt: 1000,
completion: 3000,
}),
}),
]),
);
});
});
// ── Request shape ──────────────────────────────────────────────────
describe('InfronProvider.complete request shape', () => {
const baseCompletion = {
choices: [
{
message: { content: 'hi', role: 'assistant' },
finish_reason: 'stop',
},
],
usage: { prompt_tokens: 1, completion_tokens: 1 },
cost: 0,
};
it('strips the infron: prefix from the wire model id', async () => {
const { provider } = makeProvider();
createMock.mockResolvedValueOnce(baseCompletion);
await withTestActor(() =>
provider.complete({
model: 'infron:deepseek/deepseek-v4-flash',
messages: [{ role: 'user', content: 'hello' }],
}),
);
const [args] = createMock.mock.calls[0]!;
// infron: prefix is dropped before the SDK call.
expect(args.model).toBe('deepseek/deepseek-v4-flash');
// Infron requires `usage: { include: true }` to surface the
// cost field — the provider always sets this.
expect(args.usage).toEqual({ include: true });
});
it('only sets stream_options.include_usage when streaming', async () => {
const { provider } = makeProvider();
createMock.mockResolvedValueOnce(baseCompletion);
await withTestActor(() =>
provider.complete({
model: 'infron:deepseek/deepseek-v4-flash',
messages: [{ role: 'user', content: 'hi' }],
stream: false,
}),
);
expect(createMock.mock.calls[0]![0].stream).toBe(false);
expect('stream_options' in createMock.mock.calls[0]![0]).toBe(false);
createMock.mockReturnValueOnce(asAsyncIterable([]));
await withTestActor(() =>
provider.complete({
model: 'infron:deepseek/deepseek-v4-flash',
messages: [{ role: 'user', content: 'hi' }],
stream: true,
}),
);
expect(createMock.mock.calls[1]![0].stream_options).toEqual({
include_usage: true,
});
});
});
// ── Non-stream completion: cost calculator branches ─────────────────
describe('InfronProvider.complete non-stream output', () => {
it('uses the cost-bearing branch when the top-level cost is present', async () => {
const { provider } = makeProvider();
createMock.mockResolvedValueOnce({
choices: [
{
message: { content: 'hi there', role: 'assistant' },
finish_reason: 'stop',
},
],
usage: {
prompt_tokens: 100,
completion_tokens: 50,
prompt_tokens_details: { cached_tokens: 10 },
},
// Infron reports cost at the top level of the completion,
// not inside `usage`.
cost: 0.0001,
});
const result = (await withTestActor(() =>
provider.complete({
model: 'infron:deepseek/deepseek-v4-flash',
messages: [{ role: 'user', content: 'hi' }],
}),
)) as { usage: Record<string, number> };
// The cost-bearing branch zeroes per-token costs and bills via a
// single `billedUsage` line item priced at cost * 1e8.
expect(recordSpy).toHaveBeenCalledTimes(1);
const [usage, , prefix, overrides] = recordSpy.mock.calls[0]!;
expect(prefix).toBe('infron:deepseek/deepseek-v4-flash');
expect(usage).toMatchObject({
prompt: 100 - 10, // prompt_tokens - cached
completion: 50,
input_cache_read: 10,
billedUsage: 1,
});
// All per-token costs are zeroed so Infron's authoritative
// cost is the only thing that bills.
expect(overrides.prompt).toBe(0);
expect(overrides.completion).toBe(0);
expect(overrides.input_cache_read).toBe(0);
expect(overrides.billedUsage).toBe(0.0001 * 100_000_000);
// The returned usage exposes usd_cents derived from cost.
expect(result.usage.usd_cents).toBe(0.0001 * 100);
});
it('falls back to per-token pricing when cost is absent', async () => {
const { provider } = makeProvider();
createMock.mockResolvedValueOnce({
choices: [
{
message: { content: 'ok', role: 'assistant' },
finish_reason: 'stop',
},
],
// No top-level `cost` → fallback branch.
usage: {
prompt_tokens: 100,
completion_tokens: 50,
prompt_tokens_details: { cached_tokens: 10 },
},
});
await withTestActor(() =>
provider.complete({
model: 'infron:deepseek/deepseek-v4-flash',
messages: [{ role: 'user', content: 'hi' }],
}),
);
// deepseek-v4-flash catalog pricing converted to microcents per
// token: prompt=$10/M → 1000, completion=$30/M → 3000; cache
// reads fall back to the full prompt rate.
const [usage, , , overrides] = recordSpy.mock.calls[0]!;
expect(usage).toMatchObject({
prompt: 90,
completion: 50,
input_cache_read: 10,
});
expect(overrides.prompt).toBe(90 * 1000);
expect(overrides.completion).toBe(50 * 3000);
expect(overrides.input_cache_read).toBe(10 * 1000);
});
});
// ── Streaming deltas ────────────────────────────────────────────────
describe('InfronProvider.complete streaming', () => {
it('streams text deltas through to text events and meters the final-chunk cost', async () => {
const { provider } = makeProvider();
createMock.mockReturnValueOnce(
asAsyncIterable([
{ choices: [{ delta: { content: 'hel' } }] },
{ choices: [{ delta: { content: 'lo' } }] },
{
choices: [{ delta: {} }],
usage: {
prompt_tokens: 4,
completion_tokens: 2,
},
// Cost rides at the top level of the final chunk.
cost: 0.00005,
},
]),
);
const result = await withTestActor(() =>
provider.complete({
model: 'infron:deepseek/deepseek-v4-flash',
messages: [{ role: 'user', content: 'say hi' }],
stream: true,
}),
);
expect((result as { stream: boolean }).stream).toBe(true);
const harness = makeCapturingChatStream();
await (
result as {
init_chat_stream: (p: { chatStream: unknown }) => Promise<void>;
}
).init_chat_stream({ chatStream: harness.chatStream });
const events = harness.events();
const textEvents = events.filter((e) => e.type === 'text');
expect(textEvents.map((e) => e.text)).toEqual(['hel', 'lo']);
// Cost-branch metering on the final chunk.
expect(recordSpy).toHaveBeenCalledTimes(1);
const [, , prefix, overrides] = recordSpy.mock.calls[0]!;
expect(prefix).toBe('infron:deepseek/deepseek-v4-flash');
expect(overrides.billedUsage).toBe(0.00005 * 100_000_000);
});
});
// ── Moderation ──────────────────────────────────────────────────────
describe('InfronProvider.checkModeration', () => {
it('throws — Infron provider does not implement moderation', () => {
const { provider } = makeProvider();
expect(() => provider.checkModeration('anything')).toThrow(
/not implemented/i,
);
});
});
@@ -0,0 +1,291 @@
/*
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import axios from 'axios';
import { OpenAI } from 'openai';
import { ChatCompletionCreateParams } from 'openai/resources';
import { Context } from '../../../../core/context.js';
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
import { kv } from '../../../../util/kvSingleton.js';
import * as OpenAIUtil from '../../utils/OpenAIUtil.js';
import type {
IChatModel,
IChatProvider,
IChatCompleteResult,
ICompleteArguments,
} from '../../types.js';
/**
* Shape of one entry in Infron's `GET /v1/models` catalog. Unlike OpenRouter
* there is no `pricing` object; prices are USD per million tokens in
* `min_prompt_price` / `min_completion_price`, and the catalog mixes non-chat
* modalities (image, video, embeddings) that this provider filters out via
* `category_type`.
*/
type InfronApiModel = {
id: string;
display_name?: string;
category_type?: string;
is_display_only?: boolean;
supported_endpoint_types?: string[];
context_length?: number;
max_output_tokens?: number;
min_prompt_price?: number;
min_completion_price?: number;
min_request_price?: number;
};
type InfronUsage = OpenAI.Completions.CompletionUsage & {
cost?: number;
};
const KV_MODELS_KEY = 'infronChat:models';
export class InfronProvider implements IChatProvider {
#meteringService: MeteringService;
#openai: OpenAI;
#apiKey: string;
#apiBaseUrl: string = 'https://llm.onerouter.pro/v1';
constructor(
config: { apiBaseUrl?: string; apiKey: string },
meteringService: MeteringService,
) {
this.#apiBaseUrl = config.apiBaseUrl || 'https://llm.onerouter.pro/v1';
this.#apiKey = config.apiKey;
this.#openai = new OpenAI({
apiKey: config.apiKey,
baseURL: this.#apiBaseUrl,
});
this.#meteringService = meteringService;
}
getDefaultModel() {
return 'infron:deepseek/deepseek-v4-flash';
}
/**
* Returns a list of available model names
*
* @returns {Promise<string[]>} Array of model identifiers
*/
async list() {
const models = await this.models();
const model_names: string[] = [];
for (const model of models) {
model_names.push(model.id);
}
return model_names;
}
/** AI Chat completion method. See AIChatService for more details. */
async complete({
messages,
stream,
model,
tools,
max_tokens,
temperature,
}: ICompleteArguments): Promise<IChatCompleteResult> {
const availableModels = await this.models();
const modelUsed =
availableModels.find((m) =>
[m.id, ...(m.aliases || [])].includes(model),
) || availableModels.find((m) => m.id === this.getDefaultModel())!;
const modelIdForParams = modelUsed.id.startsWith('infron:')
? modelUsed.id.slice('infron:'.length)
: modelUsed.id;
const actor = Context.get('actor');
messages = await OpenAIUtil.process_input_messages(messages);
const completionParams = {
messages,
model: modelIdForParams,
...(tools ? { tools } : {}),
max_tokens,
temperature,
stream,
...(stream
? {
stream_options: { include_usage: true },
}
: {}),
// Surfaces the authoritative `cost` field (USD) on the
// response so metering doesn't depend on catalog prices.
usage: { include: true },
} as ChatCompletionCreateParams;
const completion =
await this.#openai.chat.completions.create(completionParams);
const usage_calculator = ({
usage,
cost,
}: {
usage: InfronUsage;
cost?: number;
}) => {
// Infron reports `cost` at the top level of the response, not
// inside `usage`. Non-streaming calls get it via the spread
// completion below; streaming injects it into `usage` via the
// `index_usage_from_stream_chunk` deviation.
const authoritativeCost =
typeof cost === 'number' ? cost : usage.cost;
const trackedUsage = {
prompt:
(usage.prompt_tokens ?? 0) -
(usage.prompt_tokens_details?.cached_tokens ?? 0),
completion: usage.completion_tokens ?? 0,
input_cache_read:
usage.prompt_tokens_details?.cached_tokens ?? 0,
request: 1,
};
if (typeof authoritativeCost === 'number') {
// Bill the gateway-reported cost as a single line item and
// zero the per-token costs so nothing double-bills.
const billedTrackedUsage = { ...trackedUsage, billedUsage: 1 };
const costOverwrites = Object.fromEntries(
Object.keys(billedTrackedUsage).map((k) => [k, 0]),
);
costOverwrites.billedUsage =
authoritativeCost * 100_000_000 || 1;
this.#meteringService.utilRecordUsageObject(
billedTrackedUsage,
actor,
modelUsed.id,
costOverwrites,
);
(billedTrackedUsage as Record<string, number>).usd_cents =
authoritativeCost * 100;
return billedTrackedUsage;
}
// Fallback: per-token pricing from the model catalog.
const costOverwrites = Object.fromEntries(
Object.keys(trackedUsage).map((k) => {
return [
k,
(modelUsed.costs[k] ?? 0) *
trackedUsage[k as keyof typeof trackedUsage],
];
}),
);
this.#meteringService.utilRecordUsageObject(
trackedUsage,
actor,
modelUsed.id,
costOverwrites,
);
return trackedUsage;
};
return OpenAIUtil.handle_completion_output({
deviations: {
index_usage_from_stream_chunk: (chunk: {
usage?: InfronUsage;
cost?: number;
}) =>
chunk.usage
? { ...chunk.usage, cost: chunk.cost }
: chunk.usage,
},
usage_calculator,
stream,
completion,
});
}
async models() {
let models = kv.get(KV_MODELS_KEY) as InfronApiModel[] | undefined;
if (!models) {
try {
const resp = await axios.request({
method: 'GET',
url: `${this.#apiBaseUrl}/models`,
// Infron requires authentication on the catalog endpoint.
headers: {
Authorization: `Bearer ${this.#apiKey}`,
},
});
models = resp.data.data;
kv.set(KV_MODELS_KEY, models, { EX: 15 * 60 }); // cache for 15 minutes
} catch (e) {
console.log(e);
}
}
if (!models) return [];
const coerced_models: IChatModel[] = [];
for (const model of models) {
// The catalog mixes chat with image/video/embedding/search
// models — only chat-completion-capable models belong here.
if (model.category_type !== 'LLM') continue;
if (model.is_display_only) continue;
if (!(model.supported_endpoint_types ?? []).includes('openai')) {
continue;
}
// Catalog prices are USD per million tokens; costs are
// microcents per token, so the conversion is ×100.
const promptCost = Math.round((model.min_prompt_price ?? 0) * 100);
coerced_models.push({
id: `infron:${model.id}`,
name: `${model.display_name || model.id} (Infron)`,
aliases: [
model.id,
...(model.display_name ? [model.display_name] : []),
`infron/${model.id}`,
model.id.split('/').slice(1).join('/'),
],
context: model.context_length,
max_tokens: model.max_output_tokens ?? 0,
costs_currency: 'usd-cents',
input_cost_key: 'prompt',
output_cost_key: 'completion',
costs: {
tokens: 1_000_000,
prompt: promptCost,
completion: Math.round(
(model.min_completion_price ?? 0) * 100,
),
// The catalog carries no cache-read price; charge the
// full prompt rate in the fallback path so cached
// tokens are never billed below list. The normal path
// bills the gateway-reported cost instead.
input_cache_read: promptCost,
// USD per request → microcents per request.
request: Math.round(
(model.min_request_price ?? 0) * 1_000_000 * 100,
),
},
});
}
return coerced_models;
}
checkModeration(
_text: string,
): ReturnType<IChatProvider['checkModeration']> {
throw new Error('Method not implemented.');
}
}
@@ -53,6 +53,7 @@ export const GEMINI_IMAGE_GENERATION_MODELS: IGeminiImageModel[] = [
'google/gemini-2.5-flash-image-preview',
'google/gemini-2.5-flash-image',
'google:google/gemini-2.5-flash-image-preview',
'nano-banana',
],
name: 'Gemini 2.5 Flash Image',
@@ -94,6 +95,7 @@ export const GEMINI_IMAGE_GENERATION_MODELS: IGeminiImageModel[] = [
'google/gemini-3-pro-image-preview',
'google/gemini-3-pro-image',
'google:google/gemini-3-pro-image-preview',
'nano-banana-pro',
],
allowedQualityLevels: ['1K', '2K', '4K'],
allowedRatios: [
@@ -587,6 +587,39 @@ describe('OCRDriver.recognize (mistral)', () => {
// ── Default provider selection ──────────────────────────────────────
describe('OCRDriver provider aliases', () => {
it.each([
['aws', 'textract'],
['textract', 'textract'],
['aws-textract', 'textract'],
['mistral', 'mistral'],
['mistral-ocr', 'mistral'],
])('routes provider %s to the %s backend', async (provider, expected) => {
const { actor } = await makeUser();
textractSendMock.mockResolvedValueOnce({
Blocks: [{ BlockType: 'PAGE' }],
});
mistralOcrProcessMock.mockResolvedValueOnce({
pages: [{ index: 0, markdown: 'hi' }],
});
await withActor(actor, () =>
driver.recognize({
source: dataUrl(Buffer.from('img'), 'image/png'),
provider,
}),
);
if (expected === 'textract') {
expect(textractSendMock).toHaveBeenCalledTimes(1);
expect(mistralOcrProcessMock).not.toHaveBeenCalled();
} else {
expect(mistralOcrProcessMock).toHaveBeenCalledTimes(1);
expect(textractSendMock).not.toHaveBeenCalled();
}
});
});
describe('OCRDriver default provider selection', () => {
it('defaults to aws-textract when both providers are configured', async () => {
const { actor } = await makeUser();
+45 -9
View File
@@ -78,6 +78,23 @@ interface MistralOcrClient {
};
}
const OCR_PROVIDERS = ['aws-textract', 'mistral'] as const;
// Aliases callers may use in place of a canonical provider id. Resolved here
// rather than in the SDK so a new alias reaches every caller at once.
const PROVIDER_BY_ALIAS: Record<string, string> = {
aws: 'aws-textract',
'aws-textract': 'aws-textract',
textract: 'aws-textract',
mistral: 'mistral',
'mistral-ocr': 'mistral',
};
const normalizeOcrProvider = (value: unknown): string | undefined =>
typeof value === 'string'
? PROVIDER_BY_ALIAS[value.trim().toLowerCase()]
: undefined;
export class OCRDriver extends PuterDriver {
readonly driverInterface = 'puter-ocr';
readonly driverName = 'ai-ocr';
@@ -95,11 +112,10 @@ export class OCRDriver extends PuterDriver {
}));
}
// puter-js's `img2txt` routes by passing the provider id in the `driver`
// slot. Alias them so the unified driver resolves; `recognize` falls
// back to the alias via Context.driverName when `args.provider` isn't
// set.
readonly driverAliases = ['aws-textract', 'mistral'];
// Older SDK bundles name the provider in the driver slot instead of
// passing `{ provider }`; `#resolveProvider` reads the requested alias
// back off the Context.
readonly driverAliases = [...OCR_PROVIDERS];
readonly isDefault = true;
// Textract state — one client per region.
@@ -156,10 +172,7 @@ export class OCRDriver extends PuterDriver {
async recognize(args: RecognizeArgs) {
if (args.test_mode) return sampleResponse();
const provider =
args.provider ??
(Context.get('driverName') as string | undefined) ??
this.#defaultProvider();
const provider = this.#resolveProvider(args);
if (!provider)
throw new HttpError(500, 'No OCR provider configured', {
legacyCode: 'internal_error',
@@ -204,6 +217,29 @@ export class OCRDriver extends PuterDriver {
});
}
/**
* Decide which provider handles a call: an explicit `provider` wins, then
* the legacy driver alias the caller dispatched through, then whichever
* provider is configured.
*/
#resolveProvider(args: RecognizeArgs): string | null {
if (args.provider) {
const named = normalizeOcrProvider(args.provider);
if (!named) {
throw new HttpError(
400,
`Unknown OCR provider: ${args.provider}. Available: ${OCR_PROVIDERS.join(', ')}`,
{ legacyCode: 'bad_request' },
);
}
return named;
}
return (
normalizeOcrProvider(Context.get('driverName')) ??
this.#defaultProvider()
);
}
#defaultProvider(): 'aws-textract' | 'mistral' | null {
if (this.#awsConfig) return 'aws-textract';
if (this.#mistral) return 'mistral';
@@ -38,8 +38,11 @@ const DEFAULT_OUTPUT_FORMAT = 'mp3_44100_128';
const SAMPLE_AUDIO_URL = 'https://puter-sample-data.puter.site/tts_example.mp3';
const MAX_AUDIO_FILE_SIZE = 25 * 1024 * 1024;
const PROVIDERS = ['elevenlabs'] as const;
interface ConvertArgs {
audio: unknown;
provider?: string;
voice?: string;
voice_id?: string;
voiceId?: string;
@@ -58,7 +61,9 @@ interface ConvertArgs {
export class VoiceChangerDriver extends PuterDriver {
readonly driverInterface = 'puter-speech2speech';
readonly driverName = 'elevenlabs-voice-changer';
readonly driverName = 'ai-speech2speech';
// Older SDK bundles name the provider in the driver slot.
readonly driverAliases = ['elevenlabs-voice-changer'];
readonly isDefault = true;
// Shared AI policy — see `drivers/util/aiLimits.ts` for the tier table.
@@ -104,6 +109,23 @@ export class VoiceChangerDriver extends PuterDriver {
async convert(
args: ConvertArgs,
): Promise<DriverStreamResult | { url: string; content_type: string }> {
// Only one provider exists today, but naming a different one should
// fail loudly rather than quietly convert with this one.
if (
args.provider &&
!PROVIDERS.includes(
args.provider
.trim()
.toLowerCase() as (typeof PROVIDERS)[number],
)
) {
throw new HttpError(
400,
`Speech-to-speech provider not found: ${args.provider}. Available: ${PROVIDERS.join(', ')}`,
{ legacyCode: 'bad_request' },
);
}
if (args.test_mode) {
return { url: SAMPLE_AUDIO_URL, content_type: 'audio/mpeg' };
}
@@ -194,7 +194,11 @@ describe('SpeechToTextDriver.getReportedCosts', () => {
it('mirrors every entry in costs.ts as a per-second line item', () => {
const reported = driver.getReportedCosts();
expect(reported).toHaveLength(Object.keys(SPEECH_TO_TEXT_COSTS).length);
// One extra line item comes from the xAI provider, which the
// driver aggregates alongside OpenAI's catalogue.
expect(reported).toHaveLength(
Object.keys(SPEECH_TO_TEXT_COSTS).length + 1,
);
for (const [usageType, ucentsPerUnit] of Object.entries(
SPEECH_TO_TEXT_COSTS,
)) {
@@ -784,3 +788,75 @@ describe('SpeechToTextDriver error paths', () => {
expect(incrementUsageSpy).not.toHaveBeenCalled();
});
});
// ── Provider routing ────────────────────────────────────────────────
describe('SpeechToTextDriver provider routing', () => {
it('defaults to openai when no provider is named', async () => {
const { actor } = await makeUser();
transcriptionsCreateMock.mockResolvedValueOnce({ text: 'hi' });
await withActor(actor, () =>
driver.transcribe({ file: dataUrl(Buffer.from('a'), 'audio/mp3') }),
);
expect(transcriptionsCreateMock).toHaveBeenCalledTimes(1);
});
it.each(['xai', 'grok', 'x-ai'])(
'routes provider %s away from openai',
async (provider) => {
const { actor } = await makeUser();
// This server carries no xAI key, so the provider's own
// "not configured" rejection is the proof the call was routed
// there rather than to OpenAI.
await expect(
withActor(actor, () =>
driver.transcribe({
file: dataUrl(Buffer.from('a'), 'audio/mp3'),
provider,
}),
),
).rejects.toMatchObject({
statusCode: 500,
message: 'xAI API key not configured',
});
expect(transcriptionsCreateMock).not.toHaveBeenCalled();
},
);
it.each(['openai', 'whisper'])(
'routes provider %s to the OpenAI backend',
async (provider) => {
const { actor } = await makeUser();
transcriptionsCreateMock.mockResolvedValueOnce({ text: 'hi' });
await withActor(actor, () =>
driver.transcribe({
file: dataUrl(Buffer.from('a'), 'audio/mp3'),
provider,
}),
);
expect(transcriptionsCreateMock).toHaveBeenCalledTimes(1);
// `provider` is the driver's business, never forwarded upstream.
expect(
transcriptionsCreateMock.mock.calls[0]![0],
).not.toHaveProperty('provider');
},
);
it('throws 400 when the named provider is unknown', async () => {
const { actor } = await makeUser();
await expect(
withActor(actor, () =>
driver.transcribe({
file: dataUrl(Buffer.from('a'), 'audio/mp3'),
provider: 'totally-not-real',
}),
),
).rejects.toMatchObject({ statusCode: 400 });
expect(transcriptionsCreateMock).not.toHaveBeenCalled();
});
});
@@ -18,331 +18,213 @@
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
*/
import OpenAI, { toFile } from 'openai';
import { Context } from '../../core/context.js';
import { HttpError } from '../../core/http/HttpError.js';
import { PuterDriver } from '../types.js';
import { AI_CONCURRENT, AI_RATE_LIMIT } from '../util/aiLimits.js';
import { loadFileInput } from '../util/fileInput.js';
import { SPEECH_TO_TEXT_COSTS } from './costs.js';
import {
DEFAULT_SPEECH_TO_TEXT_PROVIDER,
normalizeSpeechToTextProvider,
SPEECH_TO_TEXT_DRIVER_ALIASES,
SPEECH_TO_TEXT_PROVIDERS,
} from './providerAliases.js';
import { OpenAISpeechToTextProvider } from './providers/openai/OpenAISpeechToTextProvider.js';
import { XAISpeechToTextProvider } from './providers/xai/XAISpeechToTextProvider.js';
import type {
ISpeechToTextDeps,
ISpeechToTextModel,
ISpeechToTextProvider,
ITranscribeArgs,
} from './types.js';
/**
* Driver implementing `puter-speech2txt`. Wraps OpenAI's audio API (Whisper +
* GPT-4o transcribe models) for transcription and translation.
* Driver implementing the `puter-speech2txt` interface.
*
* `file` may be a path, uid/uuid ref, or data URL.
* Manages the upstream transcription providers and routes each call to the one
* the caller named. Each provider is an `ISpeechToTextProvider` instantiated
* from config on boot.
*/
const DEFAULT_TRANSCRIBE_MODEL = 'gpt-4o-mini-transcribe';
const DEFAULT_TRANSLATE_MODEL = 'whisper-1';
const MAX_AUDIO_FILE_SIZE = 25 * 1024 * 1024;
/** Opt-in value that widens `list_models` out to every provider. */
const ALL_PROVIDERS = 'all';
const SAMPLE_TRANSCRIPT = {
text: 'Hello! This is a sample transcription returned while test mode is enabled.',
language: 'en',
duration_seconds: 2,
words: [
{ start: 0.0, end: 0.5, text: 'Hello' },
{ start: 1.1, end: 2.0, text: 'This is a sample transcription.' },
],
};
interface ModelCapabilities {
canPrompt: boolean;
canLogprobs: boolean;
responseFormats: string[];
timestampGranularities?: boolean;
diarization?: boolean;
requiresChunkingOverThirtySeconds?: boolean;
}
const MODEL_CAPS: Record<string, ModelCapabilities> = {
'gpt-4o-mini-transcribe': {
canPrompt: true,
canLogprobs: true,
responseFormats: ['json', 'text'],
},
'gpt-4o-transcribe': {
canPrompt: true,
canLogprobs: true,
responseFormats: ['json', 'text'],
},
'gpt-4o-transcribe-diarize': {
canPrompt: false,
canLogprobs: false,
responseFormats: ['json', 'text', 'diarized_json'],
diarization: true,
requiresChunkingOverThirtySeconds: true,
},
'whisper-1': {
canPrompt: true,
canLogprobs: false,
responseFormats: ['json', 'text', 'srt', 'verbose_json', 'vtt'],
timestampGranularities: true,
},
};
interface TranscribeArgs {
file: unknown;
model?: string;
response_format?: string;
language?: string;
prompt?: string;
temperature?: number;
logprobs?: boolean;
timestamp_granularities?: string[];
chunking_strategy?: string;
known_speaker_names?: string[];
known_speaker_references?: unknown[];
extra_body?: Record<string, unknown>;
stream?: boolean;
test_mode?: boolean;
}
const isAllProviders = (value: unknown): boolean =>
typeof value === 'string' && value.trim().toLowerCase() === ALL_PROVIDERS;
export class SpeechToTextDriver extends PuterDriver {
readonly driverInterface = 'puter-speech2txt';
readonly driverName = 'openai-speech2txt';
readonly driverName = 'ai-speech2txt';
// Older SDK bundles name the provider in the driver slot instead of
// passing `{ provider }`; `#resolveProvider` reads the requested alias
// back off the Context.
readonly driverAliases = [...SPEECH_TO_TEXT_DRIVER_ALIASES];
readonly isDefault = true;
// Shared AI policy — see `drivers/util/aiLimits.ts` for the tier table.
// The XAI sibling driver implements the same interface so both share
// the per-user bucket (keyed by interface+method+user).
// One bucket covers every provider, keyed by interface+method+user.
readonly rateLimit = AI_RATE_LIMIT;
readonly concurrent = AI_CONCURRENT;
#providers: Record<string, ISpeechToTextProvider> = {};
override onServerStart() {
this.#registerProviders();
}
override getReportedCosts(): Record<string, unknown>[] {
return Object.entries(SPEECH_TO_TEXT_COSTS).map(
([usageType, ucentsPerUnit]) => ({
usageType,
ucentsPerUnit,
unit: 'second',
source: 'driver:aiSpeech2Txt',
}),
return Object.values(this.#providers).flatMap((p) =>
p.getReportedCosts(),
);
}
#openai: OpenAI | null = null;
// -- Interface methods -------------------------------------------
override onServerStart() {
/**
* List available models. Defaults to the default provider; pass `provider:
* 'all'` to aggregate across every configured provider.
*/
async list_models(
args?: Record<string, unknown>,
): Promise<ISpeechToTextModel[]> {
const requested = args?.provider;
if (isAllProviders(requested)) {
const all: ISpeechToTextModel[] = [];
for (const p of Object.values(this.#providers)) {
all.push(
...(await p.listModels()).map((m) => ({
...m,
provider: p.providerName,
})),
);
}
return all;
}
const p =
this.#providers[this.#resolveProvider({ provider: requested })];
if (!p) return [];
return p.listModels();
}
/** List provider names that are currently configured. */
async list(): Promise<string[]> {
return Object.keys(this.#providers);
}
async transcribe(args: ITranscribeArgs) {
return this.#provider(args).transcribe(this.#providerArgs(args));
}
async translate(args: ITranscribeArgs) {
return this.#provider(args).translate(this.#providerArgs(args));
}
// -- Provider routing --------------------------------------------
#provider(args: ITranscribeArgs): ISpeechToTextProvider {
const providerName = this.#resolveProvider(args);
const provider = this.#providers[providerName];
if (!provider) {
throw new HttpError(
500,
`Speech-to-text provider not configured: ${providerName}`,
{ legacyCode: 'internal_error' },
);
}
return provider;
}
/**
* Decide which provider handles a call: an explicit `provider` wins, then
* the legacy driver alias the caller dispatched through, then the default.
*/
#resolveProvider(args: { provider?: unknown }): string {
if (
args.provider !== undefined &&
args.provider !== null &&
args.provider !== ''
) {
const named = normalizeSpeechToTextProvider(args.provider);
if (!named) {
throw new HttpError(
400,
`Speech-to-text provider not found: ${String(args.provider)}. Available: ${SPEECH_TO_TEXT_PROVIDERS.join(', ')}`,
{ legacyCode: 'bad_request' },
);
}
return named;
}
return (
normalizeSpeechToTextProvider(Context.get('driverName')) ??
this.#defaultProvider()
);
}
/** Providers read their own options; `provider` is the driver's business. */
#providerArgs(args: ITranscribeArgs): ITranscribeArgs {
const { provider: _provider, ...rest } = args;
return rest;
}
/**
* The documented default, falling back to whatever is configured so a
* deployment without OpenAI credentials still serves transcription.
*/
#defaultProvider(): string {
for (const name of [
DEFAULT_SPEECH_TO_TEXT_PROVIDER,
...SPEECH_TO_TEXT_PROVIDERS,
]) {
if (this.#providers[name]) return name;
}
return (
Object.keys(this.#providers)[0] ?? DEFAULT_SPEECH_TO_TEXT_PROVIDER
);
}
// -- Provider registration ---------------------------------------
// Providers register whether or not credentials are present, so their
// model catalogues stay listable on a deployment that only configures
// some of them. A provider without a key rejects at call time.
#registerProviders() {
const providers = (this.config.providers ?? {}) as Record<
string,
Record<string, unknown> | undefined
>;
const readKey = (
...cfgs: Array<Record<string, unknown> | undefined>
): string | undefined => {
for (const cfg of cfgs) {
if (!cfg) continue;
const k =
(cfg.apiKey as string | undefined) ??
(cfg.secret_key as string | undefined);
if (k) return k;
}
return undefined;
const deps: ISpeechToTextDeps = {
stores: this.stores,
fs: this.services.fs,
metering: this.services.metering,
};
const apiKey = readKey(
providers['openai-speech-to-text'],
providers['openai-completion'],
providers['openai'],
);
if (!apiKey) return; // Leave uninitialized; convert() will reject.
this.#openai = new OpenAI({ apiKey });
}
async list_models() {
return Object.entries(MODEL_CAPS).map(([id, caps]) => ({
id,
name: id,
type: caps.diarization
? 'transcription'
: id === 'whisper-1'
? 'translation'
: 'transcription',
response_formats: caps.responseFormats,
supports_prompt: caps.canPrompt,
supports_logprobs: caps.canLogprobs,
...(caps.diarization ? { supports_diarization: true } : {}),
...(caps.timestampGranularities
? { supports_timestamp_granularities: true }
: {}),
}));
}
this.#providers['openai'] = new OpenAISpeechToTextProvider(deps, {
apiKey: readKey(
providers['openai-speech-to-text'],
providers['openai-completion'],
providers['openai'],
),
});
async transcribe(args: TranscribeArgs) {
return this.#handleTranscription(args, false);
}
async translate(args: TranscribeArgs) {
return this.#handleTranscription(args, true);
}
async #handleTranscription(args: TranscribeArgs, translate: boolean) {
if (args.test_mode) {
return {
...SAMPLE_TRANSCRIPT,
model:
args.model ||
(translate
? DEFAULT_TRANSLATE_MODEL
: DEFAULT_TRANSCRIBE_MODEL),
};
}
if (args.stream) {
throw new HttpError(
400,
'Streaming transcription is not yet supported',
{ legacyCode: 'bad_request' },
);
}
if (!this.#openai)
throw new HttpError(500, 'OpenAI API key not configured', {
legacyCode: 'internal_error',
});
if (!args.file)
throw new HttpError(400, '`file` is required', {
legacyCode: 'bad_request',
});
const actor = Context.get('actor');
if (!actor)
throw new HttpError(401, 'Authentication required', {
legacyCode: 'unauthorized',
});
const loaded = await loadFileInput(
this.stores,
this.services.fs,
actor,
args.file,
{ maxBytes: MAX_AUDIO_FILE_SIZE, acceptWebInput: true },
);
const selectedModel =
args.model ||
(translate ? DEFAULT_TRANSLATE_MODEL : DEFAULT_TRANSCRIBE_MODEL);
const caps = MODEL_CAPS[selectedModel];
if (!caps) {
throw new HttpError(400, `Unsupported model: ${selectedModel}`, {
legacyCode: 'bad_request',
});
}
if (
args.response_format &&
!caps.responseFormats.includes(args.response_format)
) {
throw new HttpError(
400,
`response_format must be one of: ${caps.responseFormats.join(', ')}`,
{ legacyCode: 'bad_request' },
);
}
if (args.prompt && !caps.canPrompt) {
throw new HttpError(
400,
`prompt is not supported for model ${selectedModel}`,
{ legacyCode: 'bad_request' },
);
}
if (args.logprobs && !caps.canLogprobs) {
throw new HttpError(
400,
`logprobs is not supported for model ${selectedModel}`,
{ legacyCode: 'bad_request' },
);
}
// Estimate seconds from raw bytes — 16 kbps is a conservative speech-audio
// lower bound. Full metadata parsing (music-metadata) is deferred — clients
// aren't observably sensitive to billing-time delta vs real duration.
const estimatedSeconds = Math.max(
1,
Math.ceil(loaded.buffer.byteLength / 16000),
);
const usageType = `openai:${selectedModel}:second`;
const ucentsPerSecond = SPEECH_TO_TEXT_COSTS[usageType] ?? 0;
const estimatedCost = ucentsPerSecond * estimatedSeconds;
const allowed = await this.services.metering.hasEnoughCredits(
actor,
estimatedCost,
);
if (!allowed)
throw new HttpError(402, 'Insufficient credits', {
legacyCode: 'insufficient_funds',
});
const openaiFile = await toFile(
loaded.buffer,
loaded.filename,
loaded.mimeType ? { type: loaded.mimeType } : undefined,
);
const payload: Record<string, unknown> = {
file: openaiFile,
model: selectedModel,
};
if (args.response_format)
payload.response_format = args.response_format;
if (args.language) payload.language = args.language;
if (typeof args.temperature === 'number')
payload.temperature = args.temperature;
if (args.prompt && caps.canPrompt) payload.prompt = args.prompt;
if (args.logprobs && caps.canLogprobs) payload.logprobs = args.logprobs;
if (args.timestamp_granularities && caps.timestampGranularities) {
payload.timestamp_granularities = args.timestamp_granularities;
}
if (caps.diarization) {
if (!args.response_format)
payload.response_format = 'diarized_json';
const needsChunking =
caps.requiresChunkingOverThirtySeconds && estimatedSeconds > 30;
const strategy =
args.chunking_strategy ?? (needsChunking ? 'auto' : undefined);
if (strategy) payload.chunking_strategy = strategy;
if (args.known_speaker_names || args.known_speaker_references) {
payload.extra_body = {
...(args.extra_body ?? {}),
...(args.known_speaker_names
? { known_speaker_names: args.known_speaker_names }
: {}),
...(args.known_speaker_references
? {
known_speaker_references:
args.known_speaker_references,
}
: {}),
};
}
} else if (args.extra_body) {
payload.extra_body = args.extra_body;
}
const result = translate
? await this.#openai.audio.translations.create(
payload as Parameters<
OpenAI['audio']['translations']['create']
>[0],
)
: await this.#openai.audio.transcriptions.create(
payload as Parameters<
OpenAI['audio']['transcriptions']['create']
>[0],
);
this.services.metering.incrementUsage(
actor,
usageType,
estimatedSeconds,
ucentsPerSecond * estimatedSeconds,
);
// Text response_format: return raw string; otherwise forward the OpenAI object.
if (args.response_format === 'text') {
return typeof result === 'string'
? result
: ((result as { text?: string }).text ?? '');
}
return result;
this.#providers['xai'] = new XAISpeechToTextProvider(deps, {
apiKey: readKey(providers['xai']),
});
}
}
/** First API key found across the config shapes providers are declared with. */
function readKey(
...cfgs: Array<Record<string, unknown> | undefined>
): string | undefined {
for (const cfg of cfgs) {
if (!cfg) continue;
const k =
(cfg.apiKey as string | undefined) ??
(cfg.secret_key as string | undefined) ??
(cfg.api_key as string | undefined) ??
(cfg.key as string | undefined);
if (k) return k;
}
return undefined;
}
@@ -0,0 +1,61 @@
/**
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify it under the
* terms of the GNU Affero General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option) any
* later version.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
*/
/**
* Canonical `puter-speech2txt` provider ids and the aliases callers may use in
* their place. Resolution lives here rather than in the SDK so a new alias
* reaches every caller at once, including clients on an older bundle.
*/
export const SPEECH_TO_TEXT_PROVIDERS = ['openai', 'xai'] as const;
export type SpeechToTextProviderName =
(typeof SPEECH_TO_TEXT_PROVIDERS)[number];
/** The documented default when a caller names no provider. */
export const DEFAULT_SPEECH_TO_TEXT_PROVIDER: SpeechToTextProviderName =
'openai';
/**
* Driver names the unified driver answers to. Older SDK bundles put the
* provider in the driver slot instead of passing `{ provider }`.
*/
export const SPEECH_TO_TEXT_DRIVER_ALIASES = [
'openai-speech2txt',
'xai-speech2txt',
] as const;
const PROVIDER_BY_ALIAS: Record<string, SpeechToTextProviderName> = {
openai: 'openai',
'openai-speech2txt': 'openai',
whisper: 'openai',
grok: 'xai',
'x-ai': 'xai',
xai: 'xai',
'xai-speech2txt': 'xai',
};
/** Resolve a caller-supplied provider name, or `undefined` if unrecognized. */
export function normalizeSpeechToTextProvider(
value: unknown,
): SpeechToTextProviderName | undefined {
if (typeof value !== 'string') return undefined;
return PROVIDER_BY_ALIAS[value.trim().toLowerCase()];
}
@@ -0,0 +1,72 @@
/**
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify it under the
* terms of the GNU Affero General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option) any
* later version.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
*/
/**
* Abstract base for speech-to-text providers. Each provider wraps a single
* upstream API and exposes the unified `ISpeechToTextProvider` contract.
*/
import { Context } from '../../../core/context.js';
import { HttpError } from '../../../core/http/HttpError.js';
import type { Actor } from '../../../core/actor.js';
import type {
ISpeechToTextDeps,
ISpeechToTextModel,
ISpeechToTextProvider,
ITranscribeArgs,
} from '../types.js';
export abstract class SpeechToTextProvider implements ISpeechToTextProvider {
abstract readonly providerName: string;
constructor(protected deps: ISpeechToTextDeps) {}
abstract listModels(): Promise<ISpeechToTextModel[]>;
abstract transcribe(args: ITranscribeArgs): Promise<unknown>;
/**
* Translate to English. Providers whose upstream has no separate
* translation endpoint override this to delegate to `transcribe`.
*/
abstract translate(args: ITranscribeArgs): Promise<unknown>;
getReportedCosts(): Record<string, unknown>[] {
return [];
}
/** The authenticated caller, or a 401 if the request carries none. */
protected requireActor(): Actor {
const actor = Context.get('actor') as Actor | undefined;
if (!actor)
throw new HttpError(401, 'Authentication required', {
legacyCode: 'unauthorized',
});
return actor;
}
/** Reject the call unless the caller supplied audio. */
protected requireFile(args: ITranscribeArgs): void {
if (!args.file)
throw new HttpError(400, '`file` is required', {
legacyCode: 'bad_request',
});
}
}
@@ -0,0 +1,302 @@
/**
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify it under the
* terms of the GNU Affero General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option) any
* later version.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
*/
import OpenAI, { toFile } from 'openai';
import { HttpError } from '../../../../core/http/HttpError.js';
import { loadFileInput } from '../../../util/fileInput.js';
import { SPEECH_TO_TEXT_COSTS } from '../../costs.js';
import type {
ISpeechToTextDeps,
ISpeechToTextModel,
ITranscribeArgs,
} from '../../types.js';
import { SpeechToTextProvider } from '../SpeechToTextProvider.js';
/**
* Wraps OpenAI's audio API (Whisper + GPT-4o transcribe models) for
* transcription and translation.
*
* `file` may be a path, uid/uuid ref, or data URL.
*/
const DEFAULT_TRANSCRIBE_MODEL = 'gpt-4o-mini-transcribe';
const DEFAULT_TRANSLATE_MODEL = 'whisper-1';
const MAX_AUDIO_FILE_SIZE = 25 * 1024 * 1024;
const SAMPLE_TRANSCRIPT = {
text: 'Hello! This is a sample transcription returned while test mode is enabled.',
language: 'en',
duration_seconds: 2,
words: [
{ start: 0.0, end: 0.5, text: 'Hello' },
{ start: 1.1, end: 2.0, text: 'This is a sample transcription.' },
],
};
interface ModelCapabilities {
canPrompt: boolean;
canLogprobs: boolean;
responseFormats: string[];
timestampGranularities?: boolean;
diarization?: boolean;
requiresChunkingOverThirtySeconds?: boolean;
}
const MODEL_CAPS: Record<string, ModelCapabilities> = {
'gpt-4o-mini-transcribe': {
canPrompt: true,
canLogprobs: true,
responseFormats: ['json', 'text'],
},
'gpt-4o-transcribe': {
canPrompt: true,
canLogprobs: true,
responseFormats: ['json', 'text'],
},
'gpt-4o-transcribe-diarize': {
canPrompt: false,
canLogprobs: false,
responseFormats: ['json', 'text', 'diarized_json'],
diarization: true,
requiresChunkingOverThirtySeconds: true,
},
'whisper-1': {
canPrompt: true,
canLogprobs: false,
responseFormats: ['json', 'text', 'srt', 'verbose_json', 'vtt'],
timestampGranularities: true,
},
};
export class OpenAISpeechToTextProvider extends SpeechToTextProvider {
readonly providerName = 'openai';
// Null when the deployment has no OpenAI credentials. The provider still
// registers so its model catalogue stays listable; transcription rejects.
#openai: OpenAI | null;
constructor(deps: ISpeechToTextDeps, config: { apiKey?: string }) {
super(deps);
this.#openai = config.apiKey
? new OpenAI({ apiKey: config.apiKey })
: null;
}
override getReportedCosts(): Record<string, unknown>[] {
return Object.entries(SPEECH_TO_TEXT_COSTS).map(
([usageType, ucentsPerUnit]) => ({
usageType,
ucentsPerUnit,
unit: 'second',
source: 'driver:aiSpeech2Txt',
}),
);
}
async listModels(): Promise<ISpeechToTextModel[]> {
return Object.entries(MODEL_CAPS).map(([id, caps]) => ({
id,
name: id,
type: caps.diarization
? 'transcription'
: id === 'whisper-1'
? 'translation'
: 'transcription',
response_formats: caps.responseFormats,
supports_prompt: caps.canPrompt,
supports_logprobs: caps.canLogprobs,
...(caps.diarization ? { supports_diarization: true } : {}),
...(caps.timestampGranularities
? { supports_timestamp_granularities: true }
: {}),
}));
}
async transcribe(args: ITranscribeArgs) {
return this.#handleTranscription(args, false);
}
async translate(args: ITranscribeArgs) {
return this.#handleTranscription(args, true);
}
async #handleTranscription(args: ITranscribeArgs, translate: boolean) {
if (args.test_mode) {
return {
...SAMPLE_TRANSCRIPT,
model:
args.model ||
(translate
? DEFAULT_TRANSLATE_MODEL
: DEFAULT_TRANSCRIBE_MODEL),
};
}
if (args.stream) {
throw new HttpError(
400,
'Streaming transcription is not yet supported',
{ legacyCode: 'bad_request' },
);
}
if (!this.#openai)
throw new HttpError(500, 'OpenAI API key not configured', {
legacyCode: 'internal_error',
});
this.requireFile(args);
const actor = this.requireActor();
const loaded = await loadFileInput(
this.deps.stores,
this.deps.fs,
actor,
args.file,
{ maxBytes: MAX_AUDIO_FILE_SIZE, acceptWebInput: true },
);
const selectedModel =
args.model ||
(translate ? DEFAULT_TRANSLATE_MODEL : DEFAULT_TRANSCRIBE_MODEL);
const caps = MODEL_CAPS[selectedModel];
if (!caps) {
throw new HttpError(400, `Unsupported model: ${selectedModel}`, {
legacyCode: 'bad_request',
});
}
if (
args.response_format &&
!caps.responseFormats.includes(args.response_format)
) {
throw new HttpError(
400,
`response_format must be one of: ${caps.responseFormats.join(', ')}`,
{ legacyCode: 'bad_request' },
);
}
if (args.prompt && !caps.canPrompt) {
throw new HttpError(
400,
`prompt is not supported for model ${selectedModel}`,
{ legacyCode: 'bad_request' },
);
}
if (args.logprobs && !caps.canLogprobs) {
throw new HttpError(
400,
`logprobs is not supported for model ${selectedModel}`,
{ legacyCode: 'bad_request' },
);
}
// Estimate seconds from raw bytes — 16 kbps is a conservative speech-audio
// lower bound. Full metadata parsing (music-metadata) is deferred — clients
// aren't observably sensitive to billing-time delta vs real duration.
const estimatedSeconds = Math.max(
1,
Math.ceil(loaded.buffer.byteLength / 16000),
);
const usageType = `openai:${selectedModel}:second`;
const ucentsPerSecond = SPEECH_TO_TEXT_COSTS[usageType] ?? 0;
const estimatedCost = ucentsPerSecond * estimatedSeconds;
const allowed = await this.deps.metering.hasEnoughCredits(
actor,
estimatedCost,
);
if (!allowed)
throw new HttpError(402, 'Insufficient credits', {
legacyCode: 'insufficient_funds',
});
const openaiFile = await toFile(
loaded.buffer,
loaded.filename,
loaded.mimeType ? { type: loaded.mimeType } : undefined,
);
const payload: Record<string, unknown> = {
file: openaiFile,
model: selectedModel,
};
if (args.response_format)
payload.response_format = args.response_format;
if (args.language) payload.language = args.language;
if (typeof args.temperature === 'number')
payload.temperature = args.temperature;
if (args.prompt && caps.canPrompt) payload.prompt = args.prompt;
if (args.logprobs && caps.canLogprobs) payload.logprobs = args.logprobs;
if (args.timestamp_granularities && caps.timestampGranularities) {
payload.timestamp_granularities = args.timestamp_granularities;
}
if (caps.diarization) {
if (!args.response_format)
payload.response_format = 'diarized_json';
const needsChunking =
caps.requiresChunkingOverThirtySeconds && estimatedSeconds > 30;
const strategy =
args.chunking_strategy ?? (needsChunking ? 'auto' : undefined);
if (strategy) payload.chunking_strategy = strategy;
if (args.known_speaker_names || args.known_speaker_references) {
payload.extra_body = {
...(args.extra_body ?? {}),
...(args.known_speaker_names
? { known_speaker_names: args.known_speaker_names }
: {}),
...(args.known_speaker_references
? {
known_speaker_references:
args.known_speaker_references,
}
: {}),
};
}
} else if (args.extra_body) {
payload.extra_body = args.extra_body;
}
const result = translate
? await this.#openai.audio.translations.create(
payload as Parameters<
OpenAI['audio']['translations']['create']
>[0],
)
: await this.#openai.audio.transcriptions.create(
payload as Parameters<
OpenAI['audio']['transcriptions']['create']
>[0],
);
this.deps.metering.incrementUsage(
actor,
usageType,
estimatedSeconds,
ucentsPerSecond * estimatedSeconds,
);
// Text response_format: return raw string; otherwise forward the OpenAI object.
if (args.response_format === 'text') {
return typeof result === 'string'
? result
: ((result as { text?: string }).text ?? '');
}
return result;
}
}
@@ -18,12 +18,12 @@
*/
/**
* Offline unit tests for XAISpeechToTextDriver.
* Offline unit tests for the xAI speech-to-text provider.
*
* Boots a real PuterServer (in-memory sqlite + dynamo + s3 + mock
* redis) configured with an xAI API key, then drives
* `server.drivers.aiSpeech2TxtXai` directly. xAI has no SDK — the
* driver calls the REST `/v1/stt` endpoint via `fetch` — so global
* redis) configured with an xAI API key, then drives the unified
* `puter-speech2txt` driver with `provider: 'xai'`. xAI has no SDK —
* the provider calls the REST `/v1/stt` endpoint via `fetch` — so global
* `fetch` is spied for each request shape assertion. Audio inputs use
* `data:` URLs through the live `loadFileInput`, and the FS-resolution
* branch is exercised by writing a real file via `server.services.fs.write`.
@@ -42,13 +42,13 @@ import {
} from 'vitest';
import { v4 as uuidv4 } from 'uuid';
import type { Actor } from '../../core/actor.js';
import { runWithContext } from '../../core/context.js';
import { PuterServer } from '../../server.js';
import type { MeteringService } from '../../services/metering/MeteringService.js';
import { setupTestServer } from '../../testUtil.js';
import { generateDefaultFsentries } from '../../util/userProvisioning.js';
import type { XAISpeechToTextDriver } from './XAISpeechToTextDriver.js';
import type { Actor } from '../../../../core/actor.js';
import { runWithContext } from '../../../../core/context.js';
import { PuterServer } from '../../../../server.js';
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
import { setupTestServer } from '../../../../testUtil.js';
import { generateDefaultFsentries } from '../../../../util/userProvisioning.js';
import type { SpeechToTextDriver } from '../../SpeechToTextDriver.js';
// Mirror the driver's per-second cost so test expectations stay in lockstep
// with the real value rather than restating an arbitrary literal.
@@ -57,7 +57,17 @@ const UCENTS_PER_SECOND = 2778;
// ── Test harness ────────────────────────────────────────────────────
let server: PuterServer;
let driver: XAISpeechToTextDriver;
let sttDriver: SpeechToTextDriver;
// Every call below goes through the unified driver pinned to this provider.
const driver = {
transcribe: (args: Record<string, unknown> = {}) =>
sttDriver.transcribe({ ...args, provider: 'xai' }),
translate: (args: Record<string, unknown> = {}) =>
sttDriver.translate({ ...args, provider: 'xai' }),
list_models: () => sttDriver.list_models({ provider: 'xai' }),
getReportedCosts: () => sttDriver.getReportedCosts(),
};
let fetchSpy: MockInstance<typeof fetch>;
let hasCreditsSpy: MockInstance<MeteringService['hasEnoughCredits']>;
let incrementUsageSpy: MockInstance<MeteringService['incrementUsage']>;
@@ -68,7 +78,7 @@ beforeAll(async () => {
xai: { apiKey: 'xai-test-key' },
},
} as never);
driver = server.drivers.aiSpeech2TxtXai as unknown as XAISpeechToTextDriver;
sttDriver = server.drivers.aiSpeech2Txt as unknown as SpeechToTextDriver;
});
afterAll(async () => {
@@ -129,9 +139,13 @@ const sttResponse = (body: Record<string, unknown>, status = 200) =>
// ── getReportedCosts ────────────────────────────────────────────────
describe('XAISpeechToTextDriver.getReportedCosts', () => {
it('reports a single per-second line item at the documented xAI rate', () => {
const reported = driver.getReportedCosts();
describe('XAISpeechToTextProvider.getReportedCosts', () => {
it('contributes a single per-second line item at the documented xAI rate', () => {
// The driver aggregates every provider's catalogue; only the xAI
// contribution is this provider's business.
const reported = driver
.getReportedCosts()
.filter((entry) => entry.source === 'driver:aiSpeech2Txt/xai');
expect(reported).toEqual([
{
usageType: 'xai:stt:second',
@@ -145,7 +159,7 @@ describe('XAISpeechToTextDriver.getReportedCosts', () => {
// ── list_models ─────────────────────────────────────────────────────
describe('XAISpeechToTextDriver.list_models', () => {
describe('XAISpeechToTextProvider.list_models', () => {
it('returns the single xai-stt entry with diarization support', async () => {
const models = await driver.list_models();
expect(models).toHaveLength(1);
@@ -163,7 +177,7 @@ describe('XAISpeechToTextDriver.list_models', () => {
// ── test_mode bypass ────────────────────────────────────────────────
describe('XAISpeechToTextDriver test_mode', () => {
describe('XAISpeechToTextProvider test_mode', () => {
it('returns the canned sample for transcribe, bypassing all I/O', async () => {
const result = (await driver.transcribe({
file: undefined,
@@ -191,7 +205,7 @@ describe('XAISpeechToTextDriver test_mode', () => {
// ── Argument validation ─────────────────────────────────────────────
describe('XAISpeechToTextDriver argument validation', () => {
describe('XAISpeechToTextProvider argument validation', () => {
it('throws 400 when file is missing', async () => {
const { actor } = await makeUser();
await expect(
@@ -212,7 +226,7 @@ describe('XAISpeechToTextDriver argument validation', () => {
// ── Missing API key ─────────────────────────────────────────────────
describe('XAISpeechToTextDriver missing API key', () => {
describe('XAISpeechToTextProvider missing API key', () => {
it('throws 500 internal_error when no xAI key is configured', async () => {
// Boot a separate server WITHOUT an xAI provider entry so the
// driver leaves its key unset. test_mode and validation paths
@@ -220,9 +234,10 @@ describe('XAISpeechToTextDriver missing API key', () => {
const bareServer = await setupTestServer();
try {
const bareDriver =
bareServer.drivers.aiSpeech2TxtXai as unknown as XAISpeechToTextDriver;
bareServer.drivers.aiSpeech2Txt as unknown as SpeechToTextDriver;
await expect(
bareDriver.transcribe({
provider: 'xai',
file: dataUrl(Buffer.from('a'), 'audio/mp3'),
}),
).rejects.toMatchObject({
@@ -237,7 +252,7 @@ describe('XAISpeechToTextDriver missing API key', () => {
// ── Credit gate ─────────────────────────────────────────────────────
describe('XAISpeechToTextDriver credit gate', () => {
describe('XAISpeechToTextProvider credit gate', () => {
it('throws 402 BEFORE hitting xAI when actor lacks credits', async () => {
hasCreditsSpy.mockResolvedValueOnce(false);
const { actor } = await makeUser();
@@ -285,7 +300,7 @@ describe('XAISpeechToTextDriver credit gate', () => {
// ── Audio input handling ────────────────────────────────────────────
describe('XAISpeechToTextDriver audio input handling', () => {
describe('XAISpeechToTextProvider audio input handling', () => {
it('decodes a base64 data URL and POSTs it as the multipart `file` field', async () => {
const { actor } = await makeUser();
fetchSpy.mockResolvedValueOnce(
@@ -366,7 +381,7 @@ describe('XAISpeechToTextDriver audio input handling', () => {
// ── Request shape ───────────────────────────────────────────────────
describe('XAISpeechToTextDriver request shape', () => {
describe('XAISpeechToTextProvider request shape', () => {
it('forwards language / format / diarize / multichannel / channels / audio_format / sample_rate', async () => {
const { actor } = await makeUser();
fetchSpy.mockResolvedValueOnce(
@@ -438,7 +453,7 @@ describe('XAISpeechToTextDriver request shape', () => {
// ── Response shape ──────────────────────────────────────────────────
describe('XAISpeechToTextDriver response shape', () => {
describe('XAISpeechToTextProvider response shape', () => {
it('forwards the parsed xAI JSON response verbatim', async () => {
const { actor } = await makeUser();
const upstream = {
@@ -461,7 +476,7 @@ describe('XAISpeechToTextDriver response shape', () => {
// ── Metering ────────────────────────────────────────────────────────
describe('XAISpeechToTextDriver metering', () => {
describe('XAISpeechToTextProvider metering', () => {
it('meters ceil(duration) seconds × per-second ucents using the API duration', async () => {
const { actor } = await makeUser();
// Upstream reports 3.2s → driver should ceil to 4s.
@@ -508,7 +523,7 @@ describe('XAISpeechToTextDriver metering', () => {
// ── Error paths ─────────────────────────────────────────────────────
describe('XAISpeechToTextDriver error paths', () => {
describe('XAISpeechToTextProvider error paths', () => {
it('maps upstream 4xx to HttpError 400 upstream_bad_request and skips metering', async () => {
const { actor } = await makeUser();
fetchSpy.mockResolvedValueOnce(
@@ -18,14 +18,17 @@
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
*/
import { Context } from '../../core/context.js';
import { HttpError } from '../../core/http/HttpError.js';
import { PuterDriver } from '../types.js';
import { AI_CONCURRENT, AI_RATE_LIMIT } from '../util/aiLimits.js';
import { loadFileInput } from '../util/fileInput.js';
import { HttpError } from '../../../../core/http/HttpError.js';
import { loadFileInput } from '../../../util/fileInput.js';
import type {
ISpeechToTextDeps,
ISpeechToTextModel,
ITranscribeArgs,
} from '../../types.js';
import { SpeechToTextProvider } from '../SpeechToTextProvider.js';
/**
* Driver implementing `puter-speech2txt` for the xAI (Grok) STT API.
* Speech-to-text via the xAI (Grok) STT API.
*
* Uses the xAI /v1/stt REST endpoint which accepts multipart/form-data with an
* audio file and returns a JSON transcript with word-level timestamps.
@@ -54,28 +57,17 @@ const SAMPLE_TRANSCRIPT = {
],
};
interface TranscribeArgs {
file: unknown;
language?: string;
format?: boolean;
diarize?: boolean;
multichannel?: boolean;
channels?: number;
audio_format?: string;
sample_rate?: number;
test_mode?: boolean;
}
export class XAISpeechToTextProvider extends SpeechToTextProvider {
readonly providerName = 'xai';
export class XAISpeechToTextDriver extends PuterDriver {
readonly driverInterface = 'puter-speech2txt';
readonly driverName = 'xai-speech2txt';
// Null when the deployment has no xAI credentials. The provider still
// registers so its model catalogue stays listable; transcription rejects.
#apiKey: string | null;
// Shared AI policy — see `drivers/util/aiLimits.ts` for the tier table.
// Shares the `puter-speech2txt` per-user bucket with SpeechToTextDriver.
readonly rateLimit = AI_RATE_LIMIT;
readonly concurrent = AI_CONCURRENT;
#apiKey: string | null = null;
constructor(deps: ISpeechToTextDeps, config: { apiKey?: string }) {
super(deps);
this.#apiKey = config.apiKey ?? null;
}
override getReportedCosts(): Record<string, unknown>[] {
return [
@@ -88,22 +80,7 @@ export class XAISpeechToTextDriver extends PuterDriver {
];
}
override onServerStart() {
const providers = (this.config.providers ?? {}) as Record<
string,
Record<string, unknown> | undefined
>;
const xai = providers['xai'];
if (!xai) return;
const key =
(xai.apiKey as string | undefined) ??
(xai.secret_key as string | undefined) ??
(xai.api_key as string | undefined) ??
(xai.key as string | undefined);
if (key) this.#apiKey = key;
}
async list_models() {
async listModels(): Promise<ISpeechToTextModel[]> {
return [
{
id: 'xai-stt',
@@ -117,11 +94,11 @@ export class XAISpeechToTextDriver extends PuterDriver {
];
}
async transcribe(args: TranscribeArgs) {
async transcribe(args: ITranscribeArgs) {
return this.#handleTranscription(args);
}
async translate(args: TranscribeArgs) {
async translate(args: ITranscribeArgs) {
// xAI STT doesn't have a separate translation endpoint;
// delegate to transcribe which auto-detects language
return this.#handleTranscription(args);
@@ -134,7 +111,7 @@ export class XAISpeechToTextDriver extends PuterDriver {
);
}
async #handleTranscription(args: TranscribeArgs) {
async #handleTranscription(args: ITranscribeArgs) {
if (args.test_mode) {
return { ...SAMPLE_TRANSCRIPT, model: 'xai-stt' };
}
@@ -144,17 +121,9 @@ export class XAISpeechToTextDriver extends PuterDriver {
legacyCode: 'internal_error',
});
}
if (!args.file) {
throw new HttpError(400, '`file` is required', {
legacyCode: 'bad_request',
});
}
this.requireFile(args);
const actor = Context.get('actor');
if (!actor)
throw new HttpError(401, 'Authentication required', {
legacyCode: 'unauthorized',
});
const actor = this.requireActor();
// Determine if the input is an HTTP URL or a filesystem/data-URL reference
const isUrl = this.#isHttpUrl(args.file);
@@ -167,8 +136,8 @@ export class XAISpeechToTextDriver extends PuterDriver {
if (!isUrl) {
const loaded = await loadFileInput(
this.stores,
this.services.fs,
this.deps.stores,
this.deps.fs,
actor,
args.file,
{ maxBytes: MAX_AUDIO_FILE_SIZE },
@@ -185,7 +154,7 @@ export class XAISpeechToTextDriver extends PuterDriver {
? Math.max(1, Math.ceil(fileBuffer.byteLength / 16000))
: 60;
const estimatedCost = UCENTS_PER_SECOND * estimatedSeconds;
const allowed = await this.services.metering.hasEnoughCredits(
const allowed = await this.deps.metering.hasEnoughCredits(
actor,
estimatedCost,
);
@@ -228,7 +197,7 @@ export class XAISpeechToTextDriver extends PuterDriver {
if (!response.ok) {
const errText = await response.text().catch(() => '');
console.error(
`[XAISpeechToTextDriver] API returned ${response.status}: ${errText}`,
`[XAISpeechToTextProvider] API returned ${response.status}: ${errText}`,
);
// Mirrors ElevenLabs / XAITTS — map upstream status to an
// `upstream_*` HttpError so the alarm gate skips it.
@@ -268,7 +237,7 @@ export class XAISpeechToTextDriver extends PuterDriver {
: estimatedSeconds;
const actualCost = UCENTS_PER_SECOND * actualSeconds;
this.services.metering.incrementUsage(
this.deps.metering.incrementUsage(
actor,
'xai:stt:second',
actualSeconds,
@@ -0,0 +1,84 @@
/**
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify it under the
* terms of the GNU Affero General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option) any
* later version.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
*/
/** Types for the `puter-speech2txt` driver interface. */
import type { MeteringService } from '../../services/metering/MeteringService.js';
import type { loadFileInput } from '../util/fileInput.js';
/** The layers a provider needs to read its audio input and meter usage. */
export interface ISpeechToTextDeps {
stores: Parameters<typeof loadFileInput>[0];
fs: Parameters<typeof loadFileInput>[1];
metering: MeteringService;
}
export interface ISpeechToTextModel {
id: string;
name: string;
type: string;
response_formats: string[];
supports_prompt: boolean;
supports_logprobs: boolean;
supports_diarization?: boolean;
supports_timestamp_granularities?: boolean;
provider?: string;
}
export interface ITranscribeArgs {
file?: unknown;
provider?: string;
model?: string;
response_format?: string;
language?: string;
prompt?: string;
temperature?: number;
logprobs?: boolean;
timestamp_granularities?: string[];
chunking_strategy?: string;
known_speaker_names?: string[];
known_speaker_references?: unknown[];
extra_body?: Record<string, unknown>;
stream?: boolean;
test_mode?: boolean;
// Accepted by the xAI provider only.
format?: boolean;
diarize?: boolean;
multichannel?: boolean;
channels?: number;
audio_format?: string;
sample_rate?: number;
}
export interface ISpeechToTextProvider {
readonly providerName: string;
/** Models this provider exposes. */
listModels(): Promise<ISpeechToTextModel[]>;
/** Transcribe audio in its own language. */
transcribe(args: ITranscribeArgs): Promise<unknown>;
/** Transcribe audio, translating it to English. */
translate(args: ITranscribeArgs): Promise<unknown>;
/** Per-unit metering costs, aggregated by the driver. */
getReportedCosts(): Record<string, unknown>[];
}
+115 -9
View File
@@ -128,6 +128,7 @@ beforeAll(async () => {
},
gemini: { apiKey: 'gem-key' },
xai: { apiKey: 'xai-key' },
speechify: { apiKey: 'speechify-key' },
},
} as never);
driver = server.drivers.aiTts as unknown as TTSDriver;
@@ -191,6 +192,7 @@ describe('TTSDriver provider registration', () => {
'elevenlabs',
'gemini',
'openai',
'speechify',
'xai',
]);
});
@@ -243,6 +245,28 @@ describe('TTSDriver.synthesize provider routing', () => {
expect(openaiSpeechCreateMock).not.toHaveBeenCalled();
});
it('routes via legacy driverAlias (speechify-tts → speechify)', async () => {
fetchSpy.mockResolvedValueOnce(
new Response(
JSON.stringify({
audio_data: Buffer.from('audio').toString('base64'),
audio_format: 'mp3',
}),
{ status: 200, headers: { 'content-type': 'application/json' } },
),
);
await withDriverName('speechify-tts', () =>
driver.synthesize({ text: 'hi' }),
);
expect(fetchSpy).toHaveBeenCalledTimes(1);
expect(String(fetchSpy.mock.calls[0]![0])).toBe(
'https://api.speechify.ai/v1/audio/speech',
);
expect(openaiSpeechCreateMock).not.toHaveBeenCalled();
});
it('routes via legacy driverAlias (aws-polly → aws-polly)', async () => {
pollyDispatch();
@@ -257,12 +281,81 @@ describe('TTSDriver.synthesize provider routing', () => {
expect(openaiSpeechCreateMock).not.toHaveBeenCalled();
});
it('defaults to openai when no provider hint is supplied (preferred first)', async () => {
openaiSpeechCreateMock.mockResolvedValueOnce(openaiAudioResponse());
it('defaults to aws-polly when no provider hint is supplied', async () => {
pollyDispatch();
await withActor(() => driver.synthesize({ text: 'hi' }));
const synthCalls = pollySendMock.mock.calls.filter(
([cmd]) => cmd.constructor.name === 'SynthesizeSpeechCommand',
);
expect(synthCalls).toHaveLength(1);
expect(openaiSpeechCreateMock).not.toHaveBeenCalled();
});
it.each(['speechify', 'speechify-tts', 'simba'])(
'resolves the %s alias to the speechify provider',
async (provider) => {
fetchSpy.mockResolvedValueOnce(
new Response(
JSON.stringify({
audio_data: Buffer.from('audio').toString('base64'),
audio_format: 'mp3',
}),
{
status: 200,
headers: { 'content-type': 'application/json' },
},
),
);
await withActor(() => driver.synthesize({ text: 'hi', provider }));
expect(String(fetchSpy.mock.calls[0]![0])).toMatch(
/api\.speechify\.ai/,
);
expect(openaiSpeechCreateMock).not.toHaveBeenCalled();
},
);
it('resolves provider aliases', async () => {
fetchSpy.mockResolvedValueOnce(
new Response('audio', {
status: 200,
headers: { 'content-type': 'audio/mpeg' },
}),
);
await withActor(() =>
driver.synthesize({ text: 'hi', provider: '11labs' }),
);
expect(String(fetchSpy.mock.calls[0]![0])).toMatch(
/api\.elevenlabs\.io\/v1\/text-to-speech\//,
);
expect(openaiSpeechCreateMock).not.toHaveBeenCalled();
});
it('treats an engine that names a provider as the provider', async () => {
openaiSpeechCreateMock.mockResolvedValueOnce(openaiAudioResponse());
await withActor(() => driver.synthesize({ text: 'hi', engine: 'openai' }));
expect(openaiSpeechCreateMock).toHaveBeenCalledTimes(1);
// The alias selected the provider; it is not carried on as a model.
expect(openaiSpeechCreateMock.mock.calls[0][0].model).toBe(
'gpt-4o-mini-tts',
);
});
it('maps `engine` onto `model` for the model-based providers', async () => {
openaiSpeechCreateMock.mockResolvedValueOnce(openaiAudioResponse());
await withActor(() =>
driver.synthesize({ text: 'hi', provider: 'openai', engine: 'tts-1' }),
);
expect(openaiSpeechCreateMock.mock.calls[0][0].model).toBe('tts-1');
});
it('throws 400 when the named provider is not registered', async () => {
@@ -280,7 +373,7 @@ describe('TTSDriver.synthesize provider routing', () => {
// ── list_voices / list_engines aggregation ─────────────────────────
describe('TTSDriver list_voices / list_engines', () => {
it('list_voices() aggregates across providers when no filter is supplied', async () => {
it("list_voices({ provider: 'all' }) aggregates across providers", async () => {
// Provider listVoices methods that touch the network: ElevenLabs
// (fetch) and AWS Polly (DescribeVoices). Wire both up.
fetchSpy.mockResolvedValueOnce(
@@ -288,13 +381,14 @@ describe('TTSDriver list_voices / list_engines', () => {
);
pollyDispatch();
const voices = await driver.list_voices();
const voices = await driver.list_voices({ provider: 'all' });
const providers = new Set(voices.map((v) => v.provider));
// OpenAI, Gemini, xAI, AWS Polly are all hard-coded catalogs; we
// expect those at minimum (ElevenLabs returned an empty list).
expect(providers.has('openai')).toBe(true);
expect(providers.has('gemini')).toBe(true);
expect(providers.has('xai')).toBe(true);
expect(providers.has('speechify')).toBe(true);
expect(providers.has('aws-polly')).toBe(true);
});
@@ -306,13 +400,23 @@ describe('TTSDriver list_voices / list_engines', () => {
}
});
it('list_voices({ provider }) returns empty when the provider is not registered', async () => {
const voices = await driver.list_voices({ provider: 'nope' });
expect(voices).toEqual([]);
it('list_voices({ provider }) throws 400 when the provider is unknown', async () => {
await expect(driver.list_voices({ provider: 'nope' })).rejects.toMatchObject(
{ statusCode: 400 },
);
});
it('list_engines() aggregates engines across providers and namespaces them', async () => {
const engines = await driver.list_engines();
it('list_voices() defaults to aws-polly', async () => {
pollyDispatch();
const voices = await driver.list_voices();
expect(voices.length).toBeGreaterThan(0);
for (const voice of voices) {
expect(voice.provider).toBe('aws-polly');
}
});
it("list_engines({ provider: 'all' }) aggregates engines across providers", async () => {
const engines = await driver.list_engines({ provider: 'all' });
const ids = engines.map((e) => e.id);
// Some signature engines from each provider.
expect(ids).toEqual(
@@ -321,6 +425,7 @@ describe('TTSDriver list_voices / list_engines', () => {
'eleven_multilingual_v2', // elevenlabs
'gemini-2.5-flash-preview-tts',
'xai-tts',
'simba-3.2', // speechify
'standard', // aws-polly
]),
);
@@ -350,6 +455,7 @@ describe('TTSDriver.getReportedCosts', () => {
'driver:aiTts/aws-polly',
'driver:aiTts/gemini',
'driver:aiTts/xai',
'driver:aiTts/speechify',
]),
);
});
+147 -80
View File
@@ -23,10 +23,17 @@ import { HttpError } from '../../core/http/HttpError.js';
import type { DriverStreamResult } from '../meta.js';
import { PuterDriver } from '../types.js';
import { AI_CONCURRENT, AI_RATE_LIMIT } from '../util/aiLimits.js';
import {
DEFAULT_TTS_PROVIDER,
normalizeTTSProvider,
TTS_DRIVER_ALIASES,
TTS_PROVIDERS,
} from './providerAliases.js';
import { AWSPollyTTSProvider } from './providers/awsPolly/AWSPollyTTSProvider.js';
import { ElevenLabsTTSProvider } from './providers/elevenlabs/ElevenLabsTTSProvider.js';
import { GeminiTTSProvider } from './providers/gemini/GeminiTTSProvider.js';
import { OpenAITTSProvider } from './providers/openai/OpenAITTSProvider.js';
import { SpeechifyTTSProvider } from './providers/speechify/SpeechifyTTSProvider.js';
import { XAITTSProvider } from './providers/xai/XAITTSProvider.js';
import type {
ISynthesizeArgs,
@@ -38,34 +45,27 @@ import type {
/**
* Driver implementing the `puter-tts` interface.
*
* Manages multiple upstream TTS providers (OpenAI, ElevenLabs, AWS Polly) and
* handles provider routing, voice/engine aggregation, and speech synthesis.
* Each provider is an `ITTSProvider` instantiated from config on boot.
* Manages multiple upstream TTS providers and handles provider routing,
* voice/engine aggregation, and speech synthesis. Each provider is an
* `ITTSProvider` instantiated from config on boot.
*
* Provider selection, alias resolution and per-provider option naming all live
* here, so a caller only has to name the provider it wants.
*/
// puter-js still routes TTS via legacy per-provider driver names rather
// than passing `{ provider }` in args, so alias the unified driver under
// the names the client expects. `#providerFromAlias` normalizes those
// aliases to the internal provider keys used by `#providers`.
const TTS_ALIASES = [
'aws-polly',
'openai-tts',
'elevenlabs-tts',
'gemini-tts',
'xai-tts',
] as const;
type TTSAlias = (typeof TTS_ALIASES)[number];
const ALIAS_TO_PROVIDER: Record<TTSAlias, string> = {
'aws-polly': 'aws-polly',
'openai-tts': 'openai',
'elevenlabs-tts': 'elevenlabs',
'gemini-tts': 'gemini',
'xai-tts': 'xai',
};
/** Opt-in value that widens the list methods out to every provider. */
const ALL_PROVIDERS = 'all';
const isAllProviders = (value: unknown): boolean =>
typeof value === 'string' && value.trim().toLowerCase() === ALL_PROVIDERS;
export class TTSDriver extends PuterDriver {
readonly driverInterface = 'puter-tts';
readonly driverName = 'ai-tts';
readonly driverAliases = [...TTS_ALIASES];
// Older SDK bundles name the provider in the driver slot instead of
// passing `{ provider }`; `#resolveProvider` reads the requested alias
// back off the Context.
readonly driverAliases = [...TTS_DRIVER_ALIASES];
readonly isDefault = true;
// Shared AI policy — see `drivers/util/aiLimits.ts` for the tier table.
@@ -74,55 +74,50 @@ export class TTSDriver extends PuterDriver {
#providers: Record<string, ITTSProvider> = {};
/** Resolve a provider name from the alias the caller used, if any. */
#providerFromAlias(): string | undefined {
const alias = Context.get('driverName') as string | undefined;
if (!alias) return undefined;
return ALIAS_TO_PROVIDER[alias as TTSAlias];
}
override onServerStart() {
this.#registerProviders();
}
// -- Interface methods -------------------------------------------
/** List all available voices across all configured providers. */
/**
* List available voices. Defaults to the default provider; pass `provider:
* 'all'` to aggregate across every configured provider.
*/
async list_voices(args?: Record<string, unknown>): Promise<ITTSVoice[]> {
const provider =
(args?.provider as string | undefined) ?? this.#providerFromAlias();
if (provider) {
const p = this.#providers[provider];
if (!p) return [];
return p.listVoices(args);
const { provider: requested, ...rest } = args ?? {};
if (isAllProviders(requested)) {
const allVoices: ITTSVoice[] = [];
for (const p of Object.values(this.#providers)) {
allVoices.push(...(await p.listVoices(rest)));
}
return allVoices;
}
const allVoices: ITTSVoice[] = [];
for (const p of Object.values(this.#providers)) {
const voices = await p.listVoices(args);
allVoices.push(...voices);
}
return allVoices;
const p =
this.#providers[this.#resolveProvider({ provider: requested })];
if (!p) return [];
return p.listVoices(rest);
}
/** List all available engines/models across all configured providers. */
/**
* List available engines/models. Defaults to the default provider; pass
* `provider: 'all'` to aggregate across every configured provider.
*/
async list_engines(args?: Record<string, unknown>): Promise<ITTSEngine[]> {
const provider =
(args?.provider as string | undefined) ?? this.#providerFromAlias();
if (provider) {
const p = this.#providers[provider];
if (!p) return [];
return p.listEngines();
const requested = args?.provider;
if (isAllProviders(requested)) {
const allEngines: ITTSEngine[] = [];
for (const p of Object.values(this.#providers)) {
allEngines.push(...(await p.listEngines()));
}
return allEngines;
}
const allEngines: ITTSEngine[] = [];
for (const p of Object.values(this.#providers)) {
const engines = await p.listEngines();
allEngines.push(...engines);
}
return allEngines;
const p =
this.#providers[this.#resolveProvider({ provider: requested })];
if (!p) return [];
return p.listEngines();
}
/** List provider names that are currently configured. */
@@ -151,8 +146,8 @@ export class TTSDriver extends PuterDriver {
}
/**
* Synthesize speech from text. Routes to the appropriate provider based on
* the `provider` argument, or falls back to the first available provider.
* Synthesize speech from text, routed to the provider named by `provider`
* (or the default when none is given).
*/
async synthesize(
args: ISynthesizeArgs,
@@ -163,30 +158,89 @@ export class TTSDriver extends PuterDriver {
legacyCode: 'unauthorized',
});
const providerName =
args.provider ||
this.#providerFromAlias() ||
this.#getDefaultProviderName();
if (!providerName) {
throw new HttpError(500, 'No TTS providers configured', {
legacyCode: 'internal_error',
});
}
const providerName = this.#resolveProvider(args);
const provider = this.#providers[providerName];
if (!provider) {
throw new HttpError(
400,
`TTS provider not found: ${providerName}. Available: ${Object.keys(this.#providers).join(', ')}`,
`TTS provider not configured: ${providerName}. Available: ${Object.keys(this.#providers).join(', ')}`,
{ legacyCode: 'bad_request' },
);
}
return provider.synthesize(args) as Promise<
return provider.synthesize(
this.#providerArgs(providerName, args),
) as Promise<
DriverStreamResult | { url: string; content_type: string }
>;
}
// -- Provider routing --------------------------------------------
/**
* Decide which provider handles a call. An explicit `provider` wins, then
* an `engine` that names a provider (a long-standing shorthand), then the
* legacy driver alias the caller dispatched through, then the default.
*/
#resolveProvider(args: { provider?: unknown; engine?: unknown }): string {
if (
args.provider !== undefined &&
args.provider !== null &&
args.provider !== ''
) {
const named = normalizeTTSProvider(args.provider);
if (!named) {
throw new HttpError(
400,
`TTS provider not found: ${String(args.provider)}. Available: ${TTS_PROVIDERS.join(', ')}`,
{ legacyCode: 'bad_request' },
);
}
return named;
}
return (
normalizeTTSProvider(args.engine) ??
normalizeTTSProvider(Context.get('driverName')) ??
this.#defaultProvider()
);
}
/**
* `engine` is AWS Polly's own concept; on the model-based providers it is
* the legacy spelling of `model`.
*/
#providerArgs(
providerName: string,
args: ISynthesizeArgs,
): ISynthesizeArgs {
if (providerName === 'aws-polly') {
return { ...args, provider: providerName };
}
const { engine, ...rest } = args;
if (
rest.model === undefined &&
typeof engine === 'string' &&
// An engine that named the provider selected it above; it is not
// also a model id.
!normalizeTTSProvider(engine)
) {
rest.model = engine;
}
return { ...rest, provider: providerName };
}
/**
* The documented default, falling back to whatever is configured so a
* deployment without AWS Polly still serves TTS.
*/
#defaultProvider(): string {
for (const name of [DEFAULT_TTS_PROVIDER, ...TTS_PROVIDERS]) {
if (this.#providers[name]) return name;
}
return Object.keys(this.#providers)[0] ?? DEFAULT_TTS_PROVIDER;
}
// -- Provider registration ---------------------------------------
#registerProviders() {
@@ -264,6 +318,7 @@ export class TTSDriver extends PuterDriver {
this.#registerGeminiProvider(providers);
this.#registerXAIProvider(providers);
this.#registerSpeechifyProvider(providers);
}
#registerGeminiProvider(providers: Record<string, unknown>) {
@@ -312,13 +367,25 @@ export class TTSDriver extends PuterDriver {
}
}
#getDefaultProviderName(): string | null {
const names = Object.keys(this.#providers);
if (names.length === 0) return null;
// Prefer openai, then elevenlabs, then gemini, then aws-polly
if (this.#providers['openai']) return 'openai';
if (this.#providers['elevenlabs']) return 'elevenlabs';
if (this.#providers['gemini']) return 'gemini';
return names[0];
#registerSpeechifyProvider(providers: Record<string, unknown>) {
const m = this.services.metering;
const speechify = (providers['speechify'] ??
providers['speechify-tts']) as Record<string, unknown> | undefined;
const speechifyKey =
(speechify?.apiKey as string | undefined) ??
(speechify?.api_key as string | undefined) ??
(speechify?.key as string | undefined);
if (speechifyKey) {
try {
this.#providers['speechify'] = new SpeechifyTTSProvider(m, {
apiKey: speechifyKey,
});
} catch (e) {
console.warn(
'[TTSDriver] Failed to init Speechify TTS provider:',
(e as Error).message,
);
}
}
}
}
@@ -0,0 +1,86 @@
/**
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify it under the
* terms of the GNU Affero General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option) any
* later version.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
*/
/**
* Canonical `puter-tts` provider ids and the aliases callers may use in their
* place. Resolution lives here rather than in the SDK so a new alias reaches
* every caller at once, including clients running an older bundle.
*/
export const TTS_PROVIDERS = [
'aws-polly',
'openai',
'elevenlabs',
'gemini',
'xai',
'speechify',
] as const;
export type TTSProviderName = (typeof TTS_PROVIDERS)[number];
/** The documented default when a caller names no provider. */
export const DEFAULT_TTS_PROVIDER: TTSProviderName = 'aws-polly';
/**
* Driver names the unified driver answers to. Older SDK bundles put the
* provider in the driver slot instead of passing `{ provider }`.
*/
export const TTS_DRIVER_ALIASES = [
'aws-polly',
'openai-tts',
'elevenlabs-tts',
'gemini-tts',
'xai-tts',
'speechify-tts',
] as const;
const PROVIDER_BY_ALIAS: Record<string, TTSProviderName> = {
aws: 'aws-polly',
'aws-polly': 'aws-polly',
polly: 'aws-polly',
openai: 'openai',
'openai-tts': 'openai',
'11-labs': 'elevenlabs',
'11labs': 'elevenlabs',
eleven: 'elevenlabs',
'eleven-labs': 'elevenlabs',
elevenlabs: 'elevenlabs',
'elevenlabs-tts': 'elevenlabs',
gemini: 'gemini',
'gemini-tts': 'gemini',
google: 'gemini',
'google-tts': 'gemini',
grok: 'xai',
'grok-tts': 'xai',
'x-ai': 'xai',
xai: 'xai',
'xai-tts': 'xai',
simba: 'speechify',
speechify: 'speechify',
'speechify-tts': 'speechify',
};
/** Resolve a caller-supplied provider name, or `undefined` if unrecognized. */
export function normalizeTTSProvider(
value: unknown,
): TTSProviderName | undefined {
if (typeof value !== 'string') return undefined;
return PROVIDER_BY_ALIAS[value.trim().toLowerCase()];
}
@@ -3,18 +3,19 @@
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
* Puter is free software: you can redistribute it and/or modify it under the
* terms of the GNU Affero General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option) any
* later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
* along with this program. If not, see
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
*/
import {
@@ -37,14 +38,23 @@ const SAMPLE_AUDIO_URL = 'https://puter-sample-data.puter.site/tts_example.mp3';
const VALID_ENGINES = ['standard', 'neural', 'long-form', 'generative'];
// Voices tried, in order, when the caller names none. The head of each list
// is the provider's advertised default.
const PREFERRED_VOICES: Record<string, string[]> = {
standard: ['Joanna', 'Salli', 'Matthew'],
neural: ['Joanna', 'Matthew', 'Salli'],
'long-form': ['Joanna', 'Matthew'],
generative: ['Joanna', 'Matthew', 'Salli'],
};
interface PollyVoicesResponse {
Voices: any[];
}
/**
* AWS Polly TTS provider. Wraps the AWS Polly speech synthesis API and
* returns audio as a DriverStreamResult. Includes voice caching and
* engine-aware voice selection.
* AWS Polly TTS provider. Wraps the AWS Polly speech synthesis API and returns
* audio as a DriverStreamResult. Includes voice caching and engine-aware voice
* selection.
*/
export class AWSPollyTTSProvider extends TTSProvider {
readonly providerName = 'aws-polly';
@@ -111,25 +121,29 @@ export class AWSPollyTTSProvider extends TTSProvider {
): Promise<string | null> {
const voices = await this.describeVoices();
const voice = voices.Voices.find(
const candidates = voices.Voices.filter(
// eslint-disable-next-line @typescript-eslint/no-explicit-any
(v: any) =>
v.LanguageCode === language &&
v.SupportedEngines?.includes(engine),
);
return voice ? voice.Id : null;
if (candidates.length === 0) return null;
// Keep the engine's default voice when it speaks the requested
// language, so naming a language doesn't silently change the voice
// for callers who only wanted the default.
for (const voiceName of PREFERRED_VOICES[engine] ?? []) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const match = candidates.find((v: any) => v.Id === voiceName);
if (match) return match.Id;
}
return candidates[0].Id;
}
private async getDefaultVoiceForEngine(engine: string): Promise<string> {
const voices = await this.describeVoices();
const defaultVoices: Record<string, string[]> = {
standard: ['Salli', 'Joanna', 'Matthew'],
neural: ['Joanna', 'Matthew', 'Salli'],
'long-form': ['Joanna', 'Matthew'],
generative: ['Joanna', 'Matthew', 'Salli'],
};
const preferred = defaultVoices[engine] || ['Salli'];
const preferred = PREFERRED_VOICES[engine] ?? ['Salli'];
for (const voiceName of preferred) {
const voice = voices.Voices.find(
@@ -164,7 +178,7 @@ export class AWSPollyTTSProvider extends TTSProvider {
400,
`Invalid engine: ${engine}. Valid engines: ${VALID_ENGINES.join(', ')}`,
{
legacyCode: 'bad_request',
legacyCode: 'invalid_engine',
fields: { engine, valid_engines: VALID_ENGINES },
},
);
@@ -225,7 +239,7 @@ export class AWSPollyTTSProvider extends TTSProvider {
400,
`Invalid engine: ${engine}. Valid engines: ${VALID_ENGINES.join(', ')}`,
{
legacyCode: 'bad_request',
legacyCode: 'invalid_engine',
fields: { engine, valid_engines: VALID_ENGINES },
},
);
@@ -0,0 +1,385 @@
/**
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/**
* Offline unit tests for SpeechifyTTSProvider.
*
* Boots a real PuterServer (in-memory sqlite + dynamo + s3 + mock
* redis) and constructs SpeechifyTTSProvider directly against the live
* wired `MeteringService`. Speechify has no SDK here — the provider
* calls the REST endpoint via `fetch` — so global `fetch` is spied for
* each request shape assertion.
*/
import { Readable } from 'node:stream';
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
vi,
type MockInstance,
} from 'vitest';
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
import { PuterServer } from '../../../../server.js';
import { setupTestServer } from '../../../../testUtil.js';
import { withTestActor } from '../../../integrationTestUtil.js';
import { SpeechifyTTSProvider } from './SpeechifyTTSProvider.js';
import { SPEECHIFY_TTS_COSTS } from './costs.js';
// ── Test harness ────────────────────────────────────────────────────
let server: PuterServer;
let fetchSpy: MockInstance<typeof fetch>;
let hasCreditsSpy: MockInstance<MeteringService['hasEnoughCredits']>;
let incrementUsageSpy: MockInstance<MeteringService['incrementUsage']>;
beforeAll(async () => {
server = await setupTestServer();
});
afterAll(async () => {
await server?.shutdown();
});
const makeProvider = () =>
new SpeechifyTTSProvider(server.services.metering, { apiKey: 'test-key' });
const audioResponse = (audioData = Buffer.from('audio-bytes').toString('base64'), audioFormat = 'mp3') =>
new Response(JSON.stringify({ audio_data: audioData, audio_format: audioFormat }), {
status: 200,
headers: { 'content-type': 'application/json' },
});
beforeEach(() => {
fetchSpy = vi.spyOn(globalThis, 'fetch') as MockInstance<typeof fetch>;
hasCreditsSpy = vi.spyOn(server.services.metering, 'hasEnoughCredits');
incrementUsageSpy = vi.spyOn(server.services.metering, 'incrementUsage');
});
afterEach(() => {
vi.restoreAllMocks();
});
// ── Construction ────────────────────────────────────────────────────
describe('SpeechifyTTSProvider construction', () => {
it('throws when no apiKey is supplied', () => {
expect(
() =>
new SpeechifyTTSProvider(server.services.metering, {
apiKey: '',
}),
).toThrow(/API key/i);
});
});
// ── Voice / engine catalog ──────────────────────────────────────────
describe('SpeechifyTTSProvider catalog', () => {
it('listVoices returns the documented Speechify voices with provider=speechify', async () => {
const provider = makeProvider();
const voices = await provider.listVoices();
const ids = voices.map((v) => v.id);
expect(ids).toEqual(
expect.arrayContaining(['geffen_32', 'dominic_32', 'harper_32', 'hugh_32', 'imogen_32']),
);
for (const voice of voices) {
expect(voice.provider).toBe('speechify');
}
});
it('listEngines reports the Simba model family', async () => {
const provider = makeProvider();
const engines = await provider.listEngines();
const ids = engines.map((e) => e.id);
expect(ids).toEqual(
expect.arrayContaining(['simba-3.2', 'simba-english', 'simba-multilingual']),
);
for (const engine of engines) {
expect(engine.provider).toBe('speechify');
}
});
});
// ── Reported costs ──────────────────────────────────────────────────
describe('SpeechifyTTSProvider.getReportedCosts', () => {
it('mirrors every entry in costs.ts as a per-character line item', () => {
const provider = makeProvider();
const reported = provider.getReportedCosts();
expect(reported).toHaveLength(Object.keys(SPEECHIFY_TTS_COSTS).length);
for (const [model, ucentsPerUnit] of Object.entries(SPEECHIFY_TTS_COSTS)) {
expect(reported).toContainEqual({
usageType: `speechify:${model}:character`,
ucentsPerUnit,
unit: 'character',
source: 'driver:aiTts/speechify',
});
}
});
});
// ── test_mode bypass ────────────────────────────────────────────────
describe('SpeechifyTTSProvider.synthesize test_mode', () => {
it('returns the canned sample URL without hitting credits or fetch', async () => {
const provider = makeProvider();
const result = await withTestActor(() =>
provider.synthesize({ text: 'hi', test_mode: true }),
);
expect(result).toEqual({
url: 'https://puter-sample-data.puter.site/tts_example.mp3',
content_type: 'audio',
});
expect(hasCreditsSpy).not.toHaveBeenCalled();
expect(fetchSpy).not.toHaveBeenCalled();
});
});
// ── Argument validation ─────────────────────────────────────────────
describe('SpeechifyTTSProvider.synthesize argument validation', () => {
it('throws 400 when text is missing or blank', async () => {
const provider = makeProvider();
await expect(
withTestActor(() => provider.synthesize({ text: '' })),
).rejects.toMatchObject({ statusCode: 400 });
await expect(
withTestActor(() => provider.synthesize({ text: ' ' })),
).rejects.toMatchObject({ statusCode: 400 });
expect(fetchSpy).not.toHaveBeenCalled();
});
it('throws 400 for an unrecognized model', async () => {
const provider = makeProvider();
await expect(
withTestActor(() =>
provider.synthesize({ text: 'hi', model: 'not-a-real-model' }),
),
).rejects.toMatchObject({ statusCode: 400 });
expect(fetchSpy).not.toHaveBeenCalled();
});
});
// ── Credit gate ─────────────────────────────────────────────────────
describe('SpeechifyTTSProvider.synthesize credit gate', () => {
it('throws 402 BEFORE hitting Speechify when actor lacks credits', async () => {
const provider = makeProvider();
hasCreditsSpy.mockResolvedValueOnce(false);
await expect(
withTestActor(() => provider.synthesize({ text: 'hi' })),
).rejects.toMatchObject({ statusCode: 402 });
expect(fetchSpy).not.toHaveBeenCalled();
});
});
// ── Request shape ───────────────────────────────────────────────────
describe('SpeechifyTTSProvider.synthesize request shape', () => {
it('POSTs to /v1/audio/speech with Bearer auth, Speechify-Caller header, and defaults', async () => {
const provider = makeProvider();
fetchSpy.mockResolvedValueOnce(audioResponse());
await withTestActor(() => provider.synthesize({ text: 'hello' }));
const [url, init] = fetchSpy.mock.calls[0]!;
expect(String(url)).toBe('https://api.speechify.ai/v1/audio/speech');
const initObj = init as RequestInit;
expect(initObj.method).toBe('POST');
const headers = initObj.headers as Record<string, string>;
expect(headers.Authorization).toBe('Bearer test-key');
expect(headers['Speechify-Caller']).toBe('puter');
const body = JSON.parse(initObj.body as string);
expect(body).toEqual({
input: '<speak>hello</speak>',
voice_id: 'geffen_32', // DEFAULT_VOICE
model: 'simba-3.2', // DEFAULT_MODEL
audio_format: 'mp3',
});
});
it('forwards voice and model overrides to the API', async () => {
const provider = makeProvider();
fetchSpy.mockResolvedValueOnce(audioResponse());
await withTestActor(() =>
provider.synthesize({ text: 'hi', voice: 'alec', model: 'simba-english' }),
);
const body = JSON.parse(
(fetchSpy.mock.calls[0]![1] as RequestInit).body as string,
);
expect(body.voice_id).toBe('alec');
expect(body.model).toBe('simba-english');
});
it('does not re-wrap text that is already SSML', async () => {
const provider = makeProvider();
fetchSpy.mockResolvedValueOnce(audioResponse());
await withTestActor(() =>
provider.synthesize({ text: '<speak>already SSML</speak>' }),
);
const body = JSON.parse(
(fetchSpy.mock.calls[0]![1] as RequestInit).body as string,
);
expect(body.input).toBe('<speak>already SSML</speak>');
});
it('decodes base64 audio_data into a readable byte stream', async () => {
const provider = makeProvider();
const raw = Buffer.from('AAA-BBB');
fetchSpy.mockResolvedValueOnce(audioResponse(raw.toString('base64')));
const result = (await withTestActor(() =>
provider.synthesize({ text: 'hi' }),
)) as { stream: Readable; content_type: string; chunked: boolean };
expect(result.chunked).toBe(true);
expect(result.stream).toBeInstanceOf(Readable);
const chunks: Buffer[] = [];
for await (const chunk of result.stream) {
chunks.push(chunk as Buffer);
}
expect(Buffer.concat(chunks).equals(raw)).toBe(true);
});
it('maps audio_format to the canonical content-type', async () => {
const provider = makeProvider();
fetchSpy.mockResolvedValueOnce(
audioResponse(Buffer.from('x').toString('base64'), 'wav'),
);
const result = (await withTestActor(() =>
provider.synthesize({ text: 'hi', output_format: 'wav' }),
)) as { content_type: string };
expect(result.content_type).toBe('audio/wav');
});
});
// ── Cost reporting & metering ───────────────────────────────────────
describe('SpeechifyTTSProvider.synthesize metering', () => {
it('meters character count × per-char ucents under speechify:<model>:character', async () => {
const provider = makeProvider();
fetchSpy.mockResolvedValueOnce(audioResponse());
const text = 'hello world';
await withTestActor(() => provider.synthesize({ text }));
const expectedCost = SPEECHIFY_TTS_COSTS['simba-3.2'] * text.length;
expect(incrementUsageSpy).toHaveBeenCalledTimes(1);
const [, usageType, count, cost] = incrementUsageSpy.mock.calls[0]!;
expect(usageType).toBe('speechify:simba-3.2:character');
expect(count).toBe(text.length);
expect(cost).toBe(expectedCost);
});
it('asks for hasEnoughCredits with the same total it later meters', async () => {
const provider = makeProvider();
fetchSpy.mockResolvedValueOnce(audioResponse());
const text = 'hi there';
await withTestActor(() => provider.synthesize({ text }));
const expectedCost = SPEECHIFY_TTS_COSTS['simba-3.2'] * text.length;
expect(hasCreditsSpy.mock.calls[0]![1]).toBe(expectedCost);
});
});
// ── Error paths ─────────────────────────────────────────────────────
describe('SpeechifyTTSProvider.synthesize error paths', () => {
it('maps upstream 4xx to HttpError 400 upstream_bad_request', async () => {
const provider = makeProvider();
fetchSpy.mockResolvedValueOnce(
new Response('bad request', { status: 400 }),
);
await expect(
withTestActor(() => provider.synthesize({ text: 'hi' })),
).rejects.toMatchObject({
statusCode: 400,
legacyCode: 'upstream_bad_request',
});
expect(incrementUsageSpy).not.toHaveBeenCalled();
});
it('maps upstream 5xx to HttpError 400 upstream_provider_unavailable', async () => {
const provider = makeProvider();
fetchSpy.mockResolvedValueOnce(new Response('oops', { status: 503 }));
await expect(
withTestActor(() => provider.synthesize({ text: 'hi' })),
).rejects.toMatchObject({
statusCode: 400,
legacyCode: 'upstream_provider_unavailable',
});
expect(incrementUsageSpy).not.toHaveBeenCalled();
});
it('maps upstream 429 to HttpError 429 upstream_rate_limited', async () => {
const provider = makeProvider();
fetchSpy.mockResolvedValueOnce(new Response('slow down', { status: 429 }));
await expect(
withTestActor(() => provider.synthesize({ text: 'hi' })),
).rejects.toMatchObject({
statusCode: 429,
legacyCode: 'upstream_rate_limited',
});
expect(incrementUsageSpy).not.toHaveBeenCalled();
});
it('throws 400 when the response has no audio_data', async () => {
const provider = makeProvider();
fetchSpy.mockResolvedValueOnce(
new Response(JSON.stringify({}), {
status: 200,
headers: { 'content-type': 'application/json' },
}),
);
await expect(
withTestActor(() => provider.synthesize({ text: 'hi' })),
).rejects.toMatchObject({ statusCode: 400 });
expect(incrementUsageSpy).not.toHaveBeenCalled();
});
it('lets fetch network errors bubble so the driver boundary can decide', async () => {
const provider = makeProvider();
fetchSpy.mockRejectedValueOnce(new Error('connection reset'));
await expect(
withTestActor(() => provider.synthesize({ text: 'hi' })),
).rejects.toThrow('connection reset');
expect(incrementUsageSpy).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,258 @@
/**
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify it under the
* terms of the GNU Affero General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option) any
* later version.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
*/
import { Readable } from 'node:stream';
import { HttpError } from '../../../../core/http/HttpError.js';
import { Context } from '../../../../core/context.js';
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
import type { DriverStreamResult } from '../../../meta.js';
import type { ITTSVoice, ITTSEngine, ISynthesizeArgs } from '../../types.js';
import { TTSProvider } from '../TTSProvider.js';
import { SPEECHIFY_TTS_COSTS } from './costs.js';
// Public API base only — never an internal/consumer Speechify endpoint.
const API_BASE = 'https://api.speechify.ai';
const CALLER_HEADER = 'Speechify-Caller';
const CALLER_VALUE = 'puter';
const SAMPLE_AUDIO_URL = 'https://puter-sample-data.puter.site/tts_example.mp3';
const DEFAULT_MODEL = 'simba-3.2';
const DEFAULT_VOICE = 'geffen_32';
const SPEECHIFY_TTS_MODELS = [
{ id: 'simba-3.2', name: 'Simba 3.2' },
{ id: 'simba-english', name: 'Simba English' },
{ id: 'simba-multilingual', name: 'Simba Multilingual' },
];
// Representative starter catalog — verify against Speechify's live
// voices endpoint before this ships upstream.
const SPEECHIFY_TTS_VOICES = [
{ id: 'geffen_32', name: 'Geffen', description: 'Warm, conversational' },
{ id: 'dominic_32', name: 'Dominic', description: 'Deep, narrator' },
{ id: 'harper_32', name: 'Harper', description: 'Bright, upbeat' },
{ id: 'hugh_32', name: 'Hugh', description: 'Calm, professional' },
{ id: 'imogen_32', name: 'Imogen', description: 'Clear, neutral' },
];
const CONTENT_TYPES: Record<string, string> = {
mp3: 'audio/mpeg',
wav: 'audio/wav',
ogg: 'audio/ogg',
aac: 'audio/aac',
};
/**
* Speechify TTS provider. Calls the Speechify `/v1/audio/speech` REST endpoint
* and returns audio as a DriverStreamResult. Every outbound request carries
* `Speechify-Caller: puter` for integration attribution.
*/
export class SpeechifyTTSProvider extends TTSProvider {
readonly providerName = 'speechify';
#apiKey: string;
constructor(meteringService: MeteringService, config: { apiKey: string }) {
super(meteringService, config);
if (!config.apiKey) {
throw new Error('Speechify TTS requires an API key');
}
this.#apiKey = config.apiKey;
}
async listVoices(): Promise<ITTSVoice[]> {
return SPEECHIFY_TTS_VOICES.map((voice) => ({
id: voice.id,
name: voice.name,
description: voice.description,
provider: 'speechify',
supported_models: SPEECHIFY_TTS_MODELS.map((m) => m.id),
}));
}
async listEngines(): Promise<ITTSEngine[]> {
return SPEECHIFY_TTS_MODELS.map((model) => ({
id: model.id,
name: model.name,
provider: 'speechify',
}));
}
override getReportedCosts(): Record<string, unknown>[] {
return Object.entries(SPEECHIFY_TTS_COSTS).map(
([model, ucentsPerUnit]) => ({
usageType: `speechify:${model}:character`,
ucentsPerUnit,
unit: 'character',
source: 'driver:aiTts/speechify',
}),
);
}
async synthesize(
args: ISynthesizeArgs,
): Promise<DriverStreamResult | { url: string; content_type: string }> {
const {
text,
voice: voiceArg,
model: modelArg,
response_format,
output_format,
test_mode,
} = args;
if (test_mode) {
return { url: SAMPLE_AUDIO_URL, content_type: 'audio' };
}
if (typeof text !== 'string' || !text.trim()) {
throw new HttpError(400, 'Missing required field: text', {
legacyCode: 'field_required',
fields: { key: 'text' },
});
}
const model = modelArg || DEFAULT_MODEL;
if (!SPEECHIFY_TTS_MODELS.find(({ id }) => id === model)) {
throw new HttpError(
400,
`Invalid model: ${model}. Expected: ${SPEECHIFY_TTS_MODELS.map(({ id }) => id).join(', ')}`,
{
legacyCode: 'field_invalid',
fields: {
key: 'model',
expected: SPEECHIFY_TTS_MODELS.map(({ id }) => id).join(
', ',
),
got: model,
},
},
);
}
const voice = voiceArg || DEFAULT_VOICE;
const format = output_format || response_format || 'mp3';
const actor = Context.get('actor')!;
const usageType = `speechify:${model}:character`;
const ucentsPerChar = SPEECHIFY_TTS_COSTS[model] ?? 0;
const totalCost = ucentsPerChar * text.length;
const usageAllowed = await this.meteringService.hasEnoughCredits(
actor,
totalCost,
);
if (!usageAllowed) {
throw new HttpError(402, 'Insufficient funds', {
legacyCode: 'insufficient_funds',
});
}
// Speechify's synthesis endpoint expects SSML; wrap plain text so
// callers can keep passing bare strings like every other provider.
const input = /<speak[\s>]/i.test(text)
? text
: `<speak>${text}</speak>`;
const response = await fetch(`${API_BASE}/v1/audio/speech`, {
method: 'POST',
headers: {
Authorization: `Bearer ${this.#apiKey}`,
'Content-Type': 'application/json',
[CALLER_HEADER]: CALLER_VALUE,
},
body: JSON.stringify({
input,
voice_id: voice,
model,
audio_format: format,
}),
});
if (!response.ok) {
const errText = await response.text().catch(() => '');
console.error(
`[SpeechifyTTSProvider] API returned ${response.status}: ${errText}`,
);
// Map upstream status to an `upstream_*` HttpError so the
// alarm gate skips it. Mirrors ElevenLabs/xAI's translator —
// 4xx and 5xx both surface as 400 to the client (with the
// appropriate legacyCode), 429 stays 429, auth stays 500.
const legacyCode =
response.status >= 500
? 'upstream_provider_unavailable'
: response.status === 401 || response.status === 403
? 'upstream_auth_failed'
: response.status === 429
? 'upstream_rate_limited'
: 'upstream_bad_request';
const exposedStatus =
legacyCode === 'upstream_rate_limited'
? 429
: legacyCode === 'upstream_auth_failed'
? 500
: 400;
throw new HttpError(
exposedStatus,
errText ||
`Speechify TTS request failed (status ${response.status})`,
{
legacyCode,
fields: {
provider: 'speechify',
upstreamStatus: response.status,
},
},
);
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const data: any = await response.json();
if (!data?.audio_data) {
throw new HttpError(
400,
'Speechify TTS did not return audio data',
{
legacyCode: 'upstream_bad_request',
fields: { provider: 'speechify' },
},
);
}
const buffer = Buffer.from(data.audio_data, 'base64');
const stream = Readable.from(buffer);
const contentType =
CONTENT_TYPES[data.audio_format ?? format] || 'audio/mpeg';
this.meteringService.incrementUsage(
actor,
usageType,
text.length,
totalCost,
);
return {
dataType: 'stream',
content_type: contentType,
chunked: true,
stream,
};
}
}
@@ -0,0 +1,29 @@
/**
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// Speechify TTS pricing: $10.00 per 1M characters across the Simba model
// family (per Speechify's published API pricing).
// $10.00 per 1M chars = 1000 cents per 1M chars
// In microcents: 1000 * 1_000_000 = 1_000_000_000 microcents per 1M chars
// Per character: 1_000_000_000 / 1_000_000 = 1000 microcents per character
export const SPEECHIFY_TTS_COSTS: Record<string, number> = {
'simba-3.2': 1000,
'simba-english': 1000,
'simba-multilingual': 1000,
};
-16
View File
@@ -866,22 +866,6 @@ export class AppDriver extends PuterDriver {
});
}
/**
* Public wrapper over the authoritative client serializer. Callers outside
* the driver (e.g. the homepage shell baking app metadata into the rendered
* GUI) must never embed a raw store row — it carries `owner_user_id`, admin
* flags, and the private `index_url`. This projects the safe field subset
* and applies the private-app `index_url` gate for `actor`, which may be
* null for an anonymous request.
*
* @param {Record<string, unknown>} app - Raw app store row
* @param {any} [actor] - Request actor, or null when anonymous
* @param {Record<string, unknown>} [params]
*/
async toClientView(app, actor = null, params = {}) {
return this.#toClient(app, actor, params);
}
async #toClient(app, actor, params = {}) {
if (!app) return null;
+5 -7
View File
@@ -46,19 +46,17 @@ const EXPECTED: Record<string, string[]> = {
aiTts: ['list', 'list_engines', 'list_voices', 'synthesize'],
aiVideo: ['generate', 'list', 'models'],
aiSpeech2Speech: ['convert'],
aiSpeech2Txt: ['list_models', 'transcribe', 'translate'],
aiSpeech2TxtXai: ['list_models', 'transcribe', 'translate'],
aiSpeech2Txt: ['list', 'list_models', 'transcribe', 'translate'],
aiOcr: ['recognize'],
// AppDriver is the legacy `.js` driver: its non-RPC helpers are plain
// public methods (not `#`-private), so `isNameAvailable` (an AppController
// helper) and `toClientView` (a safe-field projection used by the homepage
// shell) sit on the callable surface. Both are (and always were, under the
// helper) sits on the callable surface. It is (and always was, under the
// old reflection dispatch) remotely callable — pinned here rather than
// silently exposed. See the follow-up note: lock these down by making them
// `#`-private with dedicated call sites if the exposure is unwanted.
// silently exposed. See the follow-up note: lock it down by making it
// `#`-private with a dedicated call site if the exposure is unwanted.
apps: [
'create', 'delete', 'isNameAvailable', 'read', 'select',
'toClientView', 'update', 'upsert',
'update', 'upsert',
],
subdomains: ['create', 'delete', 'read', 'select', 'update', 'upsert'],
notifications: ['create', 'mark_acknowledged', 'mark_shown', 'read', 'select'],
+11 -14
View File
@@ -25,7 +25,6 @@ import { ImageGenerationDriver } from './ai-image/ImageGenerationDriver.js';
import { OCRDriver } from './ai-ocr/OCRDriver.js';
import { VoiceChangerDriver } from './ai-speech2speech/VoiceChangerDriver.js';
import { SpeechToTextDriver } from './ai-speech2txt/SpeechToTextDriver.js';
import { XAISpeechToTextDriver } from './ai-speech2txt/XAISpeechToTextDriver.js';
import { TTSDriver } from './ai-tts/TTSDriver.js';
import { VideoGenerationDriver } from './ai-video/VideoGenerationDriver.js';
import { AppDriver } from './apps/AppDriver.js';
@@ -136,7 +135,6 @@ describe.each([
['TTSDriver', () => new TTSDriver(...fake())],
['VoiceChangerDriver', () => new VoiceChangerDriver(...fake())],
['SpeechToTextDriver', () => new SpeechToTextDriver(...fake())],
['XAISpeechToTextDriver', () => new XAISpeechToTextDriver(...fake())],
['OCRDriver', () => new OCRDriver(...fake())],
])('AI driver — %s', (_name, build) => {
const m = meta(build());
@@ -172,17 +170,16 @@ describe('non-AI drivers — session tokens stay allowed', () => {
// ── Iface coordination cross-check ──────────────────────────────────
describe('puter-speech2txt — sibling drivers share the bucket', () => {
// Both speech2txt impls register on the same interface. The
// controller keys rate/concurrent by (iface, method, user) — so as
// long as they hold the same policy reference, switching providers
// mid-session can't dodge the cap.
it('SpeechToText and XAISpeechToText carry identical policies', () => {
const a = meta(new SpeechToTextDriver(...fake()));
const b = meta(new XAISpeechToTextDriver(...fake()));
expect(a.interfaceName).toBe('puter-speech2txt');
expect(b.interfaceName).toBe('puter-speech2txt');
expect(a.rateLimit).toBe(b.rateLimit);
expect(a.concurrent).toBe(b.concurrent);
describe('puter-speech2txt — one driver covers every provider', () => {
// A single driver serves the interface, so the controller's
// (iface, method, user) bucket already spans every provider and
// switching providers mid-session can't dodge the cap.
it('answers to the legacy per-provider driver names', () => {
const m = meta(new SpeechToTextDriver(...fake()));
expect(m.interfaceName).toBe('puter-speech2txt');
expect(m.driverName).toBe('ai-speech2txt');
expect(m.aliases).toEqual(
expect.arrayContaining(['openai-speech2txt', 'xai-speech2txt']),
);
});
});
+10 -11
View File
@@ -3,18 +3,19 @@
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
* Puter is free software: you can redistribute it and/or modify it under the
* terms of the GNU Affero General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option) any
* later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
* along with this program. If not, see
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
*/
import { ChatCompletionDriver } from './ai-chat/ChatCompletionDriver';
@@ -22,7 +23,6 @@ import { ImageGenerationDriver } from './ai-image/ImageGenerationDriver';
import { OCRDriver } from './ai-ocr/OCRDriver';
import { VoiceChangerDriver } from './ai-speech2speech/VoiceChangerDriver';
import { SpeechToTextDriver } from './ai-speech2txt/SpeechToTextDriver';
import { XAISpeechToTextDriver } from './ai-speech2txt/XAISpeechToTextDriver';
import { TTSDriver } from './ai-tts/TTSDriver';
import { VideoGenerationDriver } from './ai-video/VideoGenerationDriver';
import { AppDriver } from './apps/AppDriver.js';
@@ -43,7 +43,6 @@ export const puterDrivers = {
aiVideo: VideoGenerationDriver,
aiSpeech2Speech: VoiceChangerDriver,
aiSpeech2Txt: SpeechToTextDriver,
aiSpeech2TxtXai: XAISpeechToTextDriver,
aiOcr: OCRDriver,
apps: AppDriver,
subdomains: SubdomainDriver,
+1 -1
View File
@@ -23,7 +23,7 @@ describe('KVStoreDriver', () => {
});
afterAll(async () => {
await server.shutdown();
await server?.shutdown();
});
// Each test runs against a unique actor namespace so state from one test
@@ -3,18 +3,19 @@
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
* Puter is free software: you can redistribute it and/or modify it under the
* terms of the GNU Affero General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option) any
* later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
* along with this program. If not, see
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
*/
import { Context } from '../../core/context.js';
@@ -34,8 +35,8 @@ import { PuterService } from '../types.js';
* `subdomains-of-user:*`, and `app-root-dir:*` namespaces.
*
* Ports three v1 services (ProtectedAppService, AppPermissionService, the
* app-root-dir arm of AppService) into one domain-scoped service. Nothing
* here needs to live beyond init — the registrations are stateless.
* app-root-dir arm of AppService) into one domain-scoped service. Nothing here
* needs to live beyond init — the registrations are stateless.
*/
export class AppPermissionService extends PuterService {
declare protected stores: LayerInstances<typeof puterStores>;
@@ -119,8 +120,10 @@ export class AppPermissionService extends PuterService {
});
// -- app-root-dir:<app_uid>:<mode> → fs:<root_uid>:<mode> -------
// Only rewrites during an explicit `grantUserAppPermission` (see
// PermissionService for the context flag). During scans we return
// Only rewrites while a user-app permission row is being written or
// removed — `grantUserAppPermission` / `revokeUserAppPermission`, which
// both set the context flag (see PermissionService) precisely so the
// revoke names the row the grant wrote. During scans we return
// PERMISSION_FOR_NOTHING_IN_PARTICULAR so `check(actor, 'app-root-dir:…')`
// never accidentally matches through the fs-permission path.
permissions.registerRewriter({
@@ -207,11 +210,11 @@ export class AppPermissionService extends PuterService {
* subdomain out of `app.index_url` and looking up the matching subdomain
* row.
*
* v1 first consulted `subdomains.associated_app_id` for a direct
* binding — that column was user-writable without an ownership check,
* so trusting it let any user point an arbitrary app's "root dir" at
* their own subdomain. The column is no longer authoritative; the
* `index_url`-derived lookup below is the only path.
* V1 first consulted `subdomains.associated_app_id` for a direct binding —
* that column was user-writable without an ownership check, so trusting it
* let any user point an arbitrary app's "root dir" at their own subdomain.
* The column is no longer authoritative; the `index_url`-derived lookup
* below is the only path.
*/
async #resolveAppRootDirId(app: {
id: number;
@@ -77,7 +77,7 @@ describe('MeteringService', () => {
originalShardCount;
(target.constructor as typeof MeteringService).APP_SHARD_COUNT =
originalShardCount;
await server.shutdown();
await server?.shutdown();
});
// Each test uses a fresh user so KV state from one test never leaks into
@@ -3,23 +3,24 @@
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
* Puter is free software: you can redistribute it and/or modify it under the
* terms of the GNU Affero General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option) any
* later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
* along with this program. If not, see
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
*/
import type { Actor } from '../../core/actor';
import { actorUid, isSystemActor, userRelatedActor } from '../../core/actor';
import { Context } from '../../core/context';
import { Context, runWithContext } from '../../core/context';
import { HttpError } from '../../core/http/HttpError.js';
import { Span } from '../../util/span.js';
import { PuterService } from '../types';
@@ -46,6 +47,12 @@ import {
} from '../../data/hardcoded-permissions.js';
import { UserRow } from '../../stores/user/UserStore';
/**
* Width of the `permission` column in the permission tables, which every
* dialect declares as `varchar(255)`.
*/
const PERMISSION_MAX_LEN = 255;
// -- Types ------------------------------------------------------------
export interface ScanOptions {
@@ -61,10 +68,13 @@ export interface GrantMeta {
}
/**
* PermissionService owns the *semantics* side of permissions:
* PermissionService owns the _semantics_ side of permissions:
*
* - The rule registries (rewriters, implicators, exploders)
* - The `scan()` algorithm that traverses all the ways an actor might hold a permission
* - grant/revoke orchestration (rewrite → canManage → store writes → cache invalidation)
* - The `scan()` algorithm that traverses all the ways an actor might hold a
* permission
* - Grant/revoke orchestration (rewrite → canManage → store writes → cache
* invalidation)
*
* All persistence is delegated to PermissionStore.
*/
@@ -102,8 +112,8 @@ export class PermissionService extends PuterService {
}
/**
* Grant a permission (as issued by `system`) to everyone — members of
* both the default user group and the default temp group.
* Grant a permission (as issued by `system`) to everyone — members of both
* the default user group and the default temp group.
*
* Call from an owning service's `onServerStart` (or later).
*/
@@ -143,7 +153,10 @@ export class PermissionService extends PuterService {
return permission;
}
/** Return the given permission plus all parents and their exploder expansions. */
/**
* Return the given permission plus all parents and their exploder
* expansions.
*/
async getHigherPermissions(permission: string): Promise<string[]> {
const higher = new Set<string>();
higher.add(permission);
@@ -187,10 +200,9 @@ export class PermissionService extends PuterService {
}
/**
* Batch sibling of `check`. Returns a `Map<permission, boolean>`
* answering "does `actor` hold each of these permissions?" with one
* Redis MGET for cached decisions and per-permission evaluation for
* misses.
* Batch sibling of `check`. Returns a `Map<permission, boolean>` answering
* "does `actor` hold each of these permissions?" with one Redis MGET for
* cached decisions and per-permission evaluation for misses.
*
* `scan(actor, string[])` is intentionally an OR-style API: callers ask
* "does any option match?". That makes it unsafe to infer independent
@@ -520,7 +532,8 @@ export class PermissionService extends PuterService {
* Resolve permissions that a persistent group's members inherit from an
* issuer (typically `system`) via the hardcoded map in
* `hardcoded-permissions.js`, merged with any runtime grants registered
* through `registerSystemGrantForEveryone` / `registerSystemGrantForUsers`.
* through `registerSystemGrantForEveryone` /
* `registerSystemGrantForUsers`.
*/
async #scanHcUserGroupUser(
actor: Actor,
@@ -749,8 +762,8 @@ export class PermissionService extends PuterService {
/**
* Resolves user-to-user permissions for an actor across the given
* permission strings. Prefers the "flat" KV view when present; otherwise
* falls back to a SQL traversal of `user_to_user_permissions` and
* warms the flat KV cache as a side-effect.
* falls back to a SQL traversal of `user_to_user_permissions` and warms the
* flat KV cache as a side-effect.
*/
async validateUserPerms({
actor,
@@ -1010,6 +1023,42 @@ export class PermissionService extends PuterService {
if (user.uuid) await this.#bumpUserCacheGeneration(user.uuid);
}
/**
* Rewrite a permission on its way into (or out of) a user-app row.
*
* Flags the context so the app-root-dir rewriter knows it's safe to resolve
* the pseudo-permission to a real `fs:<uid>:<mode>`. During scans
* (ACL.check) that rewriter returns PERMISSION_FOR_NOTHING_IN_PARTICULAR so
* `scan(actor, 'app-root-dir:…')` never accidentally matches through the fs
* path.
*
* Revoke goes through here too, and must: it has to name the same string
* the grant stored. Rewriting without the flag resolved the sentinel
* instead, so the DELETE matched nothing and reported success while the fs
* permission stayed live — including when the permission dialog withdraws a
* grant whose outcome it couldn't confirm, so a user who answered "Don't
* Allow" kept it. (The flag's name predates that; it now covers both
* writes.)
*/
async #rewriteForUserAppWrite(permission: string): Promise<string> {
// A caller outside a request scope (an internal job, a direct unit
// test) still needs the flag set, or its write resolves differently
// from the paired one — and `Context.set` has nothing to set it on.
// An empty scope reads the same as no scope, every other lookup still
// missing, so this only makes the flag settable.
if (!Context.current()) {
return runWithContext({}, () =>
this.#rewriteForUserAppWrite(permission),
);
}
Context.set('is_grant_user_app_permission', true);
try {
return await this.rewritePermission(permission);
} finally {
Context.set('is_grant_user_app_permission', false);
}
}
async grantUserAppPermission(
actor: Actor,
appIdentifier: string,
@@ -1017,16 +1066,16 @@ export class PermissionService extends PuterService {
extra: Record<string, unknown> = {},
meta: GrantMeta = {},
): Promise<void> {
// Flag the context so the app-root-dir rewriter knows it's safe to
// resolve the pseudo-permission to a real `fs:<uid>:<mode>`. During
// scans (ACL.check) the rewriter returns PERMISSION_FOR_NOTHING_IN_PARTICULAR
// so `scan(actor, 'app-root-dir:…')` never accidentally matches
// through the fs path.
Context.set('is_grant_user_app_permission', true);
try {
permission = await this.rewritePermission(permission);
} finally {
Context.set('is_grant_user_app_permission', false);
permission = await this.#rewriteForUserAppWrite(permission);
// Checked after the rewrite, because the rewrite is what decides how
// wide the row actually is: `fs:/deep/path:read` collapses to
// `fs:<uuid>:read`. Reject here rather than let an oversized string
// reach the INSERT, where MySQL/Postgres fault after the caller has
// been told nothing and SQLite silently stores an unmatchable row.
if (permission.length > PERMISSION_MAX_LEN) {
throw new HttpError(400, 'Invalid `permission`', {
legacyCode: 'bad_request',
});
}
const app = await this.stores.app.resolveApp(appIdentifier);
if (!app)
@@ -1075,11 +1124,14 @@ export class PermissionService extends PuterService {
permission: string,
meta: GrantMeta = {},
): Promise<void> {
permission = await this.rewritePermission(permission);
// Before the rewrite: the pseudo-permission resolvers it runs refuse an
// app actor themselves, and this says why in the caller's own terms.
if (actor.app)
throw new HttpError(403, 'actor must be a user', {
legacyCode: 'forbidden',
});
// The same rewrite the grant used, so this names the row it wrote.
permission = await this.#rewriteForUserAppWrite(permission);
const app = await this.stores.app.resolveApp(appIdentifier);
if (!app)
throw new HttpError(404, `entity_not_found: app:${appIdentifier}`, {
@@ -1426,11 +1478,11 @@ export class PermissionService extends PuterService {
// those remain bounded by the scan-cache TTL.
/**
* Generation keys whose counters this actor's cached readings depend
* on. A derived actor (app-under-user, access-token) acts through its
* user — its readings embed that user's reading via the recursive
* issuer scan — so the user's counter is folded into its cache keys.
* A plain user actor depends only on its own counter.
* Generation keys whose counters this actor's cached readings depend on. A
* derived actor (app-under-user, access-token) acts through its user — its
* readings embed that user's reading via the recursive issuer scan — so the
* user's counter is folded into its cache keys. A plain user actor depends
* only on its own counter.
*/
#cacheGenerationKeys(actor: Actor): string[] {
const keys = [actorUid(actor)];
@@ -1448,11 +1500,11 @@ export class PermissionService extends PuterService {
}
/**
* Cache-generation tag for an actor: the single counter value for a
* plain user actor (key format unchanged: `g<n>`), or the dependent
* counters joined with '.' for derived actors (e.g. `g<own>.<user>`).
* Joined rather than summed so distinct counter states can never
* collide on the same tag.
* Cache-generation tag for an actor: the single counter value for a plain
* user actor (key format unchanged: `g<n>`), or the dependent counters
* joined with '.' for derived actors (e.g. `g<own>.<user>`). Joined rather
* than summed so distinct counter states can never collide on the same
* tag.
*/
async #cacheGenerationTag(actor: Actor): Promise<number | string> {
const keys = this.#cacheGenerationKeys(actor);
@@ -1481,9 +1533,9 @@ export class PermissionService extends PuterService {
}
/**
* Bump every current member of a group. Used when a group grant
* changes — each member's readings may have resolved through the group,
* so each member's `user:<uuid>` cache must be orphaned.
* Bump every current member of a group. Used when a group grant changes —
* each member's readings may have resolved through the group, so each
* member's `user:<uuid>` cache must be orphaned.
*/
async #bumpGroupMembersCacheGeneration(groupUid: string): Promise<void> {
const memberUuids =
@@ -1494,11 +1546,10 @@ export class PermissionService extends PuterService {
}
/**
* Public: bump the permission cache for a set of users by username.
* Used by group add/remove-users — membership changes a user's
* effective permissions, so their cached readings must be orphaned
* (a removed user must lose the group's grants on their next check,
* not after the TTL).
* Public: bump the permission cache for a set of users by username. Used by
* group add/remove-users — membership changes a user's effective
* permissions, so their cached readings must be orphaned (a removed user
* must lose the group's grants on their next check, not after the TTL).
*/
async bumpPermissionCacheForUsernames(usernames: string[]): Promise<void> {
const unique = Array.from(new Set(usernames.filter(Boolean)));
@@ -12,7 +12,7 @@ beforeAll(async () => {
});
afterAll(async () => {
await server.shutdown();
await server?.shutdown();
});
describe('DefaultUserService — bootstrap admin credentials', () => {
+46 -20
View File
@@ -109,6 +109,8 @@ const READ_ONLY_COLUMNS = new Set([
'protected',
'is_private',
]);
// Unique-key columns `#getManyByProperty` may build an IN-list against.
const BATCH_LOOKUP_COLUMNS = new Set(['id', 'uid']);
const APP_BOOLEAN_COLUMNS = new Set([
'godmode',
'maximize_on_start',
@@ -134,66 +136,90 @@ export class AppStore extends PuterStore {
}
/**
* Batched lookup by id. Dedupes input ids, reads cache via a pipelined
* MGET, and resolves remaining misses with a single `SELECT … WHERE id IN
* (…)` per chunk. Use this in place of `Promise.all(ids.map(getById))` to
* avoid one connection per row on large id sets.
* Batched lookup by id. Use this in place of `Promise.all(ids.map(
* getById))` to avoid one connection per row on large id sets.
*
* Missing ids (no DB row) are simply absent from the returned map.
*/
async getByIds(ids) {
return this.#getManyByProperty('id', ids);
}
/**
* Batched lookup by uid — the uid-keyed twin of `getByIds`, for callers
* that hold uids (recent-opens lists, launch metadata).
*
* Missing uids (no DB row) are simply absent from the returned map.
*/
async getByUids(uids) {
return this.#getManyByProperty('uid', uids);
}
/**
* Shared engine behind `getByIds` / `getByUids`. Dedupes input values,
* reads cache via a pipelined MGET, and resolves remaining misses with a
* single `SELECT … WHERE <prop> IN (…)` per chunk. The returned map is
* keyed by `prop`'s value.
*/
async #getManyByProperty(prop, values) {
// `prop` is interpolated into SQL, so it may only ever be one of the
// unique-key columns this store owns — never caller-supplied.
if (!BATCH_LOOKUP_COLUMNS.has(prop)) {
throw new Error(`AppStore: unsupported batch lookup key ${prop}`);
}
const result = new Map();
const uniqueIds = [
const uniqueValues = [
...new Set(
(Array.isArray(ids) ? ids : []).filter(
(id) => id !== null && id !== undefined,
(Array.isArray(values) ? values : []).filter(
(value) => value !== null && value !== undefined,
),
),
];
if (uniqueIds.length === 0) return result;
if (uniqueValues.length === 0) return result;
const missingIds = [];
const missingValues = [];
try {
const pipeline = this.clients.redis.pipeline();
for (const id of uniqueIds) {
pipeline.get(this.#cacheKey('id', id));
for (const value of uniqueValues) {
pipeline.get(this.#cacheKey(prop, value));
}
const cacheResults = (await pipeline.exec()) ?? [];
for (let i = 0; i < uniqueIds.length; i++) {
const id = uniqueIds[i];
for (let i = 0; i < uniqueValues.length; i++) {
const value = uniqueValues[i];
const raw = cacheResults[i]?.[1];
if (typeof raw === 'string') {
try {
result.set(id, JSON.parse(raw));
result.set(value, JSON.parse(raw));
continue;
} catch {
// Fall through to DB on any parse failure.
}
}
missingIds.push(id);
missingValues.push(value);
}
} catch {
missingIds.push(...uniqueIds);
missingValues.push(...uniqueValues);
}
for (
let offset = 0;
offset < missingIds.length;
offset < missingValues.length;
offset += BULK_QUERY_CHUNK_SIZE
) {
const chunk = missingIds.slice(
const chunk = missingValues.slice(
offset,
offset + BULK_QUERY_CHUNK_SIZE,
);
const placeholders = chunk.map(() => '?').join(', ');
const rows = await this.clients.db.read(
`SELECT * FROM \`apps\` WHERE \`id\` IN (${placeholders})`,
`SELECT * FROM \`apps\` WHERE \`${prop}\` IN (${placeholders})`,
chunk,
);
for (const row of rows) {
const app = this.#normalizeRow(row);
if (!app) continue;
result.set(app.id, app);
result.set(app[prop], app);
this.#writeCache(app).catch(() => {});
}
}
+60
View File
@@ -162,3 +162,63 @@ describe('AppStore app stats (cache-on-read)', () => {
}
});
});
describe('AppStore batched lookups', () => {
let server;
let appStore;
beforeAll(async () => {
server = await setupTestServer();
appStore = server.stores.app;
});
afterAll(async () => {
await server?.shutdown();
});
const createApp = async (title) => {
const name = `batch-${Math.random().toString(36).slice(2, 10)}`;
return appStore.create(
{ name, title, index_url: `https://${name}.example.com/` },
{ ownerUserId: 1 },
);
};
it('getByUids resolves every known uid in one map', async () => {
const a = await createApp('Alpha');
const b = await createApp('Beta');
const found = await appStore.getByUids([a.uid, b.uid]);
expect(found.get(a.uid)?.title).toBe('Alpha');
expect(found.get(b.uid)?.title).toBe('Beta');
});
it('getByUids omits unknown uids rather than returning holes', async () => {
const a = await createApp('Alpha');
const found = await appStore.getByUids([a.uid, 'app-does-not-exist']);
expect(found.has(a.uid)).toBe(true);
expect(found.has('app-does-not-exist')).toBe(false);
expect(found.size).toBe(1);
});
it('getByUids dedupes repeats and tolerates empty/nullish input', async () => {
const a = await createApp('Alpha');
const found = await appStore.getByUids([a.uid, a.uid, null, undefined]);
expect(found.size).toBe(1);
expect((await appStore.getByUids([])).size).toBe(0);
expect((await appStore.getByUids(null)).size).toBe(0);
});
it('getByIds still resolves id-keyed lookups off the shared engine', async () => {
const a = await createApp('Alpha');
const found = await appStore.getByIds([a.id]);
expect(found.get(a.id)?.title).toBe('Alpha');
});
});
@@ -0,0 +1,136 @@
/**
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify it under the
* terms of the GNU Affero General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option) any
* later version.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
*/
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { v4 as uuidv4 } from 'uuid';
import { PuterServer } from '../../server.js';
import { setupTestServer } from '../../testUtil.js';
import type { SubdomainStore } from './SubdomainStore.js';
// ── Test harness ────────────────────────────────────────────────────
//
// Boots one PuterServer (in-memory sqlite + mock redis) and drives the
// live SubdomainStore. These cases are about the read/write cache
// interleaving specifically — the driver-level behaviour is covered by
// SubdomainDriver.test.ts.
let server: PuterServer;
let store: SubdomainStore;
beforeAll(async () => {
server = await setupTestServer();
store = server.stores.subdomain as unknown as SubdomainStore;
});
afterAll(async () => {
await server?.shutdown();
});
const makeUser = async (): Promise<number> => {
const username = `sds-${Math.random().toString(36).slice(2, 10)}`;
const created = await server.stores.user.create({
username,
uuid: uuidv4(),
password: null,
email: `${username}@test.local`,
free_storage: 100 * 1024 * 1024,
requires_email_confirmation: false,
});
return created.id;
};
/** Mirrors `SubdomainStore.#cacheKey` — the read path keys rows by name. */
const cacheKey = (subdomain: string) => `subdomains:name:${subdomain}`;
describe('SubdomainStore cache coherency', () => {
it('a cache-miss read cannot resurrect a pre-update row', async () => {
const userId = await makeUser();
const subdomain = `sds-clobber-${Math.random().toString(36).slice(2, 8)}`;
const row = await store.create({ userId, subdomain, rootDirId: 111 });
// Force the next lookup down the cache-miss path, which is where the
// read populates the cache.
await server.clients.redis.del(cacheKey(subdomain));
// Stall just the read path's own `set`. The update path writes
// through `pipeline()`, so this leaves it untouched and lets the
// read's write land *after* the update's — the ordering a real
// Redis round-trip can produce under load.
let releaseReadWrite: (() => void) | undefined;
const stalled = new Promise<void>((resolve) => {
releaseReadWrite = resolve;
});
const realSet = server.clients.redis.set.bind(server.clients.redis);
const setSpy = vi
.spyOn(server.clients.redis, 'set')
.mockImplementationOnce(async (...args: unknown[]) => {
await stalled;
return realSet(
...(args as Parameters<typeof realSet>),
);
});
try {
// Read observes root_dir_id 111 and queues a cache write for it.
const readBack = await store.getBySubdomain(subdomain);
expect(readBack?.root_dir_id).toBe(111);
// Update lands (and refreshes the cache) while that write is
// still in flight.
await store.update(row.uuid, { root_dir_id: 222 }, { userId });
// Now let the stale write through.
releaseReadWrite!();
await vi.waitFor(() => expect(setSpy).toHaveBeenCalledTimes(1));
// Drain the mocked write's continuation.
await new Promise((r) => setImmediate(r));
const after = await store.getBySubdomain(subdomain);
expect(after?.root_dir_id).toBe(222);
} finally {
releaseReadWrite!();
setSpy.mockRestore();
}
});
it('update is visible to the next cache-backed read', async () => {
const userId = await makeUser();
const subdomain = `sds-update-${Math.random().toString(36).slice(2, 8)}`;
const row = await store.create({ userId, subdomain, rootDirId: 1 });
await store.getBySubdomain(subdomain);
await store.update(row.uuid, { root_dir_id: 2 }, { userId });
const after = await store.getBySubdomain(subdomain);
expect(after?.root_dir_id).toBe(2);
});
it('delete leaves a negative marker rather than a stale row', async () => {
const userId = await makeUser();
const subdomain = `sds-delete-${Math.random().toString(36).slice(2, 8)}`;
const row = await store.create({ userId, subdomain, rootDirId: 3 });
await store.getBySubdomain(subdomain);
await store.deleteByUuid(row.uuid, { userId });
expect(await store.getBySubdomain(subdomain)).toBeNull();
});
});
+46 -25
View File
@@ -3,18 +3,19 @@
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
* Puter is free software: you can redistribute it and/or modify it under the
* terms of the GNU Affero General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option) any
* later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
* along with this program. If not, see
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
*/
import { v4 as uuidv4 } from 'uuid';
@@ -115,10 +116,10 @@ export class SubdomainStore extends PuterStore {
/**
* Pass `primary: true` for read-after-write lookups (e.g. checking a
* subdomain that may have been created moments ago): it skips the
* cache — which may hold a stale negative marker — and reads the
* primary instead of a possibly-lagging replica. The result still
* refreshes the cache, healing any stale marker.
* subdomain that may have been created moments ago): it skips the cache —
* which may hold a stale negative marker — and reads the primary instead of
* a possibly-lagging replica. The result still refreshes the cache, healing
* any stale marker.
*/
async getBySubdomain(
subdomain: string,
@@ -146,19 +147,30 @@ export class SubdomainStore extends PuterStore {
: await this.clients.db.read(sql, [subdomain]);
const row = (rows[0] as unknown as SubdomainRow | undefined) ?? null;
// These writes are fire-and-forget, so a mutation that lands between
// the SELECT above and the SET below would otherwise be overwritten
// by the row we just read — stranding the pre-write row in cache for
// the full TTL (an hour of a site serving its old root_dir after
// `hosting.update`). A replica read is not authoritative, so it only
// *populates* an absent key (`NX`) and can never clobber a fresher
// write. A `primary` read is read-after-write on the primary, so it
// writes through — that's what heals a stale negative marker.
const populate = (value: string, ttlSeconds: number) =>
(primary
? this.clients.redis.set(cacheKey, value, 'EX', ttlSeconds)
: this.clients.redis.set(
cacheKey,
value,
'EX',
ttlSeconds,
'NX',
)
).catch(() => {});
if (row) {
this.clients.redis
.set(cacheKey, JSON.stringify(row), 'EX', CACHE_TTL_SECONDS)
.catch(() => {});
populate(JSON.stringify(row), CACHE_TTL_SECONDS);
} else {
this.clients.redis
.set(
cacheKey,
NEGATIVE_CACHE_MARKER,
'EX',
NEGATIVE_CACHE_TTL_SECONDS,
)
.catch(() => {});
populate(NEGATIVE_CACHE_MARKER, NEGATIVE_CACHE_TTL_SECONDS);
}
return row;
}
@@ -375,7 +387,16 @@ export class SubdomainStore extends PuterStore {
// -- Writes -------------------------------------------------------
/** @param {{ userId: number, subdomain: string, rootDirId?: number|null, associatedAppId?: number|null, appOwner?: number|null, preambleVersion?: string|null }} opts */
/**
* @param {{
* userId: number;
* subdomain: string;
* rootDirId?: number | null;
* associatedAppId?: number | null;
* appOwner?: number | null;
* preambleVersion?: string | null;
* }} opts
*/
async create({
userId,
subdomain,
@@ -14,7 +14,7 @@ describe('SystemKVStore', () => {
});
afterAll(async () => {
await server.shutdown();
await server?.shutdown();
});
// Each test runs against a fresh actor namespace so state from one test
+91
View File
@@ -0,0 +1,91 @@
import { describe, expect, it } from 'vitest';
import { toAppShellView } from './appShellView.js';
const rawRow = () => ({
id: 42,
uid: 'app-1234',
name: 'cool-app',
title: 'Cool App',
description: 'does things',
icon: 'data:image/png;base64,AAA',
index_url: 'https://cool-app.site.example.com/',
owner_user_id: 7,
background: 1,
maximize_on_start: 0,
godmode: 1,
is_private: 0,
protected: 1,
approved_for_listing: 1,
approved_for_opening_items: 0,
approved_for_incentive_program: 1,
metadata: { social_image: 'https://example.com/og.png' },
created_at: 1700000000,
});
describe('toAppShellView', () => {
it('returns null for a missing row', () => {
expect(toAppShellView(null)).toBeNull();
expect(toAppShellView(undefined)).toBeNull();
});
it('never carries the launch URL or ownership/internal columns', () => {
const view = toAppShellView(rawRow()) as Record<string, unknown>;
for (const leaked of ['index_url', 'owner_user_id', 'id']) {
expect(view).not.toHaveProperty(leaked);
}
});
it('exposes exactly the agreed field set', () => {
const view = toAppShellView(rawRow()) as Record<string, unknown>;
expect(Object.keys(view).sort()).toEqual(
[
'approved_for_incentive_program',
'approved_for_listing',
'approved_for_opening_items',
'background',
'created_at',
'description',
'godmode',
'icon',
'is_private',
'maximize_on_start',
'metadata',
'name',
'protected',
'title',
'uid',
].sort(),
);
});
it('normalizes flag columns to booleans', () => {
const view = toAppShellView(rawRow())!;
expect(view.background).toBe(true);
expect(view.maximize_on_start).toBe(false);
expect(view.godmode).toBe(true);
expect(view.is_private).toBe(false);
expect(view.protected).toBe(true);
expect(view.approved_for_listing).toBe(true);
expect(view.approved_for_opening_items).toBe(false);
expect(view.approved_for_incentive_program).toBe(true);
});
it('falls back to the legacy `timestamp` column for created_at', () => {
const { created_at: _dropped, ...withoutCreatedAt } = rawRow();
const view = toAppShellView({
...withoutCreatedAt,
timestamp: 1650000000,
})!;
expect(view.created_at).toBe(1650000000);
});
it('defaults absent metadata to null rather than undefined', () => {
const { metadata: _dropped, ...withoutMetadata } = rawRow();
expect(toAppShellView(withoutMetadata)!.metadata).toBeNull();
});
});
+95
View File
@@ -0,0 +1,95 @@
/**
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify it under the
* terms of the GNU Affero General Public License as published by the Free
* Software Foundation, either version 3 of the License, or (at your option) any
* later version.
*
* This program is distributed in the hope that it will be useful, but WITHOUT
* ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
* FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
* details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see
* [https://www.gnu.org/licenses/](https://www.gnu.org/licenses/).
*/
/**
* The app projection baked into the rendered GUI shell for `/app/:name`.
*
* This is a _preview_ — page metadata and enough app identity for the boot
* script to know what it is about to open. It is deliberately not the launch
* authority: the GUI re-reads the app through the apps driver before it
* launches anything, and that read is where the private-app entitlement gate
* and the hosted-backing guard run.
*
* Because of that split, this projection omits `index_url` entirely rather than
* gating it. Nothing in the shell can launch from it, so shipping a launch URL
* in server-rendered HTML — to an anonymous reader, cached by intermediaries,
* visible in view-source — buys nothing and risks leaking a private app's
* hosting URL. Omitting it also keeps this function free of I/O: gating
* `index_url` is what forced the origin-canonicalization and
* subdomain-ownership lookups, and those are pure cost on a request whose job
* is to return HTML.
*
* Raw store rows must never reach the shell: they carry `owner_user_id`,
* moderation flags, and the private `index_url`.
*/
/** A raw `apps` store row. */
type AppRow = Record<string, unknown>;
/** The safe field subset embedded in the shell. */
export interface AppShellView {
uid: unknown;
name: unknown;
title: unknown;
description: unknown;
icon: unknown;
background: boolean;
maximize_on_start: boolean;
godmode: boolean;
is_private: boolean;
protected: boolean;
approved_for_listing: boolean;
approved_for_opening_items: boolean;
approved_for_incentive_program: boolean;
metadata: unknown;
created_at: unknown;
}
/**
* Project a raw app row down to the fields the shell may embed.
*
* Pure and synchronous by design — see the module note above. Returns null for
* a missing row so callers can pass a lookup result straight through.
*/
export function toAppShellView(
app: AppRow | null | undefined,
): AppShellView | null {
if (!app) return null;
return {
uid: app.uid,
name: app.name,
title: app.title,
description: app.description,
icon: app.icon,
background: Boolean(app.background),
maximize_on_start: Boolean(app.maximize_on_start),
godmode: Boolean(app.godmode),
is_private: Boolean(app.is_private),
protected: Boolean(app.protected),
approved_for_listing: Boolean(app.approved_for_listing),
approved_for_opening_items: Boolean(app.approved_for_opening_items),
approved_for_incentive_program: Boolean(
app.approved_for_incentive_program,
),
metadata: app.metadata ?? null,
created_at: app.created_at ?? app.timestamp,
};
}
+1 -1
View File
@@ -30,7 +30,7 @@ Additional settings for the OCR request. Available options depend on the provide
| Option | Type | Description |
|--------|------|-------------|
| `provider` | `String` | The OCR backend to use. `'aws-textract'` (default) \| `'mistral'` |
| `provider` | `String` | The OCR backend to use. `'aws-textract'` (default) \| `'mistral'`. Aliases `'aws'`, `'textract'` and `'mistral-ocr'` are also accepted; anything else is rejected with a `bad_request` error |
| `model` | `String` | OCR model to use (provider-specific) |
| `testMode` | `Boolean` | When `true`, returns a sample response without using credits. Defaults to `false` |
+1 -1
View File
@@ -32,7 +32,7 @@ When you omit `source`, supply `options.file` or `options.audio` instead.
Fine-tune how transcription runs.
- `file` / `audio` (String | File | Blob): Alternative way to pass the audio input.
- `provider` (String): STT provider to use. `'openai'` (default) or `'xai'`.
- `provider` (String): STT provider to use. `'openai'` (default) or `'xai'`. Aliases `'whisper'`, `'grok'` and `'x-ai'` are also accepted; anything else is rejected with a `bad_request` error.
- `model` (String): One of `gpt-4o-mini-transcribe`, `gpt-4o-transcribe`, `gpt-4o-transcribe-diarize`, `whisper-1`, or any future backend-supported model. Defaults to `gpt-4o-mini-transcribe` for transcription and `whisper-1` for translation.
- `translate` (Boolean): Set to `true` to force English output (uses the translations endpoint).
- `response_format` (String): Desired output shape. Examples: `json`, `text`, `diarized_json`, `srt`, `verbose_json`, `vtt` (depends on the model).
+3 -3
View File
@@ -20,15 +20,15 @@ puter.ai.txt2speech.listEngines(options)
A provider name to query. When passed as a string, this is shorthand for `{ provider }`. Defaults to `'aws-polly'`.
Accepted values: `'aws-polly'`, `'openai'`, `'elevenlabs'`, `'gemini'`, `'xai'`
Accepted values: `'aws-polly'`, `'openai'`, `'elevenlabs'`, `'gemini'`, `'xai'`, or `'all'` to list every provider at once.
Common aliases are also accepted (e.g. `'eleven'`, `'google'`, `'grok'`).
Common aliases are also accepted (e.g. `'eleven'`, `'google'`, `'grok'`). An unrecognized provider is rejected with a `bad_request` error.
#### `options` (Object) (optional)
| Option | Type | Description |
|--------|------|-------------|
| `provider` | `String` | TTS provider to query. Defaults to `'aws-polly'` |
| `provider` | `String` | TTS provider to query. Defaults to `'aws-polly'`; `'all'` returns every provider's engines |
## Return value
+4 -2
View File
@@ -19,16 +19,18 @@ puter.ai.txt2speech.listVoices(options)
| Option | Type | Description |
|--------|------|-------------|
| `provider` | `String` | TTS provider to query. Defaults to `'aws-polly'`. Accepted: `'aws-polly'`, `'openai'`, `'elevenlabs'`, `'gemini'`, `'xai'` |
| `provider` | `String` | TTS provider to query. Defaults to `'aws-polly'`. Accepted: `'aws-polly'`, `'openai'`, `'elevenlabs'`, `'gemini'`, `'xai'`, or `'all'` to list every provider at once. Common aliases are also accepted (e.g. `'eleven'`, `'google'`, `'grok'`). |
| `engine` | `String` | Engine/model filter (provider-specific, ignored by some providers) |
When `options` is a plain string it is treated as an `engine` filter for the default (AWS Polly) provider.
An unrecognized `provider` is rejected with a `bad_request` error.
## Return value
A `Promise` that resolves to an array of [`TTSVoice`](/Objects/ttsvoice) objects.
Example response:
Example response (with `provider: 'all'`):
```json
[
+37 -1
View File
@@ -32,7 +32,7 @@ Additional settings for the generation request. Available options depend on the
| Option | Type | Description |
|--------|------|-------------|
| `provider` | `String` | TTS provider to use. `'aws-polly'` (default), `'openai'`, `'elevenlabs'`, `'gemini'`, `'xai'` |
| `provider` | `String` | TTS provider to use. `'aws-polly'` (default), `'openai'`, `'elevenlabs'`, `'gemini'`, `'xai'`, `'speechify'`. Common aliases (`'eleven'`, `'google'`, `'grok'`, `'polly'`, `'simba'`, …) are also accepted; anything else is rejected with a `bad_request` error |
| `model` | `String` | Model identifier (provider-specific) |
| `voice` | `String` | Voice ID used for synthesis (provider-specific) |
| `test_mode` | `Boolean` | When `true`, returns a sample audio without using credits |
@@ -100,6 +100,18 @@ Text supports inline speech tags like `[pause]`, `[laugh]` and wrapping tags lik
For more details, see the [xAI TTS documentation](https://x.ai/news/grok-stt-and-tts-apis).
#### Speechify Options
Available when `provider: 'speechify'`:
| Option | Type | Description |
|--------|------|-------------|
| `model` | `String` | TTS model. Available: `'simba-3.2'` (default), `'simba-english'`, `'simba-multilingual'` |
| `voice` | `String` | Voice ID. Available: `'geffen_32'` (default), `'dominic_32'`, `'harper_32'`, `'hugh_32'`, `'imogen_32'` |
| `output_format` | `String` | Output format. Available: `'mp3'` (default), `'wav'`, `'ogg'`, `'aac'` |
For more details, see the [Speechify API documentation](https://docs.speechify.ai/).
## Return value
A `Promise` that resolves to an `HTMLAudioElement`. The element’s `src` points at a blob or remote URL containing the synthesized audio.
@@ -246,6 +258,30 @@ A `Promise` that resolves to an `HTMLAudioElement`. The element’s `src` points
</html>
```
<strong class="example-title">Use Speechify voices</strong>
```html;ai-txt2speech-speechify
<html>
<body>
<script src="https://js.puter.com/v2/"></script>
<button id="play">Use Speechify voice</button>
<script>
document.getElementById('play').addEventListener('click', async ()=>{
const audio = await puter.ai.txt2speech(
"Hello! This sample uses the Speechify Geffen voice.",
{
provider: "speechify",
model: "simba-3.2",
voice: "geffen_32"
}
);
audio.play();
});
</script>
</body>
</html>
```
<strong class="example-title">Compare different engines</strong>
```html;ai-txt2speech-engines
+1
View File
@@ -186,6 +186,7 @@ These Apps API are supported out of the box when using Puter.js:
- **[`puter.apps.delete()`](/Apps/delete/)** - Delete an application
- **[`puter.apps.update()`](/Apps/update/)** - Update application settings
- **[`puter.apps.get()`](/Apps/get/)** - Get information about a specific application
- **[`puter.apps.checkName()`](/Apps/checkName/)** - Check whether an app name is available
## Examples
+52
View File
@@ -0,0 +1,52 @@
---
title: puter.apps.checkName()
description: Check whether an app name is still available.
platforms: [websites, apps, nodejs, workers]
---
Checks whether an app name is available to you, without creating anything. Useful before calling [`puter.apps.create()`](/Apps/create/), which rejects when the name is already taken.
## Syntax
```js
puter.apps.checkName(name)
```
## Parameters
#### `name` (String) (required)
The app name to check. Rejects with an `invalid_request` error when it is missing or empty.
## Return value
A `Promise` that will resolve to an object describing the name's availability.
## Examples
<strong class="example-title">Check a name before creating the app</strong>
```html
<html>
<body>
<script src="https://js.puter.com/v2/"></script>
<script>
(async () => {
const appName = puter.randName();
// (1) A freshly generated name is available
puter.print(JSON.stringify(await puter.apps.checkName(appName)) + '<br>');
// (2) Create the app, which takes the name
await puter.apps.create(appName, 'https://example.com');
// (3) The same name now reports differently
puter.print(JSON.stringify(await puter.apps.checkName(appName)) + '<br>');
// (4) Delete the app (cleanup)
await puter.apps.delete(appName);
})();
</script>
</body>
</html>
```
+86
View File
@@ -150,6 +150,90 @@ __Example__
</html>
```
### `move()`
Moves the item to another location.
__Syntax__
```js
fsitem.move(destination)
fsitem.move(destination, overwrite)
fsitem.move(destination, overwrite, newName)
```
__Parameters__
- `destination` (String) (required): The directory to move the item into, or the item's new path.
- `overwrite` (Boolean) (optional): Whether to overwrite an item that already exists at the destination. Defaults to `false`.
- `newName` (String) (optional): The name to give the item at its new location. Defaults to its current name.
__Return value__
A `Promise` that resolves to the [`FSItem`](/Objects/fsitem) object of the moved item.
__Example__
```html
<html>
<body>
<script src="https://js.puter.com/v2/"></script>
<script>
(async () => {
const dirname = puter.randName();
await puter.fs.mkdir(dirname);
await puter.fs.write('hello.txt', 'Hello, world!');
const item = await puter.fs.stat('hello.txt');
await item.move(dirname);
puter.print(`Moved hello.txt into ${dirname}`);
})();
</script>
</body>
</html>
```
### `copy()`
Copies the item into another directory.
__Syntax__
```js
fsitem.copy(destinationDirectory)
fsitem.copy(destinationDirectory, autoRename)
fsitem.copy(destinationDirectory, autoRename, overwrite)
```
__Parameters__
- `destinationDirectory` (String) (required): The directory to copy the item into.
- `autoRename` (Boolean) (optional): Whether to pick a free name when an item with the same name already exists at the destination. Defaults to `false`, which makes the copy fail on a conflict.
- `overwrite` (Boolean) (optional): Whether to overwrite an item that already exists at the destination. Defaults to `false`.
__Return value__
A `Promise` that resolves to the [`FSItem`](/Objects/fsitem) object of the copied item.
__Example__
```html
<html>
<body>
<script src="https://js.puter.com/v2/"></script>
<script>
(async () => {
const dirname = puter.randName();
await puter.fs.mkdir(dirname);
await puter.fs.write('hello.txt', 'Hello, world!');
const item = await puter.fs.stat('hello.txt');
await item.copy(dirname);
puter.print(`Copied hello.txt into ${dirname}`);
})();
</script>
</body>
</html>
```
### `delete()`
Deletes the item.
@@ -194,11 +278,13 @@ __Syntax__
```js
fsitem.mkdir(name)
fsitem.mkdir(name, autoRename)
```
__Parameters__
- `name` (String) (required): The name of the subdirectory to create.
- `autoRename` (Boolean) (optional): Whether to pick a free name when a directory with that name already exists. Defaults to `false`.
__Return value__
+3 -1
View File
@@ -1,11 +1,13 @@
---
title: puter.perms.request()
description: Request a specific permission string to be granted.
platforms: [apps]
platforms: [websites, apps]
---
Request a specific permission string to be granted. Note that some permission strings are not supported and will be denied silently.
Inside the Puter desktop the permission prompt is shown as a dialog. On websites, it opens in a popup window on the Puter origin — call this from a user gesture (e.g. a click handler) so the browser doesn't block the popup; without a gesture, a consent dialog is shown first and the popup opens when the user clicks Continue.
## Syntax
```js
+7 -1
View File
@@ -1,11 +1,17 @@
---
title: puter.perms.requestEmail()
description: Request access to the user's email address.
platforms: [apps]
platforms: [websites, apps]
---
Request to see a user's email. If the user has already granted this permission the user will not be prompted and their email address will be returned. If the user grants permission their email address will be returned. If the user does not allow access `undefined` will be returned. If the user does not have an email address, the value of their email address will be `null`.
On a website, sign the user in to your site first with [`puter.auth.signIn()`](/Auth/signIn/). This method reads the signed-in user's identity before it can prompt, so for a signed-out visitor it rejects with `Unauthorized` and no prompt is shown. Answering a permission prompt does not by itself sign the user in to your site, so guard the call:
```js
if (!puter.authToken) await puter.auth.signIn();
```
## Syntax
```js
+7 -1
View File
@@ -1,11 +1,17 @@
---
title: puter.perms.requestManageApps()
description: Request write (manage) access to the user's apps.
platforms: [apps]
platforms: [websites, apps]
---
Request write (manage) access to the user's apps. If the user has already granted this permission the user will not be prompted and `true` will be returned. If the user grants permission `true` will be returned. If the user does not allow access `false` will be returned.
On a website, sign the user in to your site first with [`puter.auth.signIn()`](/Auth/signIn/). This method reads the signed-in user's identity before it can prompt, so for a signed-out visitor it rejects with `Unauthorized` and no prompt is shown. Answering a permission prompt does not by itself sign the user in to your site, so guard the call:
```js
if (!puter.authToken) await puter.auth.signIn();
```
## Syntax
```js
@@ -1,11 +1,17 @@
---
title: puter.perms.requestManageSubdomains()
description: Request write (manage) access to the user's subdomains.
platforms: [apps]
platforms: [websites, apps]
---
Request write (manage) access to the user's subdomains. If the user has already granted this permission the user will not be prompted and `true` will be returned. If the user grants permission `true` will be returned. If the user does not allow access `false` will be returned.
On a website, sign the user in to your site first with [`puter.auth.signIn()`](/Auth/signIn/). This method reads the signed-in user's identity before it can prompt, so for a signed-out visitor it rejects with `Unauthorized` and no prompt is shown. Answering a permission prompt does not by itself sign the user in to your site, so guard the call:
```js
if (!puter.authToken) await puter.auth.signIn();
```
## Syntax
```js
+7 -1
View File
@@ -1,11 +1,17 @@
---
title: puter.perms.requestReadApps()
description: Request read access to the user's apps.
platforms: [apps]
platforms: [websites, apps]
---
Request read access to the user's apps. If the user has already granted this permission the user will not be prompted and `true` will be returned. If the user grants permission `true` will be returned. If the user does not allow access `false` will be returned.
On a website, sign the user in to your site first with [`puter.auth.signIn()`](/Auth/signIn/). This method reads the signed-in user's identity before it can prompt, so for a signed-out visitor it rejects with `Unauthorized` and no prompt is shown. Answering a permission prompt does not by itself sign the user in to your site, so guard the call:
```js
if (!puter.authToken) await puter.auth.signIn();
```
## Syntax
```js
+7 -1
View File
@@ -1,11 +1,17 @@
---
title: puter.perms.requestReadDesktop()
description: Request read access to the user's Desktop folder.
platforms: [apps]
platforms: [websites, apps]
---
Request read access to the user's Desktop folder. If the user has already granted this permission the user will not be prompted and the path will be returned. If the user grants permission the path will be returned. If the user does not allow access `undefined` will be returned.
On a website, sign the user in to your site first with [`puter.auth.signIn()`](/Auth/signIn/). This method reads the signed-in user's identity before it can prompt, so for a signed-out visitor it rejects with `Unauthorized` and no prompt is shown. Answering a permission prompt does not by itself sign the user in to your site, so guard the call:
```js
if (!puter.authToken) await puter.auth.signIn();
```
## Syntax
```js
+7 -1
View File
@@ -1,11 +1,17 @@
---
title: puter.perms.requestReadDocuments()
description: Request read access to the user's Documents folder.
platforms: [apps]
platforms: [websites, apps]
---
Request read access to the user's Documents folder. If the user has already granted this permission the user will not be prompted and the path will be returned. If the user grants permission the path will be returned. If the user does not allow access `undefined` will be returned.
On a website, sign the user in to your site first with [`puter.auth.signIn()`](/Auth/signIn/). This method reads the signed-in user's identity before it can prompt, so for a signed-out visitor it rejects with `Unauthorized` and no prompt is shown. Answering a permission prompt does not by itself sign the user in to your site, so guard the call:
```js
if (!puter.authToken) await puter.auth.signIn();
```
## Syntax
```js
+7 -1
View File
@@ -1,11 +1,17 @@
---
title: puter.perms.requestReadPictures()
description: Request read access to the user's Pictures folder.
platforms: [apps]
platforms: [websites, apps]
---
Request read access to the user's Pictures folder. If the user has already granted this permission the user will not be prompted and the path will be returned. If the user grants permission the path will be returned. If the user does not allow access `undefined` will be returned.
On a website, sign the user in to your site first with [`puter.auth.signIn()`](/Auth/signIn/). This method reads the signed-in user's identity before it can prompt, so for a signed-out visitor it rejects with `Unauthorized` and no prompt is shown. Answering a permission prompt does not by itself sign the user in to your site, so guard the call:
```js
if (!puter.authToken) await puter.auth.signIn();
```
## Syntax
```js
+7 -1
View File
@@ -1,11 +1,17 @@
---
title: puter.perms.requestReadSubdomains()
description: Request read access to the user's subdomains.
platforms: [apps]
platforms: [websites, apps]
---
Request read access to the user's subdomains. If the user has already granted this permission the user will not be prompted and `true` will be returned. If the user grants permission `true` will be returned. If the user does not allow access `false` will be returned.
On a website, sign the user in to your site first with [`puter.auth.signIn()`](/Auth/signIn/). This method reads the signed-in user's identity before it can prompt, so for a signed-out visitor it rejects with `Unauthorized` and no prompt is shown. Answering a permission prompt does not by itself sign the user in to your site, so guard the call:
```js
if (!puter.authToken) await puter.auth.signIn();
```
## Syntax
```js
+7 -1
View File
@@ -1,11 +1,17 @@
---
title: puter.perms.requestReadVideos()
description: Request read access to the user's Videos folder.
platforms: [apps]
platforms: [websites, apps]
---
Request read access to the user's Videos folder. If the user has already granted this permission the user will not be prompted and the path will be returned. If the user grants permission the path will be returned. If the user does not allow access `undefined` will be returned.
On a website, sign the user in to your site first with [`puter.auth.signIn()`](/Auth/signIn/). This method reads the signed-in user's identity before it can prompt, so for a signed-out visitor it rejects with `Unauthorized` and no prompt is shown. Answering a permission prompt does not by itself sign the user in to your site, so guard the call:
```js
if (!puter.authToken) await puter.auth.signIn();
```
## Syntax
```js
+7 -1
View File
@@ -1,11 +1,17 @@
---
title: puter.perms.requestWriteDesktop()
description: Request write access to the user's Desktop folder.
platforms: [apps]
platforms: [websites, apps]
---
Request write access to the user's Desktop folder. If the user has already granted this permission the user will not be prompted and the path will be returned. If the user grants permission the path will be returned. If the user does not allow access `undefined` will be returned.
On a website, sign the user in to your site first with [`puter.auth.signIn()`](/Auth/signIn/). This method reads the signed-in user's identity before it can prompt, so for a signed-out visitor it rejects with `Unauthorized` and no prompt is shown. Answering a permission prompt does not by itself sign the user in to your site, so guard the call:
```js
if (!puter.authToken) await puter.auth.signIn();
```
## Syntax
```js
+7 -1
View File
@@ -1,11 +1,17 @@
---
title: puter.perms.requestWriteDocuments()
description: Request write access to the user's Documents folder.
platforms: [apps]
platforms: [websites, apps]
---
Request write access to the user's Documents folder. If the user has already granted this permission the user will not be prompted and the path will be returned. If the user grants permission the path will be returned. If the user does not allow access `undefined` will be returned.
On a website, sign the user in to your site first with [`puter.auth.signIn()`](/Auth/signIn/). This method reads the signed-in user's identity before it can prompt, so for a signed-out visitor it rejects with `Unauthorized` and no prompt is shown. Answering a permission prompt does not by itself sign the user in to your site, so guard the call:
```js
if (!puter.authToken) await puter.auth.signIn();
```
## Syntax
```js
+7 -1
View File
@@ -1,11 +1,17 @@
---
title: puter.perms.requestWritePictures()
description: Request write access to the user's Pictures folder.
platforms: [apps]
platforms: [websites, apps]
---
Request write access to the user's Pictures folder. If the user has already granted this permission the user will not be prompted and the path will be returned. If the user grants permission the path will be returned. If the user does not allow access `undefined` will be returned.
On a website, sign the user in to your site first with [`puter.auth.signIn()`](/Auth/signIn/). This method reads the signed-in user's identity before it can prompt, so for a signed-out visitor it rejects with `Unauthorized` and no prompt is shown. Answering a permission prompt does not by itself sign the user in to your site, so guard the call:
```js
if (!puter.authToken) await puter.auth.signIn();
```
## Syntax
```js
+7 -1
View File
@@ -1,11 +1,17 @@
---
title: puter.perms.requestWriteVideos()
description: Request write access to the user's Videos folder.
platforms: [apps]
platforms: [websites, apps]
---
Request write access to the user's Videos folder. If the user has already granted this permission the user will not be prompted and the path will be returned. If the user grants permission the path will be returned. If the user does not allow access `undefined` will be returned.
On a website, sign the user in to your site first with [`puter.auth.signIn()`](/Auth/signIn/). This method reads the signed-in user's identity before it can prompt, so for a signed-out visitor it rejects with `Unauthorized` and no prompt is shown. Answering a permission prompt does not by itself sign the user in to your site, so guard the call:
```js
if (!puter.authToken) await puter.auth.signIn();
```
## Syntax
```js
+8
View File
@@ -194,6 +194,14 @@ let sidebar = [
source: '/Apps/get.md',
path: '/Apps/get',
},
{
title: '<code>checkName()</code>',
page_title: '<code>puter.apps.checkName()</code>',
title_tag: 'puter.apps.checkName()',
icon: '/assets/img/function.svg',
source: '/Apps/checkName.md',
path: '/Apps/checkName',
},
],
},
{
+34 -17
View File
@@ -31,7 +31,7 @@ import UIWindow from './UI/UIWindow.js';
import UIWindowColorPicker from './UI/UIWindowColorPicker.js';
import UIWindowEmailConfirmationRequired from './UI/UIWindowEmailConfirmationRequired.js';
import UIWindowFontPicker from './UI/UIWindowFontPicker.js';
import UIWindowRequestPermission from './UI/UIWindowRequestPermission.js';
import UIPermissionDialog from './UI/UIPermissionDialog.js';
import UIWindowSaveAccount from './UI/UIWindowSaveAccount.js';
import UIWindowSignup from './UI/UIWindowSignup.js';
import UINotification from './UI/UINotification.js';
@@ -1312,14 +1312,38 @@ const ipc_listener = async (event, handled) => {
// requestPermission
//--------------------------------------------------------
else if ( event.data.msg === 'requestPermission' ) {
// Always respond, even on validation/auth failure, so the SDK's
// promise settles instead of hanging forever.
// The app can close its own window while the dialog is up, which
// tears down the iframe — posting into it must not throw.
const respond = (granted) => {
target_iframe?.contentWindow?.postMessage({
msg: 'permissionGranted',
granted: granted,
original_msg_id: msg_id,
}, '*');
};
// auth
if ( !window.is_auth() && !(await UIWindowSignup({ referrer: app_name })) )
{
try {
if ( !window.is_auth() && !(await UIWindowSignup({ referrer: app_name })) )
{
respond(false);
return;
}
} catch ( e ) {
// `ipc_listener` has no outer catch, so a throw from the signup
// window escapes before any reply is sent and leaves the app waiting
// forever — the exact hang `respond` was added to rule out.
console.error('IPC requestPermission: auth gate failed', e);
respond(false);
return;
}
// options must be an object
if ( event.data.options === undefined || typeof event.data.options !== 'object' )
// options must be an object. `typeof null === 'object'`, so null has to
// be caught here too — reading `.permission` off it throws, and the
// throw escapes before any reply, hanging the caller's promise forever.
if ( ! event.data.options || typeof event.data.options !== 'object' )
{
event.data.options = {};
}
@@ -1328,26 +1352,19 @@ const ipc_listener = async (event, handled) => {
if ( !event.data.options.permission || typeof event.data.options.permission !== 'string' )
{
console.error('IPC requestPermission requires parameter { permission }', event.data);
respond(false);
return;
}
let granted = await UIWindowRequestPermission({
let granted = await UIPermissionDialog({
permission: event.data.options.permission,
window_options: {
parent_uuid: event.data.appInstanceID,
disable_parent_window: true,
},
app_uid: app_uuid,
app_name: app_name,
});
// send selected font to requester window
target_iframe.contentWindow.postMessage({
msg: 'permissionGranted',
granted: granted,
original_msg_id: msg_id,
}, '*');
$(target_iframe).get(0).focus({ preventScroll: true });
// report the user's decision to the requester window
respond(granted === true);
$(target_iframe).get(0)?.focus({ preventScroll: true });
}
//--------------------------------------------------------
// showFontPicker
+68 -12
View File
@@ -89,7 +89,7 @@ const TabFiles = {
</form>
<div class="directories">
<ul>
<li data-folder="Home" style="display: none !important;" data-path="${html_encode(window.home_path)}"><img src="${html_encode(window.icons['folder-home.svg'])}"/> <span>Home</span></li>
<li data-folder="Home" data-path="${html_encode(window.home_path)}"><img src="${html_encode(window.icons['folder-home.svg'])}"/> <span>Home</span></li>
<li data-folder="Desktop" data-path="${html_encode(window.desktop_path)}"><img src="${html_encode(window.icons['folder-desktop.svg'])}"/> <span>Desktop</span></li>
<li data-folder="Documents" data-path="${html_encode(window.documents_path)}"><img src="${html_encode(window.icons['folder-documents.svg'])}"/> <span>Documents</span></li>
<li data-folder="Pictures" data-path="${html_encode(window.pictures_path)}"><img src="${html_encode(window.icons['folder-pictures.svg'])}"/> <span>Pictures</span></li>
@@ -160,6 +160,10 @@ const TabFiles = {
const _this = this;
window.dashboard_object = _this;
// The sidebar Trash icon renders in its empty state; sync it with
// the actual trash contents once known.
window.refresh_trash_state();
// Refresh an existing row in place from an fs entry. Shared with
// UIDashboard's item.updated socket handler so the two paths can't
// drift — e.g. trashed items must show metadata.original_name, not
@@ -218,6 +222,10 @@ const TabFiles = {
const parentDir = path.dirname(file.path);
if ( _this.currentPath !== parentDir ) return;
// Trash is never listed in the explorer (sidebar has its own entry),
// so a socket event must not re-add it either.
if ( file.path === window.trash_path ) return;
// If item already exists in view, update in-place.
const $existingRow = $(`.files-tab .files .item[data-uid='${file.uid}']`);
if ( $existingRow.length > 0 ) {
@@ -546,10 +554,10 @@ const TabFiles = {
this.pushNavHistory(initialPath);
this.renderDirectory(initialPath, { skipUrlUpdate: true });
} else {
// Auto-select Desktop folder on initialization
const desktopFolder = $el_window.find('[data-folder="Desktop"]');
if ( desktopFolder.length ) {
desktopFolder.trigger('click');
// Auto-select Home folder on initialization
const homeFolder = $el_window.find('[data-folder="Home"]');
if ( homeFolder.length ) {
homeFolder.trigger('click');
}
}
@@ -713,6 +721,7 @@ const TabFiles = {
for ( const row of trashedItems.toArray() ) {
await window.delete_item(row);
}
await window.refresh_trash_state();
}
} else {
// Move to trash
@@ -1243,7 +1252,7 @@ const TabFiles = {
}
},
// success
success: function (items) {
success: async function (items) {
// Add action to actions_history for undo ability
const files = [];
if ( typeof items[Symbol.iterator] === 'function' ) {
@@ -1263,11 +1272,12 @@ const TabFiles = {
window.show_save_account_notice_if_needed();
// remove from active_uploads
delete window.active_uploads[opid];
// refresh
_this.renderDirectory(_this.currentPath, { consistency: 'strong' });
// Clear the input value to allow uploading the same file again
fileInput.value = '';
document.querySelector('form').reset();
// refresh, then highlight the uploaded items
await _this.renderDirectory(_this.currentPath, { consistency: 'strong' });
_this.selectUploadedRows(files);
},
// error
error: async function (err) {
@@ -1356,6 +1366,7 @@ const TabFiles = {
}
}
_this.updateFooterStats();
await window.refresh_trash_state();
});
// Download button
@@ -1411,6 +1422,7 @@ const TabFiles = {
for ( const row of selectedRows ) {
await window.delete_item(row);
}
await window.refresh_trash_state();
}
} else {
window.move_items(Array.from(selectedRows), window.trash_path);
@@ -1957,10 +1969,12 @@ const TabFiles = {
this.updateSidebarSelection();
// Filter out hidden files/folders and AppData in home directory
// Filter out hidden files/folders and AppData in home directory.
// Trash is reachable from the sidebar; don't list it as a row too.
directoryContents = directoryContents.filter(file => {
if ( file.name.startsWith('.') ) return false;
if ( file.name === 'AppData' && this.currentPath === window.home_path ) return false;
if ( file.path === window.trash_path ) return false;
return true;
});
@@ -2608,13 +2622,20 @@ const TabFiles = {
revertDuration: 100,
start: function (_event, ui) {
// Returning false from here aborts the drag but skips the
// "stop" plugins, so the cursor plugin never restores the
// body cursor it already set to 'grabbing' — reset it on
// every abort path or the hand cursor sticks.
// Don't start drag if item wasn't already selected before mousedown;
// rubberband selection should handle this case instead.
if ( ! itemWasSelectedOnMousedown ) {
$('body').css('cursor', '');
return false;
}
if ( $(el_item).attr('data-immutable') !== '0' ) {
$('body').css('cursor', '');
return false;
}
@@ -3433,6 +3454,7 @@ const TabFiles = {
$(this).remove();
});
_this.updateFooterStats();
await window.refresh_trash_state();
},
onOpen: (el, fsentry) => {
// Custom open handler for Dashboard (avoids window_nav_history issues)
@@ -3490,6 +3512,7 @@ const TabFiles = {
}
}
_this.updateFooterStats();
await window.refresh_trash_state();
},
});
items.push('-');
@@ -3552,6 +3575,7 @@ const TabFiles = {
for ( const row of selectedRows ) {
await window.delete_item(row);
}
await window.refresh_trash_state();
}
},
});
@@ -3861,6 +3885,11 @@ const TabFiles = {
// Disable pointer events on selection actions bar during drag
_this.$el_window.find('.files-selection-actions').addClass('rubberband-active');
// The marquee is clamped to the files container but the cursor
// isn't — while the drag is active, the chrome around the list
// (sidebars, path bar) must not show hover feedback under it.
_this.$el_window.find('.dashboard').addClass('rubberband-selecting');
// Create selection area element only when drag actually starts (after threshold)
selection_area = document.createElement('div');
$(filesContainer).append(selection_area);
@@ -3927,6 +3956,7 @@ const TabFiles = {
}
// Re-enable pointer events on selection actions bar
_this.$el_window.find('.files-selection-actions').removeClass('rubberband-active');
_this.$el_window.find('.dashboard').removeClass('rubberband-selecting');
_this.updateFooterStats();
});
},
@@ -4062,7 +4092,7 @@ const TabFiles = {
update_title_based_on_uploads();
}
},
success: function (items) {
success: async function (items) {
const files = [];
if ( typeof items[Symbol.iterator] === 'function' ) {
for ( const item of items ) {
@@ -4080,8 +4110,11 @@ const TabFiles = {
}, 1000);
window.show_save_account_notice_if_needed();
delete window.active_uploads[opid];
// Refresh directory to show uploaded files
_this.renderDirectory(_this.currentPath, { consistency: 'strong' });
// Refresh directory to show uploaded files, then highlight
// them (a drop on a sidebar folder/breadcrumb uploads to a
// directory that isn't rendered — no rows match, no-op).
await _this.renderDirectory(_this.currentPath, { consistency: 'strong' });
_this.selectUploadedRows(files);
},
error: async function (err) {
const failedItems = Array.isArray(err?.failedItems) ? err.failedItems : [];
@@ -4105,6 +4138,29 @@ const TabFiles = {
});
},
/**
* Selects the rows matching the given absolute paths, replacing the
* current selection. Used after uploads so the just-uploaded items land
* highlighted. Paths outside the rendered directory match no rows and
* leave the selection untouched.
*
* @param {string[]} paths - Absolute paths of the items to select
* @returns {void}
*/
selectUploadedRows (paths) {
const wanted = new Set(paths.map(p => String(p).toLowerCase()));
const matches = this.$el_window.find('.files-tab .files .row').filter(function () {
const rowPath = String($(this).attr('data-path') ?? '').toLowerCase();
return wanted.has(rowPath);
});
if ( matches.length === 0 ) return;
this.$el_window.find('.files-tab .files .row.selected').removeClass('selected');
matches.addClass('selected');
this.updateFooterStats();
matches[0].scrollIntoView({ block: 'nearest' });
},
/**
* Renders the breadcrumb path navigation HTML.
*
+1 -2
View File
@@ -263,8 +263,7 @@ async function UIDashboard (options) {
// Trash status updates
window.socket.on('trash.is_empty', async (msg) => {
// Update sidebar Trash icon
const trashIcon = msg.is_empty ? window.icons['trash.svg'] : window.icons['trash-full.svg'];
$('.directories [data-folder=\'Trash\'] img').attr('src', trashIcon);
window.update_trash_icons(msg.is_empty);
// If currently viewing trash and it's empty, clear the file list
const dashboard = window.dashboard_object;
+1 -2
View File
@@ -195,8 +195,7 @@ async function UIDesktop (options) {
});
window.socket.on('trash.is_empty', async (msg) => {
$(`.item[data-path="${html_encode(window.trash_path)}" i]`).find('.item-icon > img').attr('src', msg.is_empty ? window.icons['trash.svg'] : window.icons['trash-full.svg']);
$(`.window[data-path="${html_encode(window.trash_path)}" i]`).find('.window-head-icon').attr('src', msg.is_empty ? window.icons['trash.svg'] : window.icons['trash-full.svg']);
window.update_trash_icons(msg.is_empty);
// empty trash windows if needed
if ( msg.is_empty )
{
+2 -22
View File
@@ -1083,17 +1083,7 @@ async function UIItem (options) {
const element = $selected_items[index];
await window.delete_item(element);
}
const trash = await puter.fs.stat({ path: window.trash_path, consistency: 'eventual' });
// update other clients
if ( window.socket ) {
window.socket.emit('trash.is_empty', { is_empty: trash.is_empty });
}
if ( trash.is_empty ) {
$(`.item[data-path="${html_encode(window.trash_path)}" i], .item[data-shortcut_to_path="${window.trash_path}" i]`).find('.item-icon > img').attr('src', window.icons['trash.svg']);
$(`.window[data-path="${html_encode(window.trash_path)}"]`).find('.window-head-icon').attr('src', window.icons['trash.svg']);
}
await window.refresh_trash_state();
}
},
});
@@ -1613,17 +1603,7 @@ async function UIItem (options) {
if ( (alert_resp) === 'Delete' ) {
await window.delete_item(el_item);
// check if trash is empty
const trash = await puter.fs.stat({ path: window.trash_path, consistency: 'eventual' });
// update other clients
if ( window.socket ) {
window.socket.emit('trash.is_empty', { is_empty: trash.is_empty });
}
// update this client
if ( trash.is_empty ) {
$(`.item[data-path="${html_encode(window.trash_path)}" i], .item[data-shortcut_to_path="${html_encode(window.trash_path)}" i]`).find('.item-icon > img').attr('src', window.icons['trash.svg']);
$(`.window[data-path="${window.trash_path}"]`).find('.window-head-icon').attr('src', window.icons['trash.svg']);
}
await window.refresh_trash_state();
}
},
});
+707
View File
@@ -0,0 +1,707 @@
/**
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
// Keeps a second request for the same (entity, permission) from stacking a
// duplicate dialog; both callers await the same user decision.
const pending_dialogs = new Map();
// Serializes the dialogs. `showModal()` makes the whole document inert, not
// just the requesting app's window, so several at once would bury the desktop
// under a pile of prompts the user has to clear before touching anything —
// including the app that asked. One at a time, in arrival order.
let dialog_queue = Promise.resolve();
// Buttons stay disabled briefly after the dialog opens so a click aimed at
// the page underneath (or a clickjacking attempt) can't land on "Allow".
const INPUT_PROTECTION_MS = 350;
// How long the grant request may take before it is aborted and the dialog
// re-enables its buttons for a retry.
const GRANT_TIMEOUT_MS = 15000;
// How long the lookups that run before the dialog appears may take. They hold
// the serialization slot, so a stall here would block every later request.
const LOOKUP_TIMEOUT_MS = 10000;
/**
* Shows a permission-request dialog and resolves with the user's decision.
*
* Rendered as a standalone top-layer `<dialog>` (not a UIWindow) so it works
* identically on the desktop, in popups, and on mobile.
*
* Requires `app_uid` or `origin`: those are the identities a grant can be
* written against, so without one there is nothing to prompt about.
*
* @param {Object} options
* @param {string} options.permission - The permission string being requested.
* @param {string} [options.app_uid] - UID of the requesting app, if known.
* @param {string} [options.app_name] - Registered name of the requesting app;
* used for display, never as the grant target.
* @param {string} [options.origin] - Origin of the requesting site (popup flow).
* @returns {Promise<boolean>} `true` only if the permission was granted.
*/
async function UIPermissionDialog (options) {
options = options ?? {};
if ( ! options.permission || typeof options.permission !== 'string' ) {
return false;
}
// Never prompt the user on behalf of a requester the grant can't name.
// Only `app_uid` and `origin` are sent to /auth/grant-user-app, so an
// `app_name` on its own would buy a prompt whose "Allow" is guaranteed to
// fail on the server.
if ( ! options.app_uid && ! options.origin ) {
return false;
}
// An origin the server would refuse to parse can't name a grant target
// either, so there is nothing to prompt about — and showing it anyway would
// put a string that is not a host into the identity line, which is styled to
// elide *hosts* from the left (`direction: rtl`). Arbitrary text there can
// render in an order it wasn't written in, on the one line of this dialog
// whose whole job is saying who is asking. Mirrors the server's own check in
// AuthService#originFromUrl: parseable, and http(s).
if ( options.origin && ! is_web_origin(options.origin) ) {
console.error('Permission dialog: unusable origin', options.origin);
return false;
}
// `||`, not `??`: the gate above treats an empty uid as absent, so the key
// has to fall through to the origin too — otherwise two different origins
// arriving with a blank uid would share one decision.
const pending_key = `${options.app_uid || options.origin || ''}\n${options.permission}`;
if ( pending_dialogs.has(pending_key) ) {
return pending_dialogs.get(pending_key);
}
// Claim the next slot in the queue. Reading and replacing `dialog_queue`
// happens before the first await, so slots are handed out in call order.
const wait_for_turn = dialog_queue;
let release_turn;
dialog_queue = new Promise((r) => { release_turn = r; });
const promise = (async () => {
try {
// A failed predecessor must not stall the queue behind it.
await wait_for_turn.catch(() => {});
return await show_permission_dialog(options);
} catch (e) {
// Callers treat this as the user's decision and some of them (the
// IPC bridge) have no way to answer a rejection, which would leave
// the requesting app waiting forever. Fail closed instead.
console.error('Permission dialog failed', options.permission, e);
return false;
} finally {
release_turn();
}
})();
pending_dialogs.set(pending_key, promise);
try {
return await promise;
} finally {
pending_dialogs.delete(pending_key);
}
}
async function show_permission_dialog (options) {
let permission_description;
try {
permission_description = await with_timeout(
get_permission_description(options.permission),
LOOKUP_TIMEOUT_MS,
null,
);
} catch (e) {
// Description lookup needs auth/whoami; treat failures as unsupported.
console.error('Failed to describe permission', options.permission, e);
return false;
}
// Unsupported permission strings are denied silently (existing contract).
// A lookup that timed out lands here too: nothing to describe, so nothing
// to prompt about.
if ( ! permission_description ) {
return false;
}
const entity = await resolve_requesting_entity(options);
return new Promise((resolve) => {
const el_dialog = create_dialog_element(entity, permission_description);
document.body.appendChild(el_dialog);
// Set once a grant request has been sent without the server definitively
// refusing it. A client-side timeout or a dropped response says nothing
// about whether the row was written, so a later denial has to undo it —
// otherwise the user is told "denied" while the grant is live in their
// account. Declared before `settle`, which reads it.
let grant_may_have_committed = false;
let settled = false;
const settle = (granted) => {
if ( settled ) return;
settled = true;
if ( ! granted && grant_may_have_committed ) {
// Not awaited: the requester gets its answer now, and the
// account is reconciled in the background.
undo_uncertain_grant(options);
}
// `close()` fires the 'close' event; the handler below resolves
// false, so resolve first.
resolve(granted);
el_dialog.close();
el_dialog.remove();
};
const $allow = $(el_dialog).find('.perm-dialog-allow');
const $deny = $(el_dialog).find('.perm-dialog-deny');
const $error = $(el_dialog).find('.perm-dialog-error');
// Input protection: ignore clicks for a moment after opening.
$allow.prop('disabled', true);
$deny.prop('disabled', true);
setTimeout(() => {
if ( settled ) return;
$allow.prop('disabled', false);
$deny.prop('disabled', false);
$deny.get(0)?.focus();
}, INPUT_PROTECTION_MS);
// True while the grant POST is outstanding. Dismissing the dialog then
// would answer "denied" for a permission the server may be in the
// middle of committing, leaving the user believing they cancelled
// something that is now granted.
let granting = false;
// Hands the dialog back to the user after a failed attempt: re-enables
// the buttons, restores dismissability, and shows a retryable error.
const fail_grant = () => {
if ( settled ) return;
granting = false;
// The browser can force-close the dialog past the `cancel`
// handler (repeated close requests can't be suppressed forever),
// and the `close` handler defers to the in-flight grant. If that
// grant then fails there is no visible retry UI to hand back —
// settle as a denial or the requester waits forever.
if ( ! el_dialog.open ) {
settle(false);
return;
}
// `.text()` escapes for display, so hand it the unencoded string —
// the default encoding would surface entities like `&#39;`
// verbatim in translations that contain an apostrophe.
$error.text(i18n('perm_dialog_error', [], false)).show();
$allow.prop('disabled', false).removeClass('perm-dialog-busy');
$deny.prop('disabled', false);
};
$allow.on('click', async () => {
if ( granting ) return;
granting = true;
$allow.prop('disabled', true).addClass('perm-dialog-busy');
$deny.prop('disabled', true);
$error.hide();
// Time-box the request: while it is outstanding the dialog can't
// be dismissed, so a hung network must not strand the user in
// front of a modal with both buttons disabled. AbortController is
// available wherever the GUI runs; AbortSignal.timeout is not.
const controller = typeof AbortController !== 'undefined'
? new AbortController()
: null;
const grant_timer = setTimeout(() => {
// A request still outstanding after the timeout has left this
// browser with an unknown outcome, so record that before
// anything can settle. The `catch` below sets the same flag,
// but only from a microtask that cannot run until this timer
// callback returns — by which time `fail_grant` may already
// have settled the dialog as a denial (it does exactly that
// when the dialog was force-closed mid-grant), leaving a
// committed grant with no revoke behind it.
grant_may_have_committed = true;
controller?.abort();
// Also recover on engines with no AbortController, where the
// fetch above may never settle on its own.
fail_grant();
}, GRANT_TIMEOUT_MS);
// True once we have an answer that proves nothing was written.
let refused_outright = false;
try {
const res = await fetch(`${window.api_origin}/auth/grant-user-app`, {
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${window.auth_token}`,
},
body: JSON.stringify({
app_uid: options.app_uid,
origin: options.origin,
permission: options.permission,
}),
method: 'POST',
...(controller ? { signal: controller.signal } : {}),
});
if ( ! res.ok ) {
// A 4xx is the server refusing outright, so nothing was
// written. Anything else leaves the outcome unknown.
refused_outright = res.status >= 400 && res.status < 500;
throw new Error(`HTTP error! Status: ${res.status}`);
}
settle(true);
} catch (err) {
// An abort or a dropped response says nothing about whether the
// server committed the request that already left this browser.
if ( ! refused_outright ) {
grant_may_have_committed = true;
}
console.error(err);
fail_grant();
} finally {
clearTimeout(grant_timer);
}
});
$deny.on('click', () => settle(false));
// Esc key (native 'cancel'), or anything else that closes the dialog,
// counts as a denial — unless a grant is mid-flight, in which case the
// request's own outcome is the answer.
el_dialog.addEventListener('cancel', (e) => {
e.preventDefault();
if ( granting ) return;
settle(false);
});
el_dialog.addEventListener('close', () => {
if ( granting ) return;
settle(false);
});
try {
el_dialog.showModal();
} catch (e) {
// `showModal()` throws when modals are blocked for the document,
// e.g. the GUI embedded in an `<iframe sandbox>` without
// `allow-modals`. Deny rather than leave an invisible dialog
// behind and the caller waiting on it.
console.error('Permission dialog could not be shown', e);
el_dialog.remove();
settle(false);
return;
}
// Focus the safe action by default; Enter must not grant by accident.
$deny.get(0)?.focus();
});
}
/**
* Withdraws a grant whose POST may have committed even though the dialog never
* saw a success response, so a "Don't Allow" can't leave a live permission the
* user believes they refused. Addressed by the same identifiers the grant used.
*/
async function undo_uncertain_grant (options) {
try {
await fetch(`${window.api_origin}/auth/revoke-user-app`, {
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${window.auth_token}`,
},
body: JSON.stringify({
app_uid: options.app_uid,
origin: options.origin,
permission: options.permission,
}),
method: 'POST',
// In the popup flow the answer is posted and the window closed
// right after `settle`, and a plain fetch is cancelled with its
// document — taking this reconciliation down with it.
keepalive: true,
});
} catch (e) {
console.error('Failed to withdraw an uncertain permission grant', e);
}
}
/**
* Whether `origin` is a real web origin: something `new URL()` accepts, on a
* scheme the platform actually serves apps over. `new URL()` alone is far too
* permissive here — it happily parses `javascript:`, `data:` and `file:`.
*
* @param {string} origin
* @returns {boolean}
*/
function is_web_origin (origin) {
try {
const { protocol } = new URL(origin);
return protocol === 'https:' || protocol === 'http:';
} catch (e) {
return false;
}
}
/**
* Resolves `promise`, or `fallback` if it hasn't settled within `ms`.
*
* The lookups before the dialog appears run while this request holds the
* serialization slot, and none of them (`whoami`, `fs.stat`, `get_apps`) has a
* timeout of its own. One stalled request would hold the slot forever and every
* later permission request in the page would wait behind it.
*/
function with_timeout (promise, ms, fallback) {
let timer;
return Promise.race([
promise,
new Promise((resolve) => {
timer = setTimeout(() => resolve(fallback), ms);
}),
]).finally(() => clearTimeout(timer));
}
/**
* Builds the dialog DOM from the requesting entity and the permission
* description. Returns a detached <dialog> element.
*/
function create_dialog_element (entity, permission_description) {
let h = '';
h += '<div class="perm-dialog-body">';
// requesting entity identity
h += '<div class="perm-dialog-identity">';
h += entity.icon_html;
// A host is elided from the left, keeping the registrable domain visible —
// the part that says who is actually asking. Truncating the other end would
// let `accounts.google.com.attacker.example` read as `accounts.google.com…`.
const name_class = entity.is_host
? 'perm-dialog-entity-name perm-dialog-entity-host'
: 'perm-dialog-entity-name';
h += `<h1 class="${name_class}">${html_encode(entity.display_name)}</h1>`;
if ( entity.subtitle ) {
h += `<span class="perm-dialog-entity-origin">${html_encode(entity.subtitle)}</span>`;
}
h += '</div>';
// what is being requested
h += `<p class="perm-dialog-lead">${i18n('perm_dialog_wants_to')}</p>`;
h += '<div class="perm-dialog-permission">';
h += `<div class="perm-dialog-perm-icon">${permission_icon_svg(permission_description.icon)}</div>`;
h += `<div class="perm-dialog-perm-text">${permission_description.html}</div>`;
h += '</div>';
// error message (hidden until a grant attempt fails)
h += '<p class="perm-dialog-error" style="display: none;"></p>';
// actions
h += '<div class="perm-dialog-actions">';
h += `<button type="button" class="perm-dialog-allow">${i18n('allow')}</button>`;
h += `<button type="button" class="perm-dialog-deny">${i18n('dont_allow')}</button>`;
h += '</div>';
// footnote
h += `<p class="perm-dialog-footnote">${i18n('perm_dialog_footnote')}</p>`;
h += '</div>';
const el_dialog = document.createElement('dialog');
el_dialog.className = 'perm-dialog';
el_dialog.setAttribute('aria-label', entity.display_name);
el_dialog.innerHTML = h;
return el_dialog;
}
/**
* Resolves how the requesting app or site is presented: display name, a
* subtitle carrying the identity that is actually unique (the app's registered
* name, or the site's host), and an icon (app icon or letter avatar).
*/
async function resolve_requesting_entity (options) {
let display_name = options.app_name ?? options.origin ?? '';
let subtitle = null;
let icon_url = null;
if ( options.origin ) {
try {
subtitle = new URL(options.origin).host;
} catch (e) {
subtitle = options.origin;
}
}
// Prefer the app's human-readable title and icon when we can get them.
if ( options.app_name ) {
try {
const app_info = await with_timeout(
window.get_apps(options.app_name),
LOOKUP_TIMEOUT_MS,
null,
);
if ( app_info && ! Array.isArray(app_info) ) {
display_name = app_info.title || display_name;
icon_url = app_info.icon || null;
}
} catch (e) {
// Fall back to the app name / letter avatar.
}
// An app's title is free-form text its author chooses and two apps can
// share one, so the title alone can impersonate another app on this
// dialog. Name the app underneath it: `name` is unique per app and
// format-restricted, which the title is not.
subtitle = options.app_name;
}
// Sites are identified by their host rather than a full origin URL.
let is_host = false;
if ( ! options.app_name && subtitle ) {
display_name = subtitle;
is_host = true;
}
// Don't repeat the same string twice.
if ( subtitle === display_name ) {
subtitle = null;
}
let icon_html;
if ( icon_url && ! is_safe_icon_url(icon_url) ) {
icon_url = null;
}
if ( icon_url ) {
icon_html = `<img class="perm-dialog-entity-icon" src="${html_encode(icon_url)}" alt="" />`;
} else {
const initial = (display_name || '?').trim().charAt(0).toUpperCase() || '?';
icon_html = `<div class="perm-dialog-entity-icon perm-dialog-letter-avatar">${html_encode(initial)}</div>`;
}
return { display_name, subtitle, icon_html, is_host };
}
/**
* App icons are author-controlled; only allow schemes that are inert as an
* image source on this security-sensitive dialog.
*/
function is_safe_icon_url (url) {
if ( url.startsWith('data:image/') ) return true;
try {
const parsed = new URL(url, window.location.origin);
return parsed.protocol === 'https:' || parsed.protocol === 'http:';
} catch (e) {
return false;
}
}
/**
* Inline SVG icons for each permission category. Stroke uses currentColor so
* they adapt to light/dark themes via CSS.
*/
function permission_icon_svg (icon) {
const attrs = 'xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"';
const icons = {
folder: `<svg ${attrs}><path d="M4 20h16a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13c0 1.1.9 2 2 2Z"/></svg>`,
file: `<svg ${attrs}><path d="M14.5 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7.5L14.5 2Z"/><polyline points="14 2 14 8 20 8"/></svg>`,
mail: `<svg ${attrs}><rect width="20" height="16" x="2" y="4" rx="2"/><path d="m22 7-8.97 5.7a1.94 1.94 0 0 1-2.06 0L2 7"/></svg>`,
apps: `<svg ${attrs}><rect width="7" height="7" x="3" y="3" rx="1"/><rect width="7" height="7" x="14" y="3" rx="1"/><rect width="7" height="7" x="14" y="14" rx="1"/><rect width="7" height="7" x="3" y="14" rx="1"/></svg>`,
globe: `<svg ${attrs}><circle cx="12" cy="12" r="10"/><path d="M12 2a14.5 14.5 0 0 0 0 20 14.5 14.5 0 0 0 0-20"/><path d="M2 12h20"/></svg>`,
zap: `<svg ${attrs}><path d="M13 2 3 14h9l-1 8 10-12h-9l1-8z"/></svg>`,
chat: `<svg ${attrs}><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/></svg>`,
shield: `<svg ${attrs}><path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z"/></svg>`,
};
return icons[icon] ?? icons.shield;
}
/**
* Generates a user-friendly description of a permission string.
*
* @param {string} permission - The permission string to describe
* @returns {Promise<{html: string, icon: string} | null>} Description (HTML)
* and icon key, or null if the permission cannot be requested interactively.
*/
async function get_permission_description (permission) {
const parts = split_permission(permission);
if ( ['fs', 'thread', 'service', 'driver'].includes(parts[0]) ) {
const [resource_type, resource_id, action, interface_name = null] = parts;
if ( resource_type === 'fs' ) {
// Check for standard folders using whoami().directories
const standard_folder_description = await get_standard_folder_description(resource_id, action);
if ( standard_folder_description ) {
return { html: standard_folder_description, icon: 'folder' };
}
// Try to stat by path or UUID
try {
let fsentry;
if ( resource_id.startsWith('/') ) {
fsentry = await puter.fs.stat({ path: resource_id, consistency: 'eventual' });
} else {
fsentry = await puter.fs.stat({ uid: resource_id, consistency: 'eventual' });
}
return {
html: i18n('perm_fs_file_access', {
name: fsentry.name,
path: fsentry.dirpath,
access: action,
}),
icon: fsentry.is_dir ? 'folder' : 'file',
};
} catch (e) {
// Can't stat, use resource_id directly
return {
html: i18n('perm_fs_resource_access', {
resource_id: resource_id,
access: action,
}),
icon: 'file',
};
}
}
if ( resource_type === 'thread' && action === 'post' ) {
return { html: i18n('perm_thread_post', { thread: resource_id }), icon: 'chat' };
}
if ( resource_type === 'service' && action === 'ii' ) {
return { html: i18n('perm_service_invoke', { service: resource_id, interface: interface_name }), icon: 'zap' };
}
if ( resource_type === 'driver' ) {
return { html: i18n('perm_driver_use', { driver: resource_id, action: action }), icon: 'zap' };
}
return null;
}
if ( parts[0] === 'user' ) {
const whoami = await puter.auth.whoami();
// An app can't ask to see other users' information
if ( whoami.uuid !== parts[1] ) return null;
if ( parts[2] === 'email' && parts[3] === 'read' ) {
return { html: i18n('perm_email_read'), icon: 'mail' };
}
}
if ( parts[0] === 'apps-of-user' ) {
const whoami = await puter.auth.whoami();
// An app can't ask to see other users' apps
if ( whoami.uuid !== parts[1] ) return null;
if ( parts[2] === 'read' ) {
return { html: i18n('perm_apps_read'), icon: 'apps' };
}
if ( parts[2] === 'write' ) {
return { html: i18n('perm_apps_write'), icon: 'apps' };
}
}
if ( parts[0] === 'subdomains-of-user' ) {
const whoami = await puter.auth.whoami();
// An app can't ask to see other users' subdomains
if ( whoami.uuid !== parts[1] ) return null;
if ( parts[2] === 'read' ) {
return { html: i18n('perm_subdomains_read'), icon: 'globe' };
}
if ( parts[2] === 'write' ) {
return { html: i18n('perm_subdomains_write'), icon: 'globe' };
}
}
if ( parts[0] === 'app-root-dir' ) {
// Format: app-root-dir:<app_uid>:<read|write>
if ( parts[2] === 'read' ) {
return { html: i18n('perm_app_root_dir_read'), icon: 'folder' };
}
if ( parts[2] === 'write' ) {
return { html: i18n('perm_app_root_dir_write'), icon: 'folder' };
}
}
return null;
}
/**
* Returns a user-friendly description for standard folder permissions.
* Uses whoami().directories to verify the path/UUID belongs to the current user.
* @param {string} resource_id - The filesystem path or UUID
* @param {string} action - The access level (read, write, list, see)
* @returns {Promise<string|null>} A friendly HTML description or null if not a standard folder belonging to current user
*/
async function get_standard_folder_description (resource_id, action) {
const whoami = await puter.auth.whoami();
const directories = whoami.directories || {};
// Standard folder names we recognize - maps to i18n keys
const folder_i18n_keys = {
'Desktop': 'perm_folder_desktop',
'Documents': 'perm_folder_documents',
'Pictures': 'perm_folder_pictures',
'Videos': 'perm_folder_videos',
};
// Check if resource_id matches any of the user's standard directories
// directories is an object like { "/username/Desktop": "uuid-here", ... }
for ( const [path, uuid] of Object.entries(directories) ) {
// Check if resource_id matches either the path or the UUID
if ( resource_id !== path && resource_id !== uuid ) continue;
// Extract folder name from path (e.g., "/username/Desktop" -> "Desktop")
const path_parts = path.split('/').filter(Boolean);
if ( path_parts.length !== 2 ) continue;
const folder_name = path_parts[1];
const folder_i18n_key = folder_i18n_keys[folder_name];
if ( ! folder_i18n_key ) continue;
const folder_desc = i18n(folder_i18n_key);
return i18n('perm_folder_access', {
access: `<strong>${html_encode(action)}</strong>`,
folder: folder_desc,
}, false);
}
return null;
}
function split_permission (permission) {
return permission
.split(':')
.map(unescape_permission_component);
}
function unescape_permission_component (component) {
let unescaped_str = '';
// Constant for unescaped permission component string
const STATE_NORMAL = {};
// Constant for escaping special characters in permission strings
const STATE_ESCAPE = {};
let state = STATE_NORMAL;
const const_escapes = { C: ':' };
for ( let i = 0; i < component.length; i++ ) {
const c = component[i];
if ( state === STATE_NORMAL ) {
if ( c === '\\' ) {
state = STATE_ESCAPE;
} else {
unescaped_str += c;
}
} else if ( state === STATE_ESCAPE ) {
unescaped_str += const_escapes.hasOwnProperty(c) ? const_escapes[c] : c;
state = STATE_NORMAL;
}
}
return unescaped_str;
}
export default UIPermissionDialog;
+5 -1
View File
@@ -358,6 +358,10 @@ async function UIWindow (options) {
h += `<div draggable="false" title="${i18n('videos')}" class="window-sidebar-item disable-user-select ${options.path === window.videos_path ? 'window-sidebar-item-active' : ''}" data-path="${html_encode(window.videos_path)}"><img draggable="false" class="window-sidebar-item-icon" src="${html_encode(window.icons['sidebar-folder-videos.svg'])}">${i18n('videos')}</div>`;
} else {
let items = JSON.parse(window.sidebar_items);
// Saved sidebar orders may predate the Home entry — make sure it's always present
if ( ! items.some(item => item.path === window.home_path) ) {
items.unshift({ path: window.home_path, name: i18n('home') });
}
for ( let item of items ) {
let icon;
if ( item.path === window.home_path )
@@ -2689,7 +2693,7 @@ async function UIWindow (options) {
window.socket.emit('trash.is_empty', { is_empty: true });
}
// use the 'empty trash' icon
$(`.item[data-path="${html_encode(window.trash_path)}" i], .item[data-shortcut_to_path="${html_encode(window.trash_path)}" i]`).find('.item-icon > img').attr('src', window.icons['trash.svg']);
window.update_trash_icons(true);
},
},
],
+15 -10
View File
@@ -22,6 +22,8 @@ import UIWindow from './UIWindow.js';
import UIWindowRecoverPassword from './UIWindowRecoverPassword.js';
import UIWindowSignup from './UIWindowSignup.js';
import { KNOWN_OIDC_PROVIDERS, OIDC_GENERIC_PROVIDER_ICON, humanizeOidcProviderId } from '../util/openid.js';
import { offersFederatedSignInInPopup } from '../util/popupAuth.js';
import { get_auth_redirect_url, get_oidc_return_to } from '../helpers/auth_redirect.js';
// ── 2FA Login CSS (injected once) ───────────────────────────────────────────
const LOGIN_2FA_CSS = `
@@ -259,14 +261,9 @@ async function UIWindowLogin (options) {
if ( options.redirect_url === undefined )
{
if ( window.location?.href?.toLowerCase().endsWith('/action/login') )
{
options.redirect_url = '/';
}
else
{
options.redirect_url = window.location.href;
}
// stay on the page the login started from (e.g. an /app/<name>
// landing), with standalone auth pages going to the root dashboard
options.redirect_url = get_auth_redirect_url();
}
return new Promise(async (resolve) => {
@@ -400,6 +397,14 @@ async function UIWindowLogin (options) {
(async () => {
try {
// A federated hop navigates this popup away and the provider
// returns it as a plain sign-in popup, losing whatever the popup
// was opened to do — and, for a permission prompt, handing the
// opener a token instead of a decision. Don't offer it there.
if ( window.embedded_in_popup
&& ! offersFederatedSignInInPopup(window.gui_action) ) {
return;
}
const res = await fetch(`${window.api_origin}/auth/oidc/providers`);
if ( ! res.ok ) return;
const data = await res.json();
@@ -411,8 +416,8 @@ async function UIWindowLogin (options) {
let url = `${window.gui_origin}/auth/oidc/${provider}/start?flow=login`;
// return to the interface the login started from (backend whitelists the path)
const return_to = window.location.pathname;
if ( return_to === '/desktop' || return_to === '/dashboard' ) {
const return_to = get_oidc_return_to();
if ( return_to ) {
url += `&return_to=${encodeURIComponent(return_to)}`;
}
-320
View File
@@ -1,320 +0,0 @@
/**
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import UIWindow from './UIWindow.js';
async function UIWindowRequestPermission (options) {
options = options ?? {};
options.reload_on_success = options.reload_on_success ?? false;
return new Promise((resolve) => {
get_permission_description(options.permission).then((permission_description) => {
if ( ! permission_description ) {
resolve(false);
return;
}
create_permission_window(options, permission_description, resolve).then((el_window) => {
setup_window_events(el_window, options, resolve);
});
});
});
}
/**
* Creates the permission dialog
*/
async function create_permission_window (options, permission_description, resolve) {
const requestingEntity = options.app_name ?? options.origin;
const h = create_window_content(requestingEntity, permission_description);
return await UIWindow({
title: null,
app: 'request-authorization',
single_instance: true,
icon: null,
uid: null,
is_dir: false,
body_content: h,
has_head: true,
selectable_body: false,
draggable_body: true,
allow_context_menu: false,
is_draggable: true,
is_droppable: false,
is_resizable: false,
stay_on_top: false,
allow_native_ctxmenu: true,
allow_user_select: true,
...options.window_options,
width: 350,
dominant: true,
on_close: () => resolve(false),
onAppend: function (this_window) {
},
window_class: 'window-login',
window_css: {
height: 'initial',
},
body_css: {
width: 'initial',
padding: '0',
'background-color': 'rgba(231, 238, 245, .95)',
'backdrop-filter': 'blur(3px)',
},
});
}
/**
* Creates HTML content for permission dialog
*/
function create_window_content (requestingEntity, permission_description) {
let h = '';
h += '<div>';
h += '<div style="padding: 20px; width: 100%; box-sizing: border-box;">';
// title
h += `<h1 class="perm-title">${html_encode(requestingEntity)}</h1>`;
// description (already HTML encoded)
h += `<p class="perm-description">${html_encode(requestingEntity)} is requesting permission to ${permission_description}</p>`;
// Allow/Don't Allow
h += `<button type="button" class="app-auth-allow button button-primary button-block" style="margin-top: 10px;">${i18n('allow')}</button>`;
h += `<button type="button" class="app-auth-dont-allow button button-default button-block" style="margin-top: 10px;">${i18n('dont_allow')}</button>`;
h += '</div>';
h += '</div>';
return h;
}
/**
* Sets up event handlers for permission dialog
*/
async function setup_window_events (el_window, options, resolve) {
$(el_window).find('.app-auth-allow').on('click', async function (e) {
$(this).addClass('disabled');
try {
// register granted permission to app or website
const res = await fetch(`${window.api_origin }/auth/grant-user-app`, {
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${ window.auth_token}`,
},
body: JSON.stringify({
app_uid: options.app_uid,
origin: options.origin,
permission: options.permission,
}),
method: 'POST',
});
if ( ! res.ok ) {
throw new Error(`HTTP error! Status: ${res.status}`);
}
$(el_window).close();
resolve(true);
} catch ( err ) {
console.error(err);
resolve(err);
}
});
$(el_window).find('.app-auth-dont-allow').on('click', function (e) {
$(this).addClass('disabled');
$(el_window).close();
resolve(false);
});
}
/**
* Generates user-friendly description of permission string in HTML format.
*
* @param {string} permission - The permission string to describe
* @returns {string} The user-friendly description of the permission in HTML format
*/
async function get_permission_description (permission) {
const parts = split_permission(permission);
if ( ['fs', 'thread', 'service', 'driver'].includes(parts[0]) ) {
const [resource_type, resource_id, action, interface_name = null] = parts;
let fsentry;
let fs_description_html = null;
if ( resource_type === 'fs' ) {
// Check for standard folders using whoami().directories
const standard_folder_description = await get_standard_folder_description(resource_id, action);
if ( standard_folder_description ) {
fs_description_html = standard_folder_description;
} else {
// Try to stat by path or UUID
try {
if ( resource_id.startsWith('/') ) {
fsentry = await puter.fs.stat({ path: resource_id, consistency: 'eventual' });
} else {
fsentry = await puter.fs.stat({ uid: resource_id, consistency: 'eventual' });
}
fs_description_html = i18n('perm_fs_file_access', {
name: fsentry.name,
path: fsentry.dirpath,
access: action,
});
} catch (e) {
// Can't stat, use resource_id directly
fs_description_html = i18n('perm_fs_resource_access', {
resource_id: resource_id,
access: action,
});
}
}
}
const permission_mappings = {
'fs': fs_description_html,
'thread': action === 'post' ? i18n('perm_thread_post', { thread: resource_id }) : null,
'service': action === 'ii' ? i18n('perm_service_invoke', { service: resource_id, interface: interface_name }) : null,
'driver': i18n('perm_driver_use', { driver: resource_id, action: action }),
};
return permission_mappings[resource_type];
}
if ( parts[0] === 'user' ) {
const whoami = await puter.auth.whoami();
// An app can't ask to see other users' information
if ( whoami.uuid !== parts[1] ) return null;
if ( parts[2] === 'email' && parts[3] === 'read' ) {
return i18n('perm_email_read');
}
}
if ( parts[0] === 'apps-of-user' ) {
const whoami = await puter.auth.whoami();
// An app can't ask to see other users' apps
if ( whoami.uuid !== parts[1] ) return null;
if ( parts[2] === 'read' ) {
return i18n('perm_apps_read');
}
if ( parts[2] === 'write' ) {
return i18n('perm_apps_write');
}
}
if ( parts[0] === 'subdomains-of-user' ) {
const whoami = await puter.auth.whoami();
// An app can't ask to see other users' subdomains
if ( whoami.uuid !== parts[1] ) return null;
if ( parts[2] === 'read' ) {
return i18n('perm_subdomains_read');
}
if ( parts[2] === 'write' ) {
return i18n('perm_subdomains_write');
}
}
if ( parts[0] === 'app-root-dir' ) {
// Format: app-root-dir:resource_request_code:access
if ( parts[2] === 'read' ) {
return i18n('perm_app_root_dir_read');
}
if ( parts[2] === 'write' ) {
return i18n('perm_app_root_dir_write');
}
}
return null;
}
/**
* Returns a user-friendly description for standard folder permissions.
* Uses whoami().directories to verify the path/UUID belongs to the current user.
* @param {string} resource_id - The filesystem path or UUID
* @param {string} action - The access level (read, write, list, see)
* @returns {string|null} A friendly HTML description or null if not a standard folder belonging to current user
*/
async function get_standard_folder_description (resource_id, action) {
const whoami = await puter.auth.whoami();
const directories = whoami.directories || {};
// Standard folder names we recognize - maps to i18n keys
const folder_i18n_keys = {
'Desktop': 'perm_folder_desktop',
'Documents': 'perm_folder_documents',
'Pictures': 'perm_folder_pictures',
'Videos': 'perm_folder_videos',
};
// Check if resource_id matches any of the user's standard directories
// directories is an object like { "/username/Desktop": "uuid-here", ... }
for ( const [path, uuid] of Object.entries(directories) ) {
// Check if resource_id matches either the path or the UUID
if ( resource_id !== path && resource_id !== uuid ) continue;
// Extract folder name from path (e.g., "/username/Desktop" -> "Desktop")
const path_parts = path.split('/').filter(Boolean);
if ( path_parts.length !== 2 ) continue;
const folder_name = path_parts[1];
const folder_i18n_key = folder_i18n_keys[folder_name];
if ( ! folder_i18n_key ) continue;
const folder_desc = i18n(folder_i18n_key);
return i18n('perm_folder_access', {
access: `<strong>${html_encode(action)}</strong>`,
folder: folder_desc,
}, false);
}
return null;
}
function split_permission (permission) {
return permission
.split(':')
.map(unescape_permission_component);
}
function unescape_permission_component (component) {
let unescaped_str = '';
// Constant for unescaped permission component string
const STATE_NORMAL = {};
// Constant for escaping special characters in permission strings
const STATE_ESCAPE = {};
let state = STATE_NORMAL;
const const_escapes = { C: ':' };
for ( let i = 0; i < component.length; i++ ) {
const c = component[i];
if ( state === STATE_NORMAL ) {
if ( c === '\\' ) {
state = STATE_ESCAPE;
} else {
unescaped_str += c;
}
} else if ( state === STATE_ESCAPE ) {
unescaped_str += const_escapes.hasOwnProperty(c) ? const_escapes[c] : c;
state = STATE_NORMAL;
}
}
return unescaped_str;
}
export default UIWindowRequestPermission;
+20 -6
View File
@@ -24,6 +24,8 @@ import UIWindowPhoneVerificationRequired from './UIWindowPhoneVerificationRequir
import UIWindowCardVerificationRequired from './UIWindowCardVerificationRequired.js';
import UIWindowLogin from './UIWindowLogin.js';
import { KNOWN_OIDC_PROVIDERS, OIDC_GENERIC_PROVIDER_ICON, humanizeOidcProviderId } from '../util/openid.js';
import { offersFederatedSignInInPopup } from '../util/popupAuth.js';
import { get_auth_redirect_url, get_oidc_return_to } from '../helpers/auth_redirect.js';
function UIWindowSignup(options) {
options = options ?? {};
@@ -31,6 +33,11 @@ function UIWindowSignup(options) {
options.has_head = options.has_head ?? true;
options.send_confirmation_code = options.send_confirmation_code ?? false;
options.show_close_button = options.show_close_button ?? true;
if (options.redirect_url === undefined) {
// stay on the page the signup started from (e.g. an /app/<name>
// landing), with standalone auth pages going to the root dashboard
options.redirect_url = get_auth_redirect_url();
}
return new Promise(async (resolve) => {
const internal_id = window.uuidv4();
@@ -216,6 +223,17 @@ function UIWindowSignup(options) {
(async () => {
try {
// A federated hop navigates this popup away and the
// provider returns it as a plain sign-in popup, losing
// whatever the popup was opened to do — and, for a
// permission prompt, handing the opener a token instead
// of a decision. Don't offer it there.
if (
window.embedded_in_popup &&
!offersFederatedSignInInPopup(window.gui_action)
) {
return;
}
const res = await fetch(
`${window.api_origin}/auth/oidc/providers`,
);
@@ -230,11 +248,8 @@ function UIWindowSignup(options) {
.on('click', function () {
let url = `${window.gui_origin}/auth/oidc/${provider}/start?flow=signup`;
// return to the interface the signup started from (backend whitelists the path)
const return_to = window.location.pathname;
if (
return_to === '/desktop' ||
return_to === '/dashboard'
) {
const return_to = get_oidc_return_to();
if (return_to) {
url += `&return_to=${encodeURIComponent(return_to)}`;
}
const referrer =
@@ -559,7 +574,6 @@ function UIWindowSignup(options) {
if (options.reload_on_success) {
window.onbeforeunload = null;
// either options.redirect_url or the current page
const redirectUrl = options.redirect_url || '/';
window.location.replace(redirectUrl);
} else {
+9
View File
@@ -2067,6 +2067,15 @@ body.myapps-reordering .myapps-tile {
pointer-events: none;
}
/* The marquee is clamped to the files container but the cursor isn't;
while a rubber-band drag is active, the chrome around the list must
not react to the cursor passing over it (hover highlights, tooltips). */
.dashboard.rubberband-selecting .dashboard-sidebar,
.dashboard.rubberband-selecting .dashboard-section-files .directories,
.dashboard.rubberband-selecting .dashboard-section-files .directory-contents .header {
pointer-events: none;
}
.dashboard-section-files .selection-action-btn {
display: flex;
align-items: center;
+319 -22
View File
@@ -3594,27 +3594,6 @@ fieldset[name=number-code] {
margin: 20px 0;
}
.perm-title {
text-align: center;
margin-top: 0;
padding-bottom: 15px;
font-size: 20px;
font-weight: 400;
margin-bottom: 10px;
color: #4b586a;
text-shadow: 1px 1px #ffffff1c;
}
.perm-description {
text-align: center;
font-size: 15px;
-webkit-font-smoothing: antialiased;
padding: 0 10px;
color: #2d3847;
margin-top: 5px;
margin-bottom: 5px;
}
@-webkit-keyframes pulse-white {
0% {
-webkit-box-shadow: 0 0 0 0 rgb(255, 255, 255);
@@ -6235,4 +6214,322 @@ html.dark-mode .usage-table-show-less:hover {
align-items: center;
height: 100%;
width: 100%;
}
}
/* -------------------------------------------------------------------------
Permission Request Dialog (UIPermissionDialog)
Standalone top-layer <dialog>; follows the OS light/dark scheme.
------------------------------------------------------------------------- */
.perm-dialog {
border: 1px solid #e8e8e8;
border-radius: 12px;
padding: 0;
width: 380px;
max-width: calc(100vw - 32px);
background-color: #fff;
color: #575762;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.18), 0 2px 8px rgba(0, 0, 0, 0.08);
-webkit-font-smoothing: antialiased;
font-family: "Helvetica Neue", HelveticaNeue, Helvetica, Arial, sans-serif;
}
.perm-dialog::backdrop {
background: rgba(0, 0, 0, 0.4);
backdrop-filter: blur(2px);
-webkit-backdrop-filter: blur(2px);
}
.perm-dialog-body {
padding: 28px 24px 18px;
box-sizing: border-box;
}
.perm-dialog-identity {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.perm-dialog-entity-icon {
width: 56px;
height: 56px;
border-radius: 12px;
object-fit: contain;
}
.perm-dialog-letter-avatar {
display: flex;
align-items: center;
justify-content: center;
font-size: 26px;
font-weight: 500;
color: #fff;
background: linear-gradient(135deg, #34a5f8, #2210d7);
user-select: none;
}
.perm-dialog-entity-name {
font-size: 18px;
font-weight: 600;
color: #1f1f2a;
margin: 14px 0 0;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
/* An app's title is author-supplied and HTML-escaping leaves bidi control
characters intact, so isolate it: without this a RIGHT-TO-LEFT OVERRIDE
could reorder the name in the one slot that says who is asking. */
unicode-bidi: plaintext;
}
/* A hostname must never lose its tail to the ellipsis: the registrable domain
is the part that identifies who is asking. `direction: rtl` anchors the text
to its end, so a too-long host is elided from the left instead — what
browsers do in their own origin chips. Latin hosts still read
left-to-right: their characters are strongly LTR, so the bidi algorithm
keeps them in order inside the right-anchored box.
`isolate`, not `plaintext`: plaintext takes the base direction from the
content's own first strong character, which for any Latin host overrides the
`direction` above and puts the ellipsis back on the right — hiding exactly
the part that identifies the requester, so
`accounts.google.com.attacker.example` reads as `accounts.google.com…`.
Isolating still keeps a bidi control character in the host from reordering
anything outside it. */
.perm-dialog-entity-host,
.perm-dialog-entity-origin {
direction: rtl;
unicode-bidi: isolate;
}
.perm-dialog-entity-origin {
font-size: 13px;
color: #8a8a96;
margin-top: 4px;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.perm-dialog-lead {
text-align: center;
font-size: 14px;
color: #575762;
margin: 18px 0 10px;
}
.perm-dialog-permission {
display: flex;
align-items: center;
gap: 12px;
background-color: #f2f5f9;
border: 1px solid #e6ebf2;
border-radius: 10px;
padding: 13px 14px;
margin: 0 0 6px;
}
.perm-dialog-perm-icon {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 9px;
background-color: #fff;
border: 1px solid #e6ebf2;
color: #088ef0;
}
.perm-dialog-perm-text {
font-size: 14px;
line-height: 1.45;
color: #33333d;
overflow-wrap: anywhere;
}
.perm-dialog-error {
text-align: center;
font-size: 13px;
color: #d0342c;
margin: 8px 0 0;
}
.perm-dialog-actions {
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 18px;
}
.perm-dialog-actions button {
appearance: none;
width: 100%;
height: 38px;
border-radius: 8px;
font-size: 15px;
font-family: inherit;
cursor: pointer;
box-sizing: border-box;
-webkit-font-smoothing: antialiased;
transition: filter 0.1s ease;
}
.perm-dialog-actions button:disabled {
cursor: default;
opacity: 0.6;
}
.perm-dialog-allow {
border: 1px solid #088ef0;
background: linear-gradient(#34a5f8, #088ef0);
color: #fff;
font-weight: 500;
}
.perm-dialog-allow:hover:not(:disabled),
.perm-dialog-allow:focus-visible {
filter: brightness(1.05);
}
.perm-dialog-allow:active:not(:disabled) {
background: #2798eb;
border-color: #2798eb;
color: #bedef5;
}
.perm-dialog-allow.perm-dialog-busy {
color: transparent;
position: relative;
}
.perm-dialog-allow.perm-dialog-busy::after {
content: '';
position: absolute;
left: 50%;
top: 50%;
width: 16px;
height: 16px;
margin: -8px 0 0 -8px;
border: 2px solid rgba(255, 255, 255, 0.4);
border-top-color: #fff;
border-radius: 50%;
animation: perm-dialog-spin 0.7s linear infinite;
}
@keyframes perm-dialog-spin {
to { transform: rotate(360deg); }
}
.perm-dialog-deny {
border: 1px solid #d4d4dc;
background: none;
background-color: transparent;
color: #575762;
}
.perm-dialog-deny:hover:not(:disabled),
.perm-dialog-deny:focus-visible {
background-color: #f4f4f7;
}
.perm-dialog-actions button:focus-visible {
outline: 2px solid #088ef0;
outline-offset: 2px;
}
.perm-dialog-footnote {
text-align: center;
font-size: 12px;
color: #8a8a96;
margin: 14px 0 0;
}
/* Popup mode: the dialog is the whole page, so drop the floating-card look */
.embedded-in-popup .perm-dialog {
border: none;
box-shadow: none;
border-radius: 0;
width: 100%;
max-width: 440px;
}
.embedded-in-popup .perm-dialog::backdrop {
background: #fff;
}
@media (max-width: 500px) {
.perm-dialog {
width: calc(100vw - 24px);
}
.perm-dialog-body {
padding: 24px 18px 16px;
}
.perm-dialog-actions button {
height: 42px;
}
}
@media (prefers-color-scheme: dark) {
.perm-dialog {
border-color: #2a2a2e;
background-color: #1e1e22;
color: #d6d6dc;
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.55), 0 2px 8px rgba(0, 0, 0, 0.35);
}
.perm-dialog::backdrop {
background: rgba(0, 0, 0, 0.55);
}
.perm-dialog-entity-name {
color: #e4e4ea;
}
.perm-dialog-entity-origin,
.perm-dialog-footnote {
color: #9a9aa0;
}
.perm-dialog-lead {
color: #b9b9c2;
}
.perm-dialog-permission {
background-color: #26262c;
border-color: #34343a;
}
.perm-dialog-perm-icon {
background-color: #1e1e22;
border-color: #34343a;
color: #4aa8f0;
}
.perm-dialog-perm-text {
color: #d6d6dc;
}
.perm-dialog-error {
color: #ff7466;
}
.perm-dialog-deny {
border-color: #4a4a50;
color: #d6d6dc;
}
.perm-dialog-deny:hover:not(:disabled),
.perm-dialog-deny:focus-visible {
background-color: #2a2a30;
}
.embedded-in-popup .perm-dialog::backdrop {
background: #1e1e22;
}
}
+42 -15
View File
@@ -23,6 +23,7 @@ import item_icon from './helpers/item_icon.js';
import truncate_filename from './helpers/truncate_filename.js';
import update_title_based_on_uploads from './helpers/update_title_based_on_uploads.js';
import update_username_in_gui from './helpers/update_username_in_gui.js';
import { select_uploaded_items } from './helpers/upload_selection.js';
import mime from './lib/mime.js';
import path from './lib/path.js';
import UIAlert from './UI/UIAlert.js';
@@ -1622,6 +1623,40 @@ window.trigger_download = (paths) => {
});
};
/**
* Updates every Trash icon in the UI to reflect whether the trash is empty:
* the taskbar item, desktop items (and shortcuts to Trash), open explorer
* window head icons, and the Dashboard's Files-tab sidebar.
*
* @param {boolean} is_empty - Whether the trash is empty
*/
window.update_trash_icons = function (is_empty) {
const icon = is_empty ? window.icons['trash.svg'] : window.icons['trash-full.svg'];
$('[data-app="trash"]').find('.taskbar-icon > img').attr('src', icon);
$(`.item[data-path="${html_encode(window.trash_path)}" i], .item[data-shortcut_to_path="${html_encode(window.trash_path)}" i]`).find('.item-icon > img').attr('src', icon);
$(`.window[data-path="${html_encode(window.trash_path)}" i]`).find('.window-head-icon').attr('src', icon);
$('.directories [data-folder="Trash"] img').attr('src', icon);
};
/**
* Checks whether the trash is empty, updates every Trash icon accordingly,
* and notifies the user's other open tabs. Call after operations that change
* trash contents without going through window.move_items or
* window.empty_trash (e.g. permanent delete, direct restore).
*
* @returns {Promise<void>}
*/
window.refresh_trash_state = async function () {
// 'strong' consistency: an 'eventual' stat can be served from the SDK
// cache, which is stale right after a trash mutation and — when seeded
// by readdir — lacks the `is_empty` field entirely.
const trash = await puter.fs.stat({ path: window.trash_path, consistency: 'eventual' });
if ( window.socket ) {
window.socket.emit('trash.is_empty', { is_empty: trash.is_empty });
}
window.update_trash_icons(trash.is_empty);
};
/**
* Moves the given items to the destination path.
*
@@ -1760,9 +1795,7 @@ window.move_items = async function (el_items, dest_path, is_undo = false) {
}
// change trash icons to 'trash-full.svg'
$('[data-app="trash"]').find('.taskbar-icon > img').attr('src', window.icons['trash-full.svg']);
$(`.item[data-path="${html_encode(window.trash_path)}" i], .item[data-shortcut_to_path="${html_encode(window.trash_path)}" i]`).find('.item-icon > img').attr('src', window.icons['trash-full.svg']);
$(`.window[data-path="${html_encode(window.trash_path)}" i]`).find('.window-head-icon').attr('src', window.icons['trash-full.svg']);
window.update_trash_icons(false);
}
// moving an item into a trashed directory? deny.
@@ -1978,15 +2011,7 @@ window.move_items = async function (el_items, dest_path, is_undo = false) {
// check if trash is empty
if ( untrashed_at_least_one_item ) {
const trash = await puter.fs.stat({ path: window.trash_path, consistency: 'eventual' });
if ( window.socket ) {
window.socket.emit('trash.is_empty', { is_empty: trash.is_empty });
}
if ( trash.is_empty ) {
$('[data-app="trash"]').find('.taskbar-icon > img').attr('src', window.icons['trash.svg']);
$(`.item[data-path="${html_encode(window.trash_path)}" i]`).find('.item-icon > img').attr('src', window.icons['trash.svg']);
$(`.window[data-path="${html_encode(window.trash_path)}" i]`).find('.window-head-icon').attr('src', window.icons['trash.svg']);
}
await window.refresh_trash_state();
}
}
@@ -2300,6 +2325,10 @@ window.upload_items = async function (items, dest_path) {
operation: 'upload',
data: files,
});
// highlight the uploaded items in the destination
select_uploaded_items(dest_path, files);
// close progress window after a bit of delay for a better UX
setTimeout(() => {
setTimeout(() => {
@@ -2364,9 +2393,7 @@ window.empty_trash = async function () {
window.socket.emit('trash.is_empty', { is_empty: true });
}
// use the 'empty trash' icon for Trash
$('[data-app="trash"]').find('.taskbar-icon > img').attr('src', window.icons['trash.svg']);
$(`.item[data-path="${html_encode(window.trash_path)}" i], .item[data-shortcut_to_path="${html_encode(window.trash_path)}" i]`).find('.item-icon > img').attr('src', window.icons['trash.svg']);
$(`.window[data-path="${window.trash_path}"]`).find('.window-head-icon').attr('src', window.icons['trash.svg']);
window.update_trash_icons(true);
// remove all items with trash paths
// todo this has to be case-insensitive but the `i` selector doesn't work on ^=
$(`.item[data-path^="${window.trash_path}/"]`).removeItems();
@@ -19,6 +19,7 @@
import UIItem from '../UI/UIItem.js';
import item_icon from './item_icon.js';
import { select_added_item_if_pending } from './upload_selection.js';
/**
* Reflect an `item.added` socket event in every open UIWindow item container
@@ -75,6 +76,10 @@ const apply_item_added_to_containers = async function (item) {
$containers.each(function () {
window.sort_items(this, $(this).attr('data-sort_by'), $(this).attr('data-sort_order'));
});
// If this item was just uploaded from this client, its upload `success`
// handler may have run before this element existed — land it selected.
select_added_item_if_pending(item, $containers);
};
export default apply_item_added_to_containers;
+70
View File
@@ -0,0 +1,70 @@
/**
* Copyright (C) 2024-present Puter Technologies Inc.
*
* This file is part of Puter.
*
* Puter is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published
* by the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
/**
* Where to send the user after a successful login/signup started from the
* current page. Keeps the user on the page they authenticated from — most
* importantly direct app landings (`/app/<name>`), so they end up in the app
* they came for instead of the dashboard.
*
* Standalone auth pages (`/action/login`, `/action/signup`, ...) have no page
* to return to, so they go to the root dashboard. Auth-flow query params
* (`action`, `auth_error`) are stripped so the reload doesn't re-open the
* auth window it just completed.
*
* @returns {string} URL to redirect to after successful authentication
*/
export const get_auth_redirect_url = () => {
const url = new URL(window.location.href);
const path_parts = url.pathname.split('/').filter(part => part);
if ( path_parts[0]?.toLowerCase() === 'action' ) {
return '/';
}
if ( url.searchParams.has('auth_error') ) {
// OIDC error companions (see buildErrorRedirectUrl on the backend);
// `message` is only stripped alongside auth_error since on its own it
// may be a legitimate app param
url.searchParams.delete('message');
url.searchParams.delete('request_code');
}
url.searchParams.delete('action');
url.searchParams.delete('auth_error');
return url.toString();
};
/**
* The `return_to` path to send along when starting an OIDC flow, or null if
* the current page isn't one the backend will return to. The backend strictly
* whitelists these (never a client-supplied URL): `/desktop`, `/dashboard`,
* and direct app landings (`/app/<name>`), so OIDC login started from an app
* landing comes back to the app.
*
* @returns {string|null} whitelistable pathname, or null
*/
export const get_oidc_return_to = () => {
const pathname = window.location.pathname;
if ( pathname === '/desktop' || pathname === '/dashboard' ) {
return pathname;
}
// app landing: normalize away a trailing slash to match the backend whitelist
if ( /^\/app\/[^/]+\/?$/.test(pathname) ) {
return pathname.replace(/\/$/, '');
}
return null;
};
Loaded 100 of 200 files, more files were not shown because too many files have changed in this diff. Show more