Compare commits

...
160 Commits
Author SHA1 Message Date
Daniel Salazar efc0ab7126 docs: refresh KV sharing, Events and Notifications docs; fix router CORS and KV size error (#4445)
* docs: refresh KV sharing, Events and Notifications docs and recipes

Bring the KV, Events and Notifications reference pages and the recipes that
use them in line with current behavior, and tighten them.

- KV: share levels, re-share and recipient rules, handle scoping, list
  limits, the 399 KB value limit, and full error tables per method.
- Events: kv:<handle> subjects, what each way of losing access does to a
  subscription, fetch() notification types, handler ownership, and accurate
  anchor-deletion and deleted-app behavior.
- Notifications: where a push goes, desktop and website behavior, sensitive
  taps, share-notify limits, and enable()/status() errors.
- Recipes: fix broken code paths (undefined helpers, single-page share
  listings, read-modify-write races, oversized batch writes, missing value
  fallbacks), use events.update(), onlyIfAbsent and batch share recipients
  where they fit, and cut repetition.
- New recipes: Add a Notification Settings Switch, Clean Up Background Work.
- Rename push-when-closed to reach-a-user-when-your-app-is-closed, drop
  playground examples that need an app the reader owns, and redirect both.

* fix(workers): send CORS on router 404s and readable 500 bodies

Unmatched paths and methods now carry Access-Control-Allow-Origin like
every other router response, so a browser sees the 404 instead of a CORS
failure. A handler response with immutable headers is copied rather than
turned into a 500, and a thrown puter.js error object is sent as JSON.

* fix(kv): one error code for oversized values, and SDK types that match the store

The store now rejects a value over 399 KB with value_too_large, as the
SDK's own check and a growing path write already did. get() declares its
batch form, and the JSDoc gives the 399 KB limit and says watch shares
can list keys.

* docs(notifications): notifications sent within 250 ms reach live listeners as one

* test(events): give the per-handle subscription cap test room on a loaded runner

It fills the whole cap of 1,000 session subscriptions one add at a time,
which went past vitest's 5 s default on a busy CI shard.
2026-11-01 20:27:15 -07:00
Daniel Salazar ef99893fc8 fix(notifications): a browser removed in Puter stays off, and status() checks with Puter on websites (#4458) 2026-11-01 19:50:51 -07:00
Daniel Salazar f8dc10268e test(kv): give the batch-deadline retire test room on a loaded runner (#4444)
"carries on from its record when one run runs out of time" seeds and
retires a full batch of share handles. It takes under a second alone but
went past vitest's 5 s default on a busy CI shard. The timed-out body kept
running with Date.now moved ahead, and "runs once when two runners race
for it" then failed with it.
2026-10-31 14:52:26 -07:00
Daniel Salazar 5d34e803ff fix(events): delivery path fixes for queued deliveries, handler failures and per-invocation database load (#4443)
* fix(events): queue every row's single delivery before any handler runs

A dispatch queued each matched single row's event and then waited on that
row's hand-over, which can be a whole handler run, before queueing the next
row's. An event matching several rows queued the later ones only after the
earlier handlers finished, and lost them if the process stopped in between.
A batched filesystem write did the same across its entries.

The event is now queued in line and the hand-over runs detached; anything it
doesn't finish is retried by the pending sweep. A hand-over that throws no
longer raises the 'could not be queued and is lost' alarm, since the event
is still queued.

* perf(events): address an app's events worker from a cached handler set

Every handler invocation read the app's handler set (names and hashes)
from the primary database to work out which script to call. The delivery
path now reads it through a Redis cache. Publishing, updating or removing a
handler, and deleting the app's handlers, drop the entry on this node and
every peer, so only a read that raced one of those writes can address the
superseded script, for at most 30 seconds. An empty set isn't cached.
Deploys and the events-worker listing still read the primary.

* perf(events): skip leased queues without a database read, and sweep in parallel

The pending sweep read each queue's subscription row from the primary
database before finding out whether anything in the queue could be claimed.
A queue whose head is in flight or waiting out a retry now goes to the back
of the line on a Redis check alone; its row is read once the lease lapses.
The backstop re-check of a queued row's access waits for that lapse too, and
nothing is handed out in the meantime.

The pass also worked through its subscriptions one at a time, so one slow
handler run held up every queue behind it. It now works on up to ten at
once.

* fix(events): don't count a wait for a dispatcher connection as a handler timeout

Invocations share one pool of connections to the events dispatcher per
process, and the invoke timeout started when a call was made, not when it
got a connection. With the pool busy, a call could spend its timeout
waiting, be reported as a handler that ran past it, and count toward
suspending a subscription whose handler never ran.

A call now waits for a free connection before its timeout starts. A call
that waits more than half the timeout for one fails as Puter's
(unavailable): retried on the usual schedule and not counted, as the
documented failure rules already say. Wait and run together stay inside the
worker lease.

* fix(events): end a background row whose consent a delivery finds withdrawn

Withdrawing an app's events:background consent is settled by a listener on
the node that handled the revoke. If that settle never ran or failed, the
row stayed in service: every delivery found the consent gone, minted no
token and was deferred, so the backlog was retried forever and the row was
never suspended or reported.

When the delivery path's consent check answers no, it now runs the same
revocation settle (permission_revoked, backlog purged, holder told, worker
session revoked), one at a time per holder and app. A consent check that
throws still only defers the delivery.

* perf(auth): record no session or user activity for events handler calls

Every API call an events handler made authenticated through its worker
session and touched the session and the user, so background handler runs
wrote session and user activity rows on the primary database (each at most
once a minute), and replaced the session's last IP and user agent with the
worker runtime's.

Calls made with an events handler token no longer touch either. A handler
run isn't the user being active, and the handler session expires only when
it is revoked, so nothing reads its activity to keep it alive. The handler
session's last activity in the session list now reads as when it was
created.

* fix(events): settle withdrawn consent only on a fresh answer

The delivery path's consent check reads through the permission scan cache
and the cached user-to-app grants, which can trail a grant, for example on
a node that cached the app's grants before the consent was given. A stale
no then ended the subscription for good (permission_revoked, backlog
purged, holder told, worker session revoked), where before it only
deferred the delivery until the caches caught up.

Before settling, the no is now asked again with a new `primary` scan
option, which skips the scan cache and reads user-to-app grants from the
primary database. Only a fresh no settles. A fresh yes, or a check that
throws, leaves the delivery deferred.

* test(events): assert no forwarded delivery, not no traffic at all

The region's own connect announces presence to its peers on the forward
queue's next flush. Now that a dispatch returns before the hand-over, the
wait for the socket send can outlast that flush, and the test saw the
presence announce as forwarding.

* fix(events): budget the settles deliveries start for withdrawn consent

A settle a delivery starts when it finds background consent withdrawn ends
rows for good. If the permission check itself answered no wrongly, every
row a delivery reached would be suspended, its backlog purged and its
holder told, with nothing to bring it back.

Each process now settles at most 20 such pairs a minute. Past that, the
rest stay deferred, as a cached no always left them, and a warning alarm
says so. A spent budget skips the fresh read too. A pair a fresh read just
found consented isn't read again for a minute, so a stale cached no costs
one primary read rather than one per delivery. Each settle is logged.
2026-10-31 14:52:06 -07:00
Filip Kujundžić a40e4d984a Merge pull request #4339 from sabrimete/fix/alibaba-catalog-puter-ids 2026-10-31 21:09:05 +01:00
Daniel Salazar dd144b6b64 feat(notifications): notify through KV shares, group replacement, fewer push prompts, and events/worker fixes (#4432)
* feat(perms): add a pushNotifications resource to puter.perms.request

notifications:push gets a resource like backgroundEvents, so it can be
batched with other access behind one prompt. requestPermission() and
hasPermission() ask through it.

* fix(puter-js): read puter.gui_features once at load

The desktop's feature list was read from location.search on each call, so
an app whose router rewrote the URL lost it and enablePush() and the other
desktop dialogs answered as if the desktop lacked them.

* feat(gui): ask for notifications:push when an app calls enablePush without it

enable() and enablePush() inside the desktop answered unavailable for an
app without the push permission. They now show the permission dialog,
whose Allow also starts the browser registration, and answer with the
browser's push state once it settles, or off when denied. A browser that
blocks notifications or has no push service still answers without a
dialog.

* fix(kv-share): charge mint and grant budgets only for share calls that change something

A share call now spends the per-call mint budget and the per-recipient grant
windows after it has looked up which recipients already hold the share. A call
whose recipients all hold the share at the requested level and label spends
neither. Recipients without the share, including unknown usernames and refused
recipients, still spend a grant unit each. Every named recipient spends from a
new 1,200/min lookup window before any lookup runs.

* perf(events): skip the subject split for rows not made through a share handle

kvHandleFromSubject checks the stored subject's kv:kvh- prefix before
splitting it, so own-namespace and fs rows return null without parsing.

* feat(notifications): resolve enable() on a website before the confirmation push

enable() waited up to 30 seconds for the confirmation push, so a prompt
the same click started next had lost its activation. On a website it now
resolves once the browser answered and the server has the subscription,
records the opt-in, and finishes the confirmation in the background. A
failed confirmation goes to onPush's onError and the next load's resync
tries again. disable() and sign-out stop a confirmation still waiting.
The GUI keeps waiting, since it reads the registered device right after.

* fix(notifications): show a page's own notifications only while push is on

A hidden page with an onPush handler kept showing notifications after
disable(), and its receipts kept claiming showsOwn, which held pushes back
from the user's other devices. Both now need the user's opt-in, cached in
the page, refreshed on enable, disable, sign-out and each sync, and re-read
when another tab of the site changes it.

* fix(events): leave subscriptions suspended for good out of the durable quota

countForHolder no longer counts rows suspended with a reason that never
resumes (permission_revoked), in both the pre-insert check and the recount
after insert. Rows suspended for a resumable reason still count. The store now
owns the list of terminal reasons, and isResumable reads it.

listActiveForHolder with includeSuspended returns only rows suspended for a
resumable reason, so its quota-sized read still covers every row a revocation
has to settle. Each holder keeps at most 500 terminal rows; a subscribe past
that deletes the oldest ones.

* feat(notifications): add status() to read whether pushes reach this browser

status() resolves 'on', 'off', 'blocked' or 'unavailable' without asking
anything. On a website it reads the notification permission, this
browser's subscription and the user's opt-in. Inside the desktop a new
pushStatus IPC, advertised as push-status, answers the browser's push
state, 'on' only while the app holds notifications:push.

* feat(notifications): disable() inside the desktop revokes the app's push permission

disable() did nothing inside the desktop. A new disablePush IPC,
advertised as disable-push, revokes the calling app's notifications:push
grant through the same route Settings uses, so its notifications stop
going out as pushes and a later enable() shows the permission dialog
again.

* feat(notifications): allow @ in a notification group, up to 128 characters

A group is an opaque display tag, hashed for the Web Push topic and used
as the notification tag, so it can carry identifiers such as an email
address.

* docs(notifications): say when a page with an older Puter.js holds a push back

* fix(notifications): let disable() win over an enable() still in flight

An enable() whose subscription reached the server after a disable() went
on to record the opt-in, so the next page load turned pushes back on. It
now rejects with push_verification_canceled and records nothing.

* docs(notifications): list push_verification_canceled and reword sentences that led with code

* fix(events): carry KV values through a share handle only at read or write

The delivery re-check now reads the share's level off the live handle along
with whether it still holds. A row on a watch share loses its includeValue for
that delivery, so its events name the key without the value; the owner's own
rows on the handle keep theirs. Decided per delivery, so raising or lowering a
share applies to subscriptions already made, and subscribing with includeValue
at watch is still accepted.

The live-updates recipe now shares at read, which its events need to carry
the score.

* fix(events): retry worker runs Puter could not complete without counting them

Invocations that never reached the handler (transport and dispatcher errors,
unmarked answers, failed or throttled deploys, no configured runtime, a bad
invoke key or missing token) now report an 'unavailable' outcome. So does a
handler that throws after one of its own puter.js calls got a 5xx, 429 or
network error from Puter's API, which the events runtime reports as
'platform-error'. Those runs back off like retriable ones, honor the answer's
retry-after up to the backoff ceiling, and no longer count toward the
consecutive-failure suspension. A run past the invoke timeout still counts.

* test(notifications): cover the pushNotifications resource and status() in the API suites

* test(gui): Allow on a batch that includes notifications:push turns push on

* fix(events): hold a share-handle row's backlog when its share can't be checked

The share-handle delivery check now tells a share that is gone (handle not
live, not the row's holder, identity gone, grant withdrawn) from one it can't
check right now (a lookup that throws, an owner who is suspended). Only the
first settles a queued row as permission_revoked and purges its backlog. The
second delivers nothing, leaves the row in service with its backlog, and sends
it to the back of the sweeper's line, so delivery resumes once the lookup
recovers or the owner is unsuspended. A revocation settle skips such a row and
leaves it to the delivery check. kvSharedRegionAuthorized returns null when
the permission check throws, and that answer is not cached.

* feat(events): update a persistent subscription's context in place

POST /events/subscriptions/update replaces a durable row's context, with the
same validation and 4 KB cap as subscribe, keeping its subId and backlog. The
row's owner region cache is rebuilt from the table and the generation bumped so
every region delivers the new value. It spends the subscribe call budget.

puter.events.update(subId, { context }) wraps it; null clears the context, and
a function handler this client registered for the subscription gets the new
ctx.

* fix(events): serve a fan-out's holders in turns and cap values per delivery

#route now orders an event's candidate rows before any cap applies: the
keyspace owner's rows first, then the other holders taking turns, one row per
holder per round, starting from a random holder each event. The filter
evaluation cap, the delivery cap and the gap markers all count in that order.

Values no longer drop for every delivery once more than 128 rows match or the
filter evaluation stops early. The first 128 deliveries that asked for the
value carry it, in serving order, and later ones carry the key alone.
EVENTS_KV_VALUE_OMIT_MATCHED_SUBSCRIPTIONS becomes
EVENTS_KV_VALUE_DELIVERIES_PER_EVENT. The caps themselves are unchanged.

* fix(email): file inbox mail as a delivery, not the request actor's write

storeInboxMessage and copyIntoInbox now run their filesystem writes with a
platformWrite context flag, and fs event dispatch leaves actingUserId unset
under it. Mail filed into an account's inbox from inside that account's own
request (an app mailing the signed-in user) is delivered with self: false, the
same as external ingress. Handler depth and viaWorker are still carried from the
actor.

* feat(notifications): raise the default daily app message limit to 1000

The per-day default for `app.message` goes from 300 to 1000 per user and
app, matching the daily push cap. The per-minute default stays at 30, and
`notificationAppMessageLimits` still overrides both.

* feat(notifications): look up an unseen notification by group

`notification` gets a nullable `group_key` column, a sha256 hex digest of
the group a row was written with, indexed on (user_id, app_uid, group_key).
`NotificationStore.create` takes a `group`, and `getUnseenByGroup` returns
the newest row an app wrote for a user under that group that is neither
shown nor acknowledged. Rows written before the column exist never match.

`updateValue` takes `onlyUnseen`, which also skips a shown row, and
`refreshCreatedAt`, which dates the row to now.

Migrations: sqlite 0098, mysql_mig_52, postgres_mig_41.

* feat(notifications): replace an app's unseen message in the same group

An `app.message` with a `group` now replaces the app's earlier notification
to the same user with that group while it is neither shown nor
acknowledged. The row keeps its uid and takes the new value and time. With
no group, or once the earlier one was shown or dismissed, it is a new
notification as before.

A replacement spends a per-minute slot but no per-day slot. If the earlier
row is marked between the lookup and the rewrite, the message is written as
a new notification and spends a day slot then.

`app.message` is groupable, so the replacement goes through
`notifyUpdate`. For types kept off the desktop, `notifyUpdate` skips the
desktop wire and dispatches the rewrite again under the same uid, so app
subscribers and receipts see it, and `notif.written` carries `updated`.

The driver's write path takes the recipient id rather than the actor, so
rate limits key on the recipient.

* feat(workers): report the app a worker runs as

workers.create now returns app_uid and app_name for the app its token is bound
to (null for a user-scoped worker), and list/get items carry app_name beside
app_uid from the app rows they already load. The dev center workers table shows
an App column, and the SDK typedefs and docs describe the fields and that a
sandboxed worker's notifications and pushes come from its sandbox app.

* feat(events): refuse handlers with free variables on REST publish

POST /events/handlers/publish and /publishAll now run the SDK's own
scanHandlerSource on each source before publishing and answer 400
events_handler_free_variable, naming the identifier. The backend imports the
module from src/puter-js; the build marks dist/src/puter-js as an ES module
package so the compiled backend can require it. Sources over the per-handler cap
are left to the store's size refusal rather than tokenized.

* docs(events): say Puter-side retries never suspend and stay within backlog caps

* fix(events): redeploy an events worker that answers bad-key

A script deployed before the internal secret rotated keeps the old invoke key,
so every invocation answered bad-key and nothing redeployed it. A bad-key answer
now goes through the same miss handler as a dispatcher miss (the deployer, with
its hourly throttle) and is retried once with the deployed header. A throttled
or failed redeploy stays 'unavailable' and backs off without counting.

* perf(events): check the deploy throttle before reading handler sources

EventsWorkerDeployer now reads the app's handler names and hashes first,
answers no-handlers or stale from those, and checks the hourly deploy throttle
before loading the sources and generating the worker. A throttled retry costs a
hash read and a counter bump instead of a full source read and build, and a
stale request still does not spend the throttle. The generated set is checked
against the script again in case it moved between the two reads.

* fix(workers): move a redeployed worker's row to the app it now runs as

Redeploying an existing worker under a different app minted the new app's
token but left subdomains.app_owner on the old one, so list/get and hot-reload
kept naming and minting for the old app. The redeploy now sets app_owner to the
binding #resolveWorkerAppBinding authorized (null for a user-scoped worker)
through a new SubdomainStore.setAppOwner, since app_owner stays read-only to
update patches.

* fix(workers): revoke the old binding's worker session on a rebind

A redeploy that binds a worker to a different app (or to none) now deletes the
owner's worker sessions under that name other than the one for the new binding,
through the same deleteWorkerSessionsByName path worker deletion uses (which
also announces the revocation and drops access-token grants). SessionStore's
deleteWorkerSessions takes an exceptAppUid filter for it, null meaning the
user-scoped session. The app_owner move and the revocation now both run only
after the new script deployed successfully, so a failed redeploy leaves the
live script, its token and the row on the old binding.

* feat(kv-share): store a notify flag on share handles

kv_share_handles gains a notify column, off by default, so every existing
share reads as not allowing notifications (sqlite 0099, mysql_mig_53,
postgres_mig_42). The store reads it on every row, including the live
handle cache and the holder listing, writes it on mint, and changes it
through update().

* feat(kv-share): let the owner turn notifications on for a share

POST /kv/shares and puter.kv.share() take notify, off by default. Leaving it
out of a re-share keeps the stored value, and turning it on or off counts as a
change, so it spends the mint budget. Turning it on for a holder who now
refuses the owner's shares is refused, like a raise or a new label.

The kv.share.received notice is sent only when a share starts carrying
notify: a new share made with it, or an existing one switched on. Its hourly
bound and the app-less namespace exclusion are unchanged.

The share response, the owner's listing and the holder's listing report
notify.

* feat(kv-share): resolve the other party of a share for a notification

KvShareService.resolveNotifyRecipient(actor, handle) returns the recipient
user id, app uid and handle for a notification one party of a share sends
the other through its handle. The handle must be live with an unsuspended
owner, the caller must be its owner or holder acting from the app whose data
it is, the share must have notify on, the holder's grant must still stand,
and the recipient must not refuse shares from the sender.

An unknown, retired or foreign handle, a caller who is not a party, and a
caller in another app all get subject_does_not_exist. The account itself gets
forbidden, a share without notify kv_share_notify_not_enabled, and a lookup
that fails service_unavailable. Once every check passes it spends one unit
from KV_SHARE_NOTIFY_LIMITS (30/min, 500/day per sender, recipient and app),
peeking both windows first so a refusal spends nothing.

* feat(notifications): hand a replaced notification to listeners again

A replacement now records `replaced_at` (epoch ms) on its row, added to the
same unreleased migrations as `group_key` (the sqlite file is renamed to
0098_notification-groups.sql). Its live event and its fetched copy both
carry `replaced: true` and use that time as `ts`, so the pair versions the
uid identically on both paths.

`onNotification()` dedupes on uid plus that version: an original counts as
version 0 and a replacement as its `ts`. A newer replacement is handed over
again with `replaced: true`, while a copy already handed over, a replay, or
an older state is dropped. Listeners started with `catchUp: false` still
get replacements of what was already there.

* fix(notifications): replace only the newest notification in a group

The group lookup takes the newest row an app wrote under the group and
returns nothing when that row was shown or dismissed, instead of falling
back to an older row that is still unseen.

The docs state the rule as it is: a grouped notification replaces the
newest one in its group unless that was shown or dismissed. Puter.js
marks neither, and the only thing that does is a tap on the push inside
the Puter desktop, which dismisses it.

* feat(notifications): notify the other party of a KV share through its handle

`app.message` takes `to: { handle }`. The driver validates the message,
then `to` must be an object with only a non-empty string `handle`, else
400. `KvShareService.resolveNotifyRecipient` checks the share and spends
the pair limit, and the message is written for the share's other party in
the share's app. The recipient's per-app limits and group replacement
apply to their own rows, and the push is billed to them through
`notif.written`.

A call with `to` answers with `{ uid }` only, so nothing about the
recipient's row reaches the sender. puter.js gets the
`NotificationRecipient` type for `to`.

* docs(notifications): document notifying the other side of a KV share

notify.md covers `to: { handle }`: who can be reached, the errors, the
recipient's limits plus the per-pair share limit, the uid-only return and
that the recipient pays for pushes. push.md and rate-limits-and-quotas.md
point at the share path. The chat notification recipe sends through each
share's handle and keeps the per-member handler only for members who need
to reach each other; the alert recipe says when its handler is the right
fit.

* fix(notifications): reveal nothing to the sender of a notification through a share

A call with `to: { handle }` answers with an empty object, and puter.js
resolves it with no value: a uid would show whether the recipient's last
notification in a group was replaced. `notify()` gets one overload per
form, and `ShareNotificationInput` is exported.

Over the per-pair share limit or the recipient's own limits, such a call
gets the same 429 `too_many_requests` with the same generic message, so the
sender can't tell which limit it hit.

* fix(notifications): serialize grouped app messages per group

Grouped app.message sends take a Redis lock on (recipient, app, group) around the lookup and the replace-or-insert, so concurrent sends to an empty group write one row and spend one day slot. A lock still busy after the wait, or an unreachable Redis, sends unlocked as before. Sends without a group take no lock.

* fix(events): attribute Puter API failures to the invocation that saw them

The events worker runtime counted Puter API failures on the cached per-token client, so a concurrent invocation on the same token could answer platform-error for its own unrelated throw. A cached client now serves one invocation at a time and records failures and retry-after on that invocation; an invocation overlapping another on the same token gets its own client, and idle clients stay cached per token.

* fix(events): drop KV values from a queued backlog once its share is at watch

A queue delivery is stored with its value. Every hand-out of a stored
entry (backlog drain to a socket or another region, the worker, and a
declined delivery passed to the worker) now re-reads the share's level
and removes the value at watch. A share that can't be checked still
holds the backlog.

* fix(events): return notifications replaced behind the fetch cursor

The notification fetch cursor now holds the last row id and the last
(replaced_at, id) it returned. A read from it merges two indexed reads:
rows inserted after the id, and rows up to the id replaced after that
mark. A replacement made while a client was away therefore comes back
on its next catch-up. Adds idx_notification_replaced on (user_id,
audience, app_uid, replaced_at).
2026-10-31 12:21:18 -07:00
Daniel Salazar 4851df716c fix(kv): KV share lifecycle hardening (#4429)
* fix(kv): harden the KV share lifecycle

- Retire a deleted app's live shares before an origin app is registered
  again under the same uid. The retire runs before the app row exists and
  throws on failure, so a failed launch leaves no row and the next one
  retries.
- Record deleted-app retires and uninstall cleanups (retire, then purge) as
  reserved items before they run. A lease keeps one node on a job; a failed
  run raises an alarm and backs off, and a run that hits its time budget or
  is cut short by a restart resumes on the next check. Each job is bounded
  to handles made before it was recorded.
- Uninstalling with deleteData records the job and returns; the retire and
  purge run off the request. The purge checks whether the app was
  authorized again before deleting each page, so data written after that
  is kept.
- Take a per-region lock when minting and re-check for a live handle under
  it, so concurrent shares of one region make one handle.
- Retire the handles an account holds before the account is deleted, so the
  owners' subscriptions through them end instead of staying active.

* fix(events): bound share-handle session rows by the per-connection cap

The count across a holder's connections also counted connections that
had dropped but weren't reaped yet (a crashed node, a deploy, a network
blip the server hasn't noticed), so resubscribes after a reconnect could
be refused with `events_subscription_limit`. Rows made through a share
handle now count toward the 50-per-connection cap like any other, and
with the open-connections cap per account that bounds what one holder
can put in the owner's keyspace.

The handle index stays for ending rows on retire. Rows are added to it
with the row itself and taken out when the row is dropped, and members
whose row is gone are cleared when a retire reads it.

* fix(events): keep a retire's durable settle running if ending sessions fails

Ending a retired handle's session subscriptions reads Redis. A failure
there is now logged and the durable suspension that follows still runs,
so those rows' backlogs are still purged.

* perf(events): ask once per handle when a refresh screens rows, and share handle reads

A socket refresh asks whether a share handle still stands once per
handle, holder and app, rather than once per row: rows through one
handle share its grant. Concurrent cache misses on one handle share a
single read; a forget drops the shared read so later callers start a
fresh one.

* fix(permissions): chunk the exact-match subtree delete

The holder-scoped subtree delete names every matched grant, so a large
subtree could exceed an engine's limit on bound parameters. It now
deletes 500 grants per statement.

* fix(kv): give each holder a share of a namespace's handle budget

One holder could spend a namespace's whole handle budget and starve the
others. Each holder now gets at most 3,750 of the namespace's 15,000
units per 10 seconds, and the owner's own calls through a handle spend
none. The holder's budget is checked before the grant lookups, so
throttled retries cost none; the namespace's is only told to callers the
grant admits. Multi-unit charges use a new counted claim that keeps two
counters per key instead of a sorted-set member per unit.

* fix(kv): refuse handles older than their app and claim cleanup jobs globally

- A share handle created before its app row reads as gone: in the KV
  driver, in the handle cache, on the events subscribe path, and in the
  delegate implicator. An origin app registered again under a deleted
  app's uid can't reach its predecessor's shares, whatever created the row.
  The app-token launch no longer retires inline; it records a background
  retire and never waits on it or fails because of it. The clock-skew rule
  is shared with app sessions.
- Cleanup job records now carry their lease, attempts and retry time, and
  a node claims a job by compare-and-set on its record. A job stays with
  the deployment that recorded or last ran it, and another one adopts it
  only after it has gone unclaimed well past due.
- The job check pages through records from where the last look stopped, so
  waiting records don't hide due ones. After 10 failed runs a job stops
  retrying with a warning alarm, and its record expires after 30 days.
- A mint that waits out the region lock looks for the share again and
  returns it, or goes ahead, instead of failing with too_many_requests.
- The record-failure alarm dedups; an unfinished run is a log line.

* fix(kv): hide shares older than their app from re-shares and listings

After an app is registered again under a deleted app's uid, its
predecessor's live handles stayed visible: sharing the same region again
handed back the old, dead handle, and both sides' listings showed the old
handles and labels. findLive and both listing queries now leave out a live
handle created before its app row, the same rule handle resolution uses,
so a re-share makes a new handle and the listings, totals included, skip
the old ones until their retire lands.

A new share also retires, grants and all, the handles its owner gave the
same holder for the earlier app first, so withdrawing those later can't
take the new grant with it.

* fix(events): keep a loose cap on subscriptions through one share handle

Without a count across connections, a holder could put up to 20,000 rows
in the owner's keyspace (50 per connection, 400 connections), each one
evaluated on every owner write. The cap is back at 1,000 per holder per
handle, far above what connections that dropped without being reaped
leave behind. The index is only read row by row once it's past the cap.

* fix(kv): harden the share cleanup jobs

- Job records are keyed by domain, so a deployment that shares the table
  but not the database or broadcast (staging beside production) never
  runs another's jobs.
- A job whose record can't be written runs unrecorded instead of not at
  all, and a failed check for live handles records the job anyway so it
  retries. Failures that used to be swallowed are logged.
- Recording a job again keeps its attempts and backoff; one that gave up
  gets one more attempt.
- A system retire gives back the grant of a live share it doesn't cover
  inside a withdrawn region, and a share made under a reused uid grants
  again once its row stands, so a concurrent retire can't leave a live
  handle without its grant.

* fix(kv): don't cache a live handle a retire overtook during its app lookup

The live read decided whether it could cache before awaiting the app
lookup, so a retire's forget that landed during that lookup was
overwritten with the handle as live for up to 60 s. It's now checked
after the lookup.

* chore(kv): word the job key comment for any install
2026-10-31 12:12:29 -07:00
Daniel Salazar faa8cd99dd fix(kv): per-holder share of the KV handle budget (#4433)
* fix(permissions): chunk the exact-match subtree delete

The holder-scoped subtree delete names every matched grant, so a large
subtree could exceed an engine's limit on bound parameters. It now
deletes 500 grants per statement.

* fix(kv): give each holder a share of a namespace's handle budget

One holder could spend a namespace's whole handle budget and starve the
others. Each holder now gets at most 3,750 of the namespace's 15,000
units per 10 seconds, and the owner's own calls through a handle spend
none. The holder's budget is checked before the grant lookups, so
throttled retries cost none; the namespace's is only told to callers the
grant admits. Multi-unit charges use a new counted claim that keeps two
counters per key instead of a sorted-set member per unit.
2026-10-31 12:10:39 -07:00
Daniel Salazar 279bb8beb6 fix(events): KV share handle subscription follow-ups (#4434)
* fix(events): bound share-handle session rows by the per-connection cap

The count across a holder's connections also counted connections that
had dropped but weren't reaped yet (a crashed node, a deploy, a network
blip the server hasn't noticed), so resubscribes after a reconnect could
be refused with `events_subscription_limit`. Rows made through a share
handle now count toward the 50-per-connection cap like any other, and
with the open-connections cap per account that bounds what one holder
can put in the owner's keyspace.

The handle index stays for ending rows on retire. Rows are added to it
with the row itself and taken out when the row is dropped, and members
whose row is gone are cleared when a retire reads it.

* fix(events): keep a retire's durable settle running if ending sessions fails

Ending a retired handle's session subscriptions reads Redis. A failure
there is now logged and the durable suspension that follows still runs,
so those rows' backlogs are still purged.

* perf(events): ask once per handle when a refresh screens rows, and share handle reads

A socket refresh asks whether a share handle still stands once per
handle, holder and app, rather than once per row: rows through one
handle share its grant. Concurrent cache misses on one handle share a
single read; a forget drops the shared read so later callers start a
fresh one.

* fix(events): keep a loose cap on subscriptions through one share handle

Without a count across connections, a holder could put up to 20,000 rows
in the owner's keyspace (50 per connection, 400 connections), each one
evaluated on every owner write. The cap is back at 1,000 per holder per
handle, far above what connections that dropped without being reaped
leave behind. The index is only read row by row once it's past the cap.
2026-10-31 12:10:34 -07:00
Daniel Salazar 52aedde3c5 fix(kv): KV share authz and load hardening (#4430)
* fix(permissions): delete user-to-user grants by their exact spelling

MySQL compares the permission column case-insensitively, and LIKE folds
case on MySQL and SQLite, so withdrawing a grant could also remove one
spelled differently, which checks and the flat view treat as distinct.
The single, batch and subtree deletes for one holder now match the
permission byte for byte.

* fix(auth): refuse key-value share grants on /auth/revoke-user-user

Withdrawing a share's grant there left its handle live and listed. The
route now refuses kv-share permissions and points to puter.kv.unshare().

* perf(kv): ask whether a delegate app is authorized once per check

The app-delegate implicator re-read the app's authorization for every
option a refused check offered, one read per ancestor of a deep key.

* fix(puter-js): default a kvShare consent request to read

puter.kv.share() creates read shares by default, so a consent asked for
with the defaults (watch) didn't cover the share made with the defaults.

* fix(kv): treat a private entry as missing for a share holder's writes

Through a handle, a private entry reads as absent, but writes to it were
refused with an error naming it as private, and onlyIfAbsent answered
false. Deleting one through a handle now answers as deleting a missing
key does and leaves the entry in place. Any other write is refused with
kv_handle_access_denied, the same error as a call the share's level
doesn't allow, onlyIfAbsent included. Cross-app access is unchanged.

* fix(kv): cap handle traffic per owner namespace

Every call through a share handle lands on its owner's namespace, and
per-holder limits put no bound on what all holders together could send
there. Handle calls into one namespace now share a budget of 6,000 units
per 10 seconds, counted from the storage reads and writes each call
consumes (at least one), and are refused with too_many_requests past it.
2026-10-31 01:01:53 -07:00
Daniel Salazar 7f832ede43 fix(events): KV share handle subscription hardening (#4428)
* fix(events): keep a share's prefix out of handle subscribe errors and limits

A `kv:<handle>:<key>` pattern is now checked against the 256-character
limit as the holder wrote it, before it's composed onto the shared
prefix. The composed pattern is compiled with room for the longest
prefix a grant can carry, at subscribe and at dispatch, so neither the
limit nor its error depends on the prefix.

A handle subscription's `match` is read off the holder's own subject
instead of the stored composed pattern, so whether it's `null` no
longer depends on how deep or long the prefix is.

* fix(events): cap and end session subscriptions made through a share handle

Session subscriptions made through a share handle are indexed under the
handle in the owner's keyspace. A holder may keep 100 of them through
one handle across all their connections; past that, subscribe fails
with `events_subscription_limit`. The owner's own subscriptions through
the handle don't count.

Retiring a handle now ends its session subscriptions in the same
region and sends `events.ended` with `permission_revoked`, alongside
the existing durable settle. A socket refresh no longer renews a row
whose handle was retired or whose grant is gone: it ends it the same
way, which is how a retire made elsewhere reaches these rows.

* fix(kv): read a forgotten share handle from the primary

After a handle or its owner is forgotten, reads of that handle go to
the primary for the next minute, so a replica that hasn't caught up
can't put the old row back in the cache. A read that a forget lands on
while it's in flight is answered but not cached.

* fix(events): read a socket ref's subId after the last `|`

A KV token carries the subscribed key, which can contain `|`, so
splitting the ref at the second one cut the token short. A subscription
on such a key couldn't be unsubscribed, and a reap left its row behind
in the owner's keyspace. SubIds never contain `|`, so the ref is split
at the last one.
2026-10-31 00:58:42 -07:00
Daniel Salazar 58275ccd52 ci(puterjs): check out the coverage base leg from this repo (#4431)
The base leg checked out the base branch from the PR's head repo. For a
fork PR that is the fork's copy of main, which can predate the current
Node version and fail npm ci.
2026-10-31 00:42:37 -07:00
Daniel Salazar 11114c80d6 fix(auth): fail closed on otp-login token reuse check (#4421)
/login/otp and /login/recovery-code marked the otp-login token used
through a rate-limit check that admits on a store error, so while the
store was unavailable a captured token could complete another sign-in.
The check now fails closed.

Also drops the check-translations scripts and the AGENTS.md step that
runs them; the script they call was removed in #2873.
2026-10-31 00:26:56 -07:00
Daniel Salazar 5d907d18c9 fix(socket): bound acks a receipt ask leaves on sockets that never answer (#4422)
An acked broadcast leaves its callback in socket.io's per-socket ack map on every socket it reaches, and nodes other than the asker free it only when that socket answers or disconnects. Each socket's map is now a BoundedTtlMap capped at 64 entries and 60 s, so a socket that never answers holds a bounded number and a late answer is ignored. BoundedTtlMap drops expired entries on set so its TTL frees memory too.
2026-10-31 00:26:44 -07:00
Daniel Salazar 2d5e037599 fix(ai): bill realtime responses the upstream drops before reporting usage (#4423)
When the upstream realtime connection closes, for any reason, with a
started response that never sent response.done, the relay now records that
response at its credit hold (8k audio in + 4,096 audio out at the session
model's rates, through the AI cost factor) instead of releasing the hold
uncharged. The hold is released once the charge is recorded, and a warning
alarm is still raised.

Client conversation.item.retrieve events and MCP tools in session.update or
response.create are refused with an invalid_event error and a 1008 close.
2026-10-30 23:48:58 -07:00
8d3a1ccc2b feat(notifications): remove a notification from the dashboard history (#4412)
Each notification in the dashboard list gets a remove (✕) control next to
mark-as-read. It calls the new POST /notif/delete, which deletes the user's
own row and sends `notif.removed` to their other tabs so the entry and any
toast for it disappear everywhere.

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Daniel Salazar <[email protected]>
2026-10-30 22:52:27 -07:00
Daniel Salazar a1fe14cbd5 fix(ai): bound large AI request bodies per user and to chat (#4420)
Follow-up to #4408.

The 503 returned when a process holds too many large bodies is load
shedding, so it records an info alarm instead of a critical one.

One user can hold at most 128 MiB of large bodies at once; past that the
request gets 429 with Retry-After, so a single account can't take a
process's whole budget, e.g. with slow uploads.

Through /drivers/call only puter-chat-completion takes a body over 50 MiB;
other drivers keep the default limit and get 413. The check reads the
parsed body's interface, since route matching ignores case and trailing
slashes.
2026-10-30 21:16:53 -07:00
Filip KujundžićandDaniel Salazar a195605003 fix(ai): accept 128 MiB Anthropic message requests (#4408)
* fix(ai): accept larger Anthropic message requests

* fix(ai): accept 128 MiB bodies on /drivers/call and OpenAI routes

Extend the 128 MiB JSON limit to /drivers/call (puter.ai.*) and the
OpenAI-compatible routes, alongside the Anthropic ones.

A body over the default 50 MiB is read only after a header or cookie
credential resolves; otherwise the global parser rejects it with 413, so
unauthenticated callers can't make the server buffer and parse 128 MiB.
Large bodies skip the rawBody copy, and a per-process budget caps how many
bytes of them are held at once, returning 503 with Retry-After past it.

The prompt token estimate no longer splits text on whitespace, which took
seconds and allocated a string per word on large prompts. It counts
whitespace runs in a loop, scans at most 1 MiB per prompt and extrapolates
the measured density past that.

---------

Co-authored-by: Daniel Salazar <[email protected]>
2026-10-30 20:52:00 -07:00
Daniel Salazar 1e39936d12 fix: custom domains (#4419) 2026-10-30 20:39:11 -07:00
John CostaandDaniel Salazar 1390a87280 fix(docker): hoist sharp so the backend can load it from dist (#4400)
The backend runs from dist/src/backend, so Node resolves modules from the
root node_modules, never src/backend/node_modules. Since sharp ^0.35.5
(#4081) only the backend asked for that version, so npm nested it under
src/backend while the root copy stayed miniflare's dev-only 0.35.4, which
the image doesn't ship. The 26.10.1 image then crash-loops on start with
"Cannot find module 'sharp'" from the Replicate image provider.

Declaring sharp ^0.35.5 at the root hoists the backend's version, the
same way other backend runtime deps are reachable from dist.

Fixes #4394

Co-authored-by: Daniel Salazar <[email protected]>
2026-10-30 20:00:09 -07:00
dependabot[bot] 6d47db9358 chore(deps): bump nodemailer from 9.1.1 to 10.1.4 (#4386)
Bumps [nodemailer](https://github.com/nodemailer/nodemailer) from 9.1.1 to 10.1.4.
- [Release notes](https://github.com/nodemailer/nodemailer/releases)
- [Changelog](https://github.com/nodemailer/nodemailer/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodemailer/nodemailer/compare/v9.1.1...v10.1.4)

---
updated-dependencies:
- dependency-name: nodemailer
  dependency-version: 10.1.4
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-30 19:39:17 -07:00
mahilsaily 2e57214bb3 fix: update outdated HOME_URL in /etc/os-release (closes #4329) (#4392) 2026-10-30 19:37:22 -07:00
spacedandDevin AI 7ac2f2e247 feat(ai-chat): let requests opt out of provider fallback (#4343)
* ai-chat: fallback: false fails on the first route instead of rerouting

A chat request whose first provider fails is retried on up to two other
providers that serve the same model, and the caller pays for whichever
one answers. Callers who want one specific route, or who want to handle
a vendor failure themselves, had no way to turn that off.

Passing fallback: false (puter.js option, /drivers/call arg, and the
OpenAI-compat and Anthropic-compat request bodies) makes the first
attempt's failure final: the route is still marked unhealthy, and the
error is classified the same way an exhausted chain would be.

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* ai-chat: cover fallback: false in the SDK suite

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* ai-chat: fallback: false rejects a pinned provider with no route for the model

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* docs: plainer wording for the fallback option

Co-Authored-By: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-10-30 19:35:45 -07:00
dafiliandDaniel Salazar 0982e696a5 docs(install): explain how to install Docker instead of stopping at a dead end (#4411)
* docs(install): explain how to install Docker instead of a dead end

The installer stopped with 'missing required command: docker' and nothing
else, which left self-hosters on a clean machine with no next step. Since
Puter self-hosting is a Docker Compose stack, that check is also the very
first thing a new user hits.

Print the official install links for macOS/Windows and Linux plus the
'docker compose version' sanity check, and cover the same ground in
doc/self-hosting.md under Requirements and Troubleshooting.

The installer still does not install Docker itself — per the maintainer's
note on #4346, pulling a remote script into a system package manager is
too intrusive. This only makes the failure actionable.

* fix: remove extra sh stuff already merged

---------

Co-authored-by: Daniel Salazar <[email protected]>
2026-10-30 19:33:01 -07:00
Juan Fernando Castro fea89c91b8 feat(puter-js)!: remove notifications.registerDevice (#4409)
* docs(notifications): registerDevice takes ios or android

* feat(puter-js)!: remove notifications.registerDevice
2026-10-30 19:31:30 -07:00
Rasad Regmi 489a7c0129 fix(install): improve Docker missing error with platform-specific instructions (#4384) 2026-10-30 19:29:52 -07:00
Juan Fernando Castro e5ba469ffc fix(notifications): keep app messages in their app, off the desktop (#4410)
* fix(notifications): keep app messages in their app, off the desktop

An app's notifications now reach only that app: its live feed on
notif:app-user and, through the app's own sockets, receipts and pushes.
The desktop's listing, unread replay, live wire and notif:app-user
subscription leave them out, the same way key-value share notices
already are, which covers rows stored before this change too. Puter's
own notifications and shares still show on the desktop.

The desktop and its notifications panel also ignore an app message
they are handed, and the desktop no longer notes one as received.
The notification docs now say where an app's notifications show.

* docs(notifications): say an app's notifications reach the app, not the panel

* test(notifications): wait for the unread replay instead of sleeping past it

* fix(gui): show notifications a frozen tab missed once it reconnects

A tab that was frozen or asleep keeps its socket registered until it
resumes, so a notification created meanwhile counts as shown and no
unread replay carries it: the tab never showed it, not even after a
reload.

On a socket reconnect the desktop and the dashboard now read the
undismissed notifications created since the page loaded and raise the
ones that page has not shown or seen dismissed, once. Every unread
replay is taken too, not only the first, and no longer only while the
events feed is down; a per-page log keeps either from raising anything
twice. App messages stay out.

* fix(gui): catch up after a reconnect without the page's clock

The reconnect catch-up compared each row's server time with the page's
own load time, so a page whose clock ran behind raised, on its first
reconnect, every undismissed notification from before it loaded.

A page now notes the undismissed notifications it knew at load (the
desktop from one listing, the dashboard from its first) and a reconnect
raises only those it neither knew then nor has raised since. The
dashboard reads them from the listing its reconnect refresh already
takes. The per-page log is bounded, and a caught-up notification shows
its title as the body when it has no text, as the replay does.

* style(gui): keep the reconnect comments to one line
2026-10-30 19:24:38 -07:00
Neal Shah 2a3c181657 DNS ui changes (#4417)
* DNS ui changes
2026-10-30 21:16:25 -04:00
Daniel Salazar 4881264c88 feat(ai): realtime reconnect notice, context restore and retry hardening (#4415)
* test(ai): end every realtime relay test's upstream before the next

The fake upstream now answers response.create with response.created first,
as OpenAI does, so closing those sessions no longer waits out the settle
window.

* feat(ai): ask realtime clients to reconnect before a restart

Open realtime sessions get a puter.reconnect event when the drain starts,
with close_in_ms set to the drain window, and new sessions are still refused
until the 1012 close at shutdown. A host extension can announce a restart
early by emitting serverRestartAnnounced; that only sends the notice, so a
false alarm leaves the server working normally. A session gets the notice at
most once a minute.

onServerDrainStart now receives the drain duration.

* fix(ai): retry realtime reconnects for about a minute and wait out offline browsers

Five attempts gave up after about 15 seconds, shorter than a server restart.
The conversation makes ten attempts with the backoff capped at 10 seconds,
and while the browser reports being offline it waits for the online event,
up to two minutes per reconnect, instead of spending attempts. close() ends
either wait and removes its listener.

* feat(ai): restore recent conversation context after a realtime reconnect

The provider starts a fresh session on every reconnect, so the conversation
lost all earlier items. The SDK now records finished items it can recreate
from the provider's own events: user text, user speech with a transcript,
assistant replies as text, and function calls with their outputs. After the
new session acknowledges its settings, and before ready fires, it adds back
the newest of them, up to 40 items and 48 KiB, which stays under the relay's
upload burst.

Assistant replies are cut to what the user saw or heard: the deltas shown
before an interruption, and the share of the transcript matching the audio
played before a truncation or a drop. Deleted items, unfinished replies,
images, speech without a transcript and incomplete function calls are left
out, as are function outputs whose call falls outside the window. Restored
copies take over their new item IDs. A refused item is reported once as
context_restore_failed without failing the reconnect.

* feat(ai): move a realtime conversation to a new session on the restart notice

On puter.reconnect the conversation waits until it is idle: no reply
requested, generating or playing, no user turn being spoken or transcribed,
no function call awaiting its output and no acknowledgment pending. Then it
reconnects through the usual path, emitting reconnecting (code 1012) and
ready with reconnected: true, without an error. Until then the session keeps
working, and if the server closes it first the normal 1012 reconnect takes
over. close() at any point stops the wait.

* docs(ai): realtime restart notice, context restore and reconnect limits

* test(ai): realtime reconnects against the local server

The mock provider answers with item events and whole replies like OpenAI.
New cases cover a dropped connection (cut through a TCP proxy so the client
sees 1006), a host-announced restart, and a draining server refusing the
reconnect; the first two check the provider receives the restored items
before the next turn. The browser media test waits for captured audio and
the reply instead of a fixed sleep. The live suite gains a tool and text
context round trip across a restart notice.

* ci: run the live realtime conversation tests with the AI provider suite

They need the OpenAI key, which only this workflow has, and skip themselves
without it.

* fix(ai): open the replacement before leaving a realtime session on the restart notice

The handover closed the current session and then reconnected, so every
notice cost a second or two in which sends failed and microphone audio was
dropped. On a single server that refuses new sessions while it drains, the
retries ran out during the drain and the conversation closed sooner than if
it had stayed. The conversation now opens a replacement session as soon as
the notice arrives and switches only once it is open and the conversation is
idle. A refused replacement is retried with backoff while the current
session keeps working; when the server closes it first, the held replacement
is used right away.

The idle check also missed some in-progress work:
- a reply created while the user was still speaking ended the user's turn;
  speech now lasts until its audio is committed or cleared, and a committed
  turn waits for the reply server turn detection creates
- only the newest function call was checked; every finished call since the
  last user message needs its output, and calls cut off by a cancellation
  don't count
- native session.update and conversation.item.create events the provider
  hadn't acknowledged could be lost; the switch waits for them, up to the
  acknowledgment timeout for items
- only the newest transcription was waited on; every committed item is
- clearing playback didn't recheck the switch

Restored items stay internal: the new session's copies of them no longer
reach listeners. At a reconnect, every queued item the user didn't hear is
cut, not just the one playing. A notice from a failed reconnect attempt is
dropped.

* test(ai): realtime replay after split settings, and a draining server refusing the replacement

The relay accepts the context replay right after two full-size settings
updates. Against the local server, a draining relay refuses the replacement
session while the current one keeps answering.

* ci: build the SDK in the live realtime step so it stands on its own

* fix(ai): a failed realtime reconnect attempt no longer poisons the next

An attempt that dropped after sending its session.update but before the
acknowledgment left that event ID queued, so the next attempt's
acknowledgment was matched to it and timed out, and so on until the
conversation closed. Turn state is now reset after each failed attempt.

* fix(ai): bound the realtime handover's waits and stop retries that can't succeed

- The first replacement opens within a randomized 3 seconds of the notice,
  so sessions notified together don't all open at once.
- A replacement refused with a code retrying can't fix, such as
  insufficient_funds, ends the attempts; the server's close still falls back
  to the usual reconnect.
- A held replacement that gets an error or its own server's notice is closed
  and replaced.
- A held replacement used after a drop gets a 3 second acknowledgment
  timeout, since its socket may have died with the network.
- close() during the switch closes the held replacement too.
- The reply to a committed turn and each transcription hold the switch for
  at most 10 and 30 seconds, like app items already did, and an app item is
  released only by an added item of its type, role and call ID.

* docs(ai): the realtime restart notice isn't guaranteed before every restart
2026-10-30 18:06:54 -07:00
c7c3f21e4b feat(ai): puter.ai.convo - Preliminary Realtime API implementation (#4401)
* feat(ai): relay OpenAI's Realtime API as puter.ai.assistant

Adds a WebSocket relay at /puterai/openai/v1/realtime on the api host,
one upstream OpenAI connection per client, frames passed through 1:1.
The client socket is accepted before auth so socket.io's 1 s
unhandled-upgrade timeout can't kill slow handshakes; failures arrive as
OpenAI-shaped error events and a 1008 close. Usage is metered per
response.done at the model's token rates, sessions are capped per user
(realtime.maxConnectionsPerUser), and each node logs counters,
event-loop delay and memory every 30 s.

puter.js gets puter.ai.assistant.connect(), returning a session with
send/on/off/close over the same OpenAI events.

realtime.upstreamUrl points the relay at a mock for load tests;
tools/realtime-stress has the mock upstream, a load generator with a
JSON report, and an agent runbook. The relay README lists its quirks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>

* fix(ai): backpressure and upload budget on the realtime relay

Neither direction applied backpressure: a client that stopped reading
grew a queue up to 64 MiB, and a client streaming 16 MiB frames was read
and forwarded as fast as it could send. A few such sockets pinned the
event loop and pushed RSS past 1 GB.

Each side now stops reading the other once 1 MiB is queued toward it,
and a client that keeps the upstream paused for 30 s is closed. Client
uploads are budgeted per session (1 MiB/s sustained, 16 MiB burst,
realtime.maxClientBytesPerSecond), pre-open buffering pauses reads at
1 MiB instead of rejecting at 32 MiB, and clients get a 30 s ping so
half-open sockets release their upstream and slot. Upstream frames use
a zero mask and skip UTF-8 validation, which the relay never needed.

The stats line gains elu. tools/realtime-stress gains abuse.mjs and a
runbook phase for event-loop abuse.

* fix(ai): drain, transcription metering and credential re-check on the realtime relay

Shutdown now drains: a new onServerDrainStart hook (run when SIGTERM
starts the drain, before the listener closes) makes the relay refuse new
sessions with 1012, open ones keep running through the drain window, and
at onServerPrepareShutdown each gets up to 5 s to finish an in-flight
response before closing with 1012 so the client reconnects elsewhere.

Input transcription is metered. The relay reads the type from each text
frame's first 96 bytes and, after forwarding, parses response.done,
input_audio_transcription.completed and session.created/updated (for the
transcription model) on setImmediate, so metering never holds up frames.
Unknown transcription models bill at the highest known rate.

Credentials are re-checked: a successful auth is cached in the kv.js
singleton for 60 s under a hash of the token, and a 15 s sweep
re-authenticates sessions whose entry expired, closing revoked or
suspended ones with invalid_api_key. Lookup errors keep the session and
retry on the next sweep.

* docs(ai): API shape proposal for puter.ai.conversation() across OpenAI, Gemini Live and xAI

* feat(ai): add composable realtime conversations with bounded transport

* fix(ai): realtime billing, subscription gate and conversation reconnects

Relay:
- Cancel and bill a response still generating when the client leaves,
  instead of cutting the upstream before response.done.
- Record realtime usage through the AI cost-factor hook.
- Drop the upstream byte and message caps; replies arrive faster than real
  time and tripping them closed sessions and skipped billing.
- Price usage as it arrives, before any forwarding decision, so a closing
  session never drops a usage event.
- Bill the model the session reports and refuse a session.update that
  changes the model.
- Require a paid plan.

SDK (puter.ai.convo):
- Reconnect after a dropped connection with the same settings, keeping
  listeners and media; emits reconnecting and ready { reconnected }.
- Errors reject only the operation they name; a failed send rejects only
  its own request.
- configure() sends only the given settings and settles on its own ack;
  commitAudio follows turn detection set natively.
- interrupt() right after sendText cancels the reply once it starts.
- Microphone: capture at the device rate, drop chunks instead of stopping
  on stalls or a congested socket, no timeout on the permission prompt,
  permission_denied on refusal.
- Speaker: 300 s playback bound without interrupting, played position
  accounts for output latency and gaps between chunks.
- Audio control events keep headroom over queued audio.
- Upgrade prompt on subscription_required / insufficient_funds.

Tests that need the built SDK and Chromium move to the puter.js suite.

* chore: remove stray node_modules symlinks

* feat(ai): retire assistant API and document realtime beta

* test(ai): wait for the matching realtime billing batch

* test(kv): exclude health probes from warm read measurements

* Revert "test(kv): exclude health probes from warm read measurements"

This reverts commit 631003f755.

* fix(ai): upstream message size, null usage, team 2FA and reconnect settings for realtime

Relay:
- Upstream messages may be up to 4 MiB; client messages stay at 64 KiB.
  An OpenAI event above 64 KiB (an echoed item near the client limit, a
  long reply, a retrieved audio item) closed the upstream before in-flight
  responses reported usage.
- An upstream that closes with a response still in flight raises a
  warning alarm.
- A null usage counts as none instead of closing the session.
- Seats whose team requires 2FA need it for realtime too, at connect and
  on the credential recheck.

SDK (puter.ai.convo):
- A reconnect reapplies every acknowledged setting, native session.update
  events included, split to fit the message limit.
- connect() during a reconnect settles with it.
- Reconnect delays are jittered, and closing cancels a pending delay.

The credential-recheck and browser worklet tests now fail when the
behavior they cover breaks.

* feat(ai): credit holds, attempt limits and late-response billing for realtime (#4407)

Relay:
- Each response holds 8,000 audio input and 4,096 audio output tokens of
  the account's budget, at the session model's rates, from
  response.created until its usage is recorded. A session opens only if
  one hold fits.
- Session attempts are limited to 60 per minute per credential, checked
  before authentication.
- A response the client requested that starts after the client leaves is
  cancelled and billed.
- Input transcription is priced at the costlier of the transcription
  models in force at commit and at completion.
- rate_limits.updated isn't forwarded, and provider organization IDs are
  removed from error messages.

SDK (puter.ai.convo):
- microphone.stop() during the permission prompt rejects the pending
  start() with session_closed.
- speaker.start() rejects with { code, message } for browser exceptions.
- connect() waits 20 seconds for the session, past the server's 15-second
  setup.
- Convenience events fill IDs the provider leaves out; types cover
  turn.done, error, and client events for send().

---------

Co-authored-by: Claude Opus 5.5 <[email protected]>
Co-authored-by: Daniel Salazar <[email protected]>
2026-10-30 14:50:15 -07:00
Daniel Salazar 7401fe0b09 fix(notifications): receipts report what the user saw, sticky push opt-in, safer app pushes (#4413)
* fix(socket): count sockets that never acknowledge instead of asking them

askWithAck sent an acked broadcast to sockets outside the ack room only to
learn whether any were there. Those sockets never answer, so every node held
the ack callback until they disconnected. Each node now answers a server-side
count of them instead; a node that does not answer in time still reads as
holding one.

* feat(notifications): read focus and own display from receipt answers

A receipt answer may carry focused and showsOwn beside visibilityState; both
pass through to notif.delivery.settled when they are booleans. An answer with
received: false is not a responder, and an ask answered only that way settles
as unreceived.

* fix(notifications): name an app message's bad field before the size cap

An app message over the 16 KB value cap answered notification_value_too_large
even when one field was what broke a rule of its own. The fields are checked
first now, so the caller gets bad_request naming the field; the total cap still
applies after. Character counts skip the code point walk on strings that can't
fit.

* fix(push-sw): match the server's tags, keep taps, and show pages' notifications

- A push is tagged with the server's group, `<app uid>:<group>` or the uid, and
  alerts again when that replaces one showing. Handlers get the app's own group
  and the send time in ms from `ts`.
- The app's icon stands in when the notification has none.
- Default and relative tap URLs resolve against the worker's scope, still only
  on its origin.
- A tap posted to an open window waits briefly for the page to take it; without
  a click handler there, the window is taken to the URL, or one is opened.
- A page that got a notification live while away can ask the worker to show it;
  the worker holds it back while another window of the origin has focus.

* fix(notifications): answer every receipt ask

A page now answers each socket that asks about a notification, not only the
first, and answers { received: false } when the notification doesn't arrive
within the wait or the page holds too many asks, so the server's nodes can
let go of them. A received answer carries focused and showsOwn beside
visibilityState.

* fix(notifications): keep web push to users who turned it on, and show what a push would

Subscription:
- onPush() renews the subscription on load only for a user who turned push on
  on this origin with enable(); enable() records that, and disable(), sign-out
  and push_revoked forget it. One renewal runs per page and worker, however many
  onPush() calls there are, and push_disabled, push_revoked, push_unavailable
  and too_many_requests stay quiet there.
- puter.auth.signOut() on a website removes this browser's subscription with
  the leaving user's token and forgets the opt-in and kept taps, without
  holding up the sign-out.
- A subscription another service on the site made is left alone and rejected
  as push_subscription_conflict; one made with Puter's earlier key is replaced.
- A subscription refused with push_endpoint_revoked is replaced once, like
  push_subscription_invalid.
- Another worker holding the scope rejects push_worker_conflict, naming
  importScripts and the serviceWorker option, instead of waiting out the
  confirmation push.
- enable() rejects user_gesture_required outside a click instead of prompting,
  and a refused browser subscribe rejects push_subscribe_failed.

Live side:
- A page that is hidden or unfocused shows every app notification it receives,
  with or without a handler for its channel, through push-sw.js so a focused
  window of the app holds it back. It uses the server's tag, the app's icon as
  a fallback, the generic app line for sensitive ones, and the registration of
  its own worker.
- Page handlers get 5 seconds, as in the worker (push_handler_timeout).
- A feed stopped by a sign-out starts again on the next sign-in.
- onPush() still delivers live when IndexedDB is unavailable.
- onPush() rejects invalid_handler for a handler the worker could not rebuild
  from its source (methods, bound and built-in functions).
- Saving and removing a handler's source for the worker is ordered per key, so
  a quick unmount and remount keeps it.
- A tap posted by push-sw.js is acknowledged only when a click handler takes it.

* fix(gui): show app pushes under the app's name and Puter's icon

- On Puter's own origin, an app's push takes the app's name as its title, with
  the app's title and text in the body, and Puter's copy of the app's icon; an
  icon the app chose is never shown. A sensitive one keeps its generic line.
- A push's ts is its timestamp, and a newer push in a group alerts again.
- Re-registering this browser counts only apps that push to Puter's devices
  (puterDevices), so a website's Web Push confirmation alone never registers
  it.

* fix(gui): free the push dialog's button when registering fails late

A registration that failed after the dialog's deadline left the button
spinning and disabled. It now clears the attempt, settles on a state the
browser moved to meanwhile, or offers a retry.

* fix(permission): a user's own app grant clears a narrower via on the row

An app grant that already existed was skipped, so a row another path recorded
with extra.via kept it after the user approved the same permission in Puter.
A grant without via now rewrites such a row without it, keeping its other
extras; a grant with via still never touches an existing row.

* refactor(notifications): export the notification text cleaner

Extensions that word notifications can use the same cleaning as app messages.

* docs(notifications): describe when pushes go out, taps, worker rules and enable() errors

Pushes go out when no open page answers that it's visible and focused or
shows the notification itself. Documents the page-shown notification, the
sticky opt-in, tap routing, group and sensitive behavior, service worker
rules, requirements, every enable() and onPush() error, push caps for
unlisted apps and websites, and sending from workers and events handlers.

The push playground example is removed (the playground can't serve the
site's service worker) and redirects to the guide. The minimal page catches
enable() errors and sends after 5 seconds. The recipe no longer sends on
every load, and app.message is listed in events fetch and the catch-up
recipe.

* docs(notifications): per-app browser caps and when push_endpoint_revoked appears

* docs(notifications): the page's handler timeout

* fix(notifications): leave a site's own service worker alone when enabling pushes

Registering /puter-sw.js at a scope another worker holds queued it behind that worker, so enable() rejected with push_worker_conflict but swapped the site's worker out once its tabs closed. Now enable() checks the scope first and rejects without registering.

* docs(notifications): a background page shows score alerts itself
2026-10-30 14:43:48 -07:00
Daniel Salazar 61b713438d docs(notifications): push setup requirements, browser and device support, and push recipes (#4406)
Adds worker requirements, per-browser/OS support (iOS Home Screen, macOS/Windows/Android settings), and troubleshooting to the push setup page. Website taps now documented on on('click'). New recipes: notify chat members of new messages, alert users when shared data changes.
2026-10-30 09:12:19 -07:00
Daniel Salazar 7ceaf69651 docs(notifications): import push-sw.js from the root of js.puter.com (#4405)
The worker is published next to puter.js at https://js.puter.com/push-sw.js.
Docs and the worker's own header now point there. The js subdomain serves
/push-sw.js for self-hosted setups and keeps /v2/push-sw.js as an alias.
2026-10-29 20:10:27 -07:00
Juan Fernando CastroandDaniel Salazar c00f693ec6 feat(notifications): report how each live notification was received (#4403)
* feat(notifications): report how each live notif.message was received

SocketService.sendWithAck sends an event to rooms or sockets and waits,
up to a timeout, for an acknowledgement from each socket that listed the
event in its handshake (`auth.acks`); every other socket gets a plain
emit. It lists sockets across the cluster with fetchSockets and asks the
listed ones through the adapter's broadcast acks, and counts this node
alone when the adapter cannot list the others.

The emitting region now sends notif.message through it, with the window
from `notificationAckTimeoutMs` (default 5000), and emits the node-local
`notif.delivery.settled` with the outcome: received, unreceived,
no-socket or remote-only (sockets only in a peer region), whether any
socket there acknowledges, and each responder's visibilityState. Peer
regions keep sending their own copy as before; the GUI event carries
`region_delivered` so this region does not send it twice.

The desktop and dashboard list notif.message in their handshake and
answer its callback once with document.visibilityState. Nothing in core
acts on the settled event.

* fix(notifications): send live notif.message at once and report what the count covers

sendWithAck now emits to the whole room straight away as a broadcast with
ack, and lists the room's sockets alongside it only to fill in the
counts, so nobody waits on the listing. Whether the adapter can count
the cluster's servers is checked once; where it cannot, sends go out
per socket on this node with a one-time warning. The result carries
`complete`: false when only this node's sockets were counted, either
for that reason or because the listing failed or took over 1 s, which
is warned about at most once a minute.

notif.delivery.settled gains `sendId`, `type`, `complete` and
`peerRegions`. An incomplete count with no socket found settles as
unreceived rather than no-socket, and the peer-region read runs
alongside the send instead of after it. notif.written carries the same
`sendId`, minted per send (an update gets a new one) and passed through
notif.created to the socket adapter on the fold-in path.

The comment on revoking sockets no longer says fetchSockets is
unavailable with our Redis client; it works with ioredis cluster.

* fix(notifications): keep a failed server count from ending the process

socket.io awaits the adapter's serverCount without a catch on every
broadcast with an ack, and the redis-streams adapter's count rejects as
soon as one cluster node fails to answer. The count on the root
namespace's adapter now never rejects: a failure reports the last good
count (1 before any), and is warned about at most once a minute. A stale
count only lets an ack wait run to its timeout.

Whether sendWithAck takes the cluster path is no longer a one-shot check
awaited before the emit. Until a count has succeeded, sends go out per
socket on this node at once while a count is retried in the background,
at most every 30 s (SocketService.COUNT_RETRY_MS); the first success
switches to the cluster path for good.

* fix(notifications): ask only acknowledging sockets, through an ack room

A socket whose handshake lists an event in `acks` now joins
`<room>:acks:<event>` for each of its rooms and its own id.
sendWithAck sends the ordinary broadcast to the target rooms except
those ack rooms, which keeps it on the adapter's stream, and a broadcast
with an ack only to the ack rooms. Sockets that never acknowledge are no
longer asked, so other nodes hold no pending ack for them and a send
completes as soon as every acknowledging socket answered. The per-socket
listing that matched socket.data.acks is gone; the ack-capable count
comes from the room membership the listing already returns. The
per-socket path stays only for a node whose adapter has not counted
servers yet.

A failed server count during a send now marks its counts incomplete, so
a stale count can't make a listing that missed a node read as complete.
With no count ever succeeded (an in-memory Redis), the failure is warned
once per process instead of once a minute.

* fix(notifications): say whether every socket on a live notification acknowledges it

A tab on an older bundle beside one that acknowledges made the settle read
as ack-capable, so a listener could not tell that the older tab may have
shown the notification. notif.delivery.settled now carries everySocketAcks.

* feat(notifications): app pages acknowledge the live copy of an app message

NotificationService.appReceipt() asks an app's own sockets whether a
notification reached a page, and settles it as notif.delivery.settled with
appUid, the same outcomes as the desktop's. Callers ask only when the answer
decides something, since it is a round trip to every node.

- SocketService.askWithAck asks without listing sockets: sockets that don't
  acknowledge get an acked broadcast too, which can only time out, and that
  says whether an older client shares the target.
- puter.js declares notif.receipt on its filesystem and events sockets. A page
  answers once one of its notification feeds received that uid, before any
  handler runs, and never when no feed on the page got it.
- Docs describe the push route as "no page of the app received it live
  within 5 seconds".

* fix(notifications): count a socket that acknowledges other events as a current client

Every desktop page also holds puter.js's own socket, which never shows
notif.message, so "every socket acknowledges notif.message" never held there.
sendWithAck now reports `declaring`, the sockets that listed any event in
auth.acks, and everySocketAcks on the desktop's settle means no socket here
declares nothing.

* fix(puter-js): fall back to the Continue dialog when a popup can't be opened

Some browsers report user activation but still return null from
window.open (iOS home-screen web apps, Safari after awaited work), so
permission prompts, signIn, enablePush, and GUI action popups failed
without showing anything.

PuterDialog.launch() now owns the open-now-or-show-Continue decision for
every popup flow and shows the consent dialog when the direct open
returns null. On cross-origin-isolated pages signIn leaves settling to
the /login/wait relay on both launch paths.

* fix(notifications): show a live push when the page's window isn't focused

onPush showed the handler's result as a system notification only when
the tab was hidden. A visible tab in a window the user left showed
nothing. It now also shows when the document doesn't have focus. Inside
the Puter desktop the desktop's banner shows the notification, so an
app's frame still never does.

* build(puter-js): publish push-sw.js exactly as written

The build emits dist/push-sw.js next to puter.js, but terser minified it
along with the bundle. It is now marked as already minimized, so the file
apps import into their service worker is the source unchanged.

* fix(notifications): send live notif.message plainly and ask for receipts on request

Every live notif.message used to be sent with an ack, list the user's sockets
across the cluster (fetchSockets ships each socket's handshake between nodes)
and wait out the receipt window, whether or not anything would act on it.

notif.message is a plain send again. NotificationService.receipt() asks the
desktop's sockets, or with appUid an app's own, the way app receipts already
did: an ask with no socket listing, settled as notif.delivery.settled. A
caller asks only when the answer changes what it does.

- The desktop and dashboard sockets answer notif.receipt from the page's
  puter.js receipt log and note each live notif.message in it, so every socket
  on the page answers alike. A page whose puter.js has no log lists no acks.
- SocketService.sendWithAck, the socket listing and region_delivered are gone.
  askWithAck counts this node's sockets when the adapter cannot count servers.
- The send id stays on notif.written and the settle, and leaves the wire.

* fix(socket): join only the ack room receipts are asked in

A socket listing events in auth.acks joined an ack room beside each of its
rooms and its own id. Receipts are only asked in the user's room, or the app's
for an app socket, so that is the one ack room it joins.

* docs(notifications): publish the push limits of 12 a minute and 1,000 a day

Pushes are capped per user per app at 12 a minute and 1,000 a day, and at 6 a
minute and 200 a day while an app is new. The rate limits table and the
notification pages said one a minute and 100 a day.

---------

Co-authored-by: Daniel Salazar <[email protected]>
2026-10-29 18:57:36 -07:00
Juan Fernando Castro dace77c212 fix(gui): a request the server no longer accepts signs the tab out locally (#4390)
A 401 outside the reauth flow used to run the full logout, which ends the
session on the server and takes every device registered under it. The tab
now drops its token and reloads instead; the session cookie signs it back
in when it still holds. A second 401 right after that reload falls back
to the full logout. A peer tab signing out is mirrored the same way.
2026-10-29 09:19:43 -04:00
Juan Fernando CastroandDaniel Salazar 8e1233d82a feat(push): notifications:push app permission (#4317)
* feat(push): notifications:push app permission

Apps need a per-app notifications:push grant for their notifications to
go out as pushes. hasPushConsent checks it as the app acting for the
user, so only that user's grant to that app answers yes, and a revoke
takes effect on the next check. The permission dialog describes it and
the perms docs list it.

* fix(push): canonical app uid in the push consent check

* fix(push): let users grant apps notifications:push

The grant route now only accepts permissions from a fixed set of
app-grantable families, which refused notifications:push, so an app could
never be given push consent. Register it next to events:background.

* refactor(permission): keep the notifications:push constant with the permission code

The consent check that reads it lives with the delivery code, outside
core. The grant flow test checks the grant through the permission
service directly.

* feat(notifications): node-local notif.written hook

NotificationService emits notif.written after each non-silent write and
after an in-place rewrite, on the node that wrote the row, so an
extension can act on new notifications without a second bus round trip.
It stays off the published event stream; notif.created carries the
notification itself.

* feat(puter-js): puter.notifications

An app notifies the user it runs for, reads its own notifications back
and follows new ones, hears clicks on them from the desktop, and asks
for or checks the notifications:push grant. Puter's own apps also
register, list and remove the user's devices.

* feat(puter-js): device calls report push_unavailable without a push service

A deployment with no push service has no /push routes, so core answers
the device calls with its catch-all 404. registerDevice, listDevices and
removeDevice turn that into push_unavailable, kept apart from a missing
device, which comes back as device_not_found.

* feat(homepage): serve the service worker at /sw.js

/sw.js serves the GUI's sw.js, preceded by one importScripts per script
an extension registered with registerServiceWorkerScript. Whether the
GUI registers the worker follows the pushEnabled gui param, which only
an extension sets. Notification reads also carry the row's app_uid, so
the GUI can route a tap to the app a notification is about.

* feat(gui): notifications settings for push devices and apps

Settings -> Notifications turns pushes on and off for this browser
through a push client an extension registers, lists the user's devices
and the apps allowed to push, and switches Puter's own pushes. The tab
shows only when the pushEnabled gui param is true. The sessions window
shares the user agent formatting with it.

* feat(gui): service worker that shows pushes and routes taps

sw.js shows each push as a notification and, on a tap, focuses or opens
a Puter window and hands it the tapped notification. The desktop routes
the tap to the app the notification names, opening it when needed, or
to the notifications panel. The worker is registered only when the
pushEnabled gui param is true.

* feat(gui): notifications settings read a missing push service as unavailable

Without a push extension the /push routes answer core's plain 404. The
GUI maps that to push_unavailable, so the tab says pushes aren't
available on this server instead of reporting a load failure, and a
device removal only counts as done when the device itself is missing.
The push client doc describes the payload sw.js reads and where the
push endpoints come from.

* fix(gui): keep the service worker on servers without push

Unregistering /sw.js wherever pushEnabled is unset ended the browser's
push subscription, so a single page load on such a server stopped pushes
from every server. Turning push off already ends the subscription.

* feat(gui): the service worker tells open tabs when the push subscription changes

* fix(gui): removing this browser in Settings also forgets its push registration

* fix(gui): removing this browser also deletes it on the server when its local record is gone

* fix(notifications): notif.written hands out a copy with the audience

Listeners get their own copy of the value, so what the socket carries is
what was written. Both emits name the audience, and an update names the
row's app when the row or the value says which.

* fix(notifications): read the app off the notification on an update

* fix(gui): route push taps through the stored notification

A tap from the worker names only a notification; the desktop and the
dashboard read the signed-in user's own row and route from its app,
target and data, as a tap from the URL already did. Without a row the
dashboard opens the list and the desktop does nothing.

* fix(gui): local banner tags carry the app

An app message's group is tagged under its app, as the server tags a
push, so two apps using the same group name keep separate banners.

* fix(gui): a tap delivered twice routes once

The page remembers the notifications it handed over in the last ten
seconds and drops a repeat, whichever way it arrives.

* fix(gui): the service worker never reuses cached imports

The worker is registered with updateViaCache set to none, so the scripts
it imports are fetched fresh on every update check.

* fix(gui): turn on can't run twice at once

The browser card's button stays out while the client is enabling or
disabling, through any redraw in between, and a status read begun before
the press no longer draws the card.

* feat(gui): turn on browser push when an app is allowed to notify

Allow on a permission request that includes notifications:push also
turns push on in this browser, from the same click, when it is off here.
The decision never depends on it. The permission popup starts the
service worker for such a request, answers the opener first and closes
once the registration is done. The Notifications tab records a browser
the user turned off, per user.

* feat(gui): register this browser for pushes again where it already allows them

On desktop and dashboard boot, and when an app that holds
notifications:push is launched, a browser whose notification permission
is already granted registers again without a click, unless the user
turned push off in this browser.

* feat(sdk): puter.notifications.enablePush() and the enable-push popup

An app on its own domain can offer to turn push on in the user's browser
from its own button: puter.notifications.enablePush() opens a popup on
the Puter origin whose single button supplies the click the browser's
prompt needs, and inside the desktop the same dialog is shown over IPC.
The popup hands the opener no token. Docs for the new call and for the
paths that now call the push client's enable().

* fix(gui): wait for the browser's prompt before closing the permission popup

The deadline on a registration begun from a click counts no time while
the browser's own notification prompt is open, in the permission popup
and in the enable-push dialog. The enable-push dialog keeps one
registration in flight, settles on whenever it resolves while open, and
offers a retry only after a real failure. A permission dialog that ends
in a denial after Allow began a registration turns push back off and
restores the opt-out it cleared.

* fix(gui): one enable-push dialog at a time, only for apps that may send pushes

The desktop and the popup show the enable-push dialog only when the
asking app holds notifications:push. The IPC branch answers a second
call unavailable while a dialog is up, as the feedback dialog does. The
silent registration on boot and app launch runs once at a time. A popup
whose opener cannot be attested answers the enable-push request before
it closes.

* feat(events): isOnline presence check for a user and app

* feat(notifications): onPush and enable with Web Push on the app's own origin

puter.notifications.onPush(key, handler) handles an app's notifications for
one channel: live over the events socket while the app is connected, and as
a browser push otherwise. enable() asks for notification permission on the
site's origin and subscribes through the site's worker, which imports
push-sw.js (served at js.puter.com/v2/push-sw.js). A new subscription is
confirmed by a challenge push the worker hands back to the page. disable()
removes it.

The GUI uses the same flow through /sw.js when the server publishes
pushWebPushKey.

* fix(notifications): visible sender for app messages; local banners follow push consent

An app title of only invisible or blank-filler characters no longer leaves
an app message without a sender; it falls back to the app's name, then its
uid. Hidden-tab banners now show only while push is on in this browser, and
for an app's message only when the app holds notifications:push.

* feat(notifications): enable() asks through Puter's prompt after a revoke

When the user turned an app's notifications off in Puter, the browser's
permission on the app's origin no longer re-enables them. enable() asks
again through Puter's permission prompt and retries once.

* fix(notifications): enable() rejects on a revoke, onPush() syncs quietly, one permission check per app a minute

enable() no longer opens Puter's prompt after a push_revoked error; apps call requestPermission() from a click and enable() again. onPush()'s page-load sync no longer reports push_unavailable or push_revoked. Hidden-tab banners for an app's messages check the app's push permission once a minute instead of once per message.

* docs(notifications): describe the confirmation step as what happens; one-line comments and neutral fixtures

* docs(notifications): guide for app push notifications, limits and prices

* fix(puter-js): subscribe once the worker is active, and retry a subscription the push service hasn't settled

* fix(gui): the desktop's hidden-tab banner is for Puter's own notifications only

* docs(notifications): app pushes at $2.50 per million

---------

Co-authored-by: Daniel Salazar <[email protected]>
2026-10-29 09:19:09 -04:00
Juan Castro 722c25e0dc Merge juancastro/put-2258-push-from-app-subdomains into juancastro/put-2168-let-users-decide-which-apps-can-send-them-pushes 2026-10-29 09:11:29 -04:00
Juan Fernando Castro 9e449e635b Merge pull request #4399 from HeyPuter/juancastro/put-2180-push-docs
docs(notifications): guide for app push notifications, limits and prices
2026-10-29 09:10:44 -04:00
Juan Fernando Castro ba7011b5bb Merge pull request #4391 from HeyPuter/juancastro/put-2258-push-from-app-subdomains
feat(gui): turn on browser push when an app is allowed to notify
2026-10-29 09:10:34 -04:00
Juan Castro 25d5843d03 Merge juancastro/put-2262-web-push into juancastro/put-2180-push-docs 2026-10-29 08:59:08 -04:00
Juan Castro 4e6d655f4c Merge juancastro/put-2258-push-from-app-subdomains into juancastro/put-2262-web-push 2026-10-29 08:59:06 -04:00
Juan Castro bd1dd6564e Merge juancastro/put-2172-notifications-settings into juancastro/put-2258-push-from-app-subdomains 2026-10-29 08:59:04 -04:00
Juan Castro 69a3e918a7 Merge juancastro/put-2171-push-service-worker into juancastro/put-2172-notifications-settings 2026-10-29 08:59:03 -04:00
Juan Castro aded055212 Merge juancastro/put-2170-puterjs-notifications into juancastro/put-2171-push-service-worker 2026-10-29 08:59:01 -04:00
Juan Castro e54750492f Merge juancastro/put-2167-notification-written-hook into juancastro/put-2170-puterjs-notifications 2026-10-29 08:58:59 -04:00
Juan Castro 406fe4dcfc Merge juancastro/put-2168-let-users-decide-which-apps-can-send-them-pushes into juancastro/put-2167-notification-written-hook 2026-10-29 08:58:57 -04:00
Juan Castro 2e085c0b75 Merge juancastro/put-2251-let-apps-send-notifications-to-their-own-user into juancastro/put-2168-let-users-decide-which-apps-can-send-them-pushes 2026-10-29 08:58:49 -04:00
Juan Castro b95fb870a5 Merge main into juancastro/put-2251-let-apps-send-notifications-to-their-own-user 2026-10-29 08:58:39 -04:00
Daniel Salazar 28be1851f9 fix(workers): bind credentials and cleanup to the owning site (#4362)
* fix(workers): bind credentials and cleanup to the owning site

* test(workers): deploy valid source in the site-bound token test

Where workerd is installed the deploy starts the script, and the bare
identifier `src` threw at startup as an unhandled rejection.
2026-10-28 17:31:15 -07:00
Juan Castro ff83360272 docs(notifications): app pushes at $2.50 per million 2026-10-28 19:53:26 -04:00
Daniel Salazar b19fde242f fix(networking): recover shared relay clients and preserve write order (#4377)
* fix(networking): recover shared relay clients and preserve write order

* fix(networking): clear the flush flag with the last queue check

A write landing between the drained queue and the connect path resuming
was queued with nothing left to send it.

* fix(networking): keep a throwing socket listener out of connect and close

An open listener that threw was caught as a failed connect and could
retry onto a second stream; an error listener that threw stopped the
socket from ever closing. Both now rethrow on their own tick.
2026-10-28 16:05:54 -07:00
Daniel Salazar c2b38786f7 fix(ai): meter converted audio from reported output duration (#4366)
* fix(ai): meter converted audio from reported output duration

* fix(ai): size undocumented voice changer output formats as the default

* fix(ai): skip WAV headers when metering and renew streamed charge holds

A wav_* output's RIFF header was counted as samples, rounding a 3 s clip
up to 4 s. The streamed-charge hold now renews until the charge lands, so
a slow reader can't outlast it.

* fix(ai): time Opus output by its pages and renew holds through slow opens

Opus is variable bitrate, so a mostly silent clip billed far under its
length at the nominal rate; its length now comes from the last Ogg
page's granule position. The streamed-charge hold renews from before the
upstream opens, since opening can run as long as the hold's TTL, and is
released even if recording the charge throws. Speech-to-text extra_body
can no longer override response_format or stream, which billing reads.
2026-10-28 16:05:36 -07:00
Daniel Salazar 7c6c0f2d39 fix(ai): finish async media accounting and recheck output access (#4365)
* fix(ai): finish async media accounting and recheck output access

* fix(ai): settle late media jobs on their outcome and keep their slot

A job still running when the poll window closes no longer charges its
estimate up front. The caller still gets the 504; the job is followed in
the background, holding its credit, until it settles, and is metered from
the provider's outcome: what it reports, nothing for a failure that
reports no cost, the estimate only if it never settles.

The driver concurrency slot is now held until the handler and any work it
carries on after the response settle, so a disconnect no longer frees a
slot while polling continues.

A revoked puter_output_path is reported as metadata.outputSaved = false.

* fix(ai): bill a lost media job the same in and after the window

A poll error that isn't transient now ends the same way whether it hits
inside the window or after it: a client-error refusal is a failure the
provider doesn't bill, while auth, not-found and unclassified errors mean
we lost a job the provider still runs, charged its estimate. A Hailuo
clip already billed retries a transient file lookup instead of failing,
and a concurrency slot renews from the moment it is held.
2026-10-28 16:05:26 -07:00
Juan Castro 5ac41f90b6 Merge juancastro/put-2262-web-push into juancastro/put-2180-push-docs 2026-10-28 16:30:47 -04:00
Juan Castro c024e007aa Merge juancastro/put-2258-push-from-app-subdomains into juancastro/put-2262-web-push 2026-10-28 16:30:45 -04:00
Juan Castro eeedeb817f Merge juancastro/put-2172-notifications-settings into juancastro/put-2258-push-from-app-subdomains 2026-10-28 16:30:42 -04:00
Juan Castro ca79f01edd Merge juancastro/put-2171-push-service-worker into juancastro/put-2172-notifications-settings 2026-10-28 16:30:40 -04:00
Juan Castro 55ba45e159 Merge juancastro/put-2170-puterjs-notifications into juancastro/put-2171-push-service-worker 2026-10-28 16:30:38 -04:00
Juan Castro e8688d5025 Merge juancastro/put-2167-notification-written-hook into juancastro/put-2170-puterjs-notifications 2026-10-28 16:30:35 -04:00
Juan Castro 10f10029c2 Merge juancastro/put-2168-let-users-decide-which-apps-can-send-them-pushes into juancastro/put-2167-notification-written-hook 2026-10-28 16:30:33 -04:00
Juan Castro 11a94ed608 Merge juancastro/put-2251-let-apps-send-notifications-to-their-own-user into juancastro/put-2168-let-users-decide-which-apps-can-send-them-pushes 2026-10-28 16:30:30 -04:00
Juan Castro 5c35f8d053 Merge main into juancastro/put-2251-let-apps-send-notifications-to-their-own-user 2026-10-28 16:30:10 -04:00
Juan Castro c5a4b1f3b5 Merge juancastro/put-2262-web-push into juancastro/put-2180-push-docs 2026-10-28 16:27:20 -04:00
Daniel Salazar 179ad990d6 feat(events): simplify onPersistent delivery (#4395)
* feat(events): simplify onPersistent delivery

Replace delivery/targets with a boolean single. Without single, every
event runs the app's published handler in its events worker and is also
sent to every connected client; the server no longer routes between the
two. single keeps leasing each event to one consumer that acks.

delivery: 'single' | 'broadcast' is still accepted and targets is
ignored. Stored rows are read by their delivery column alone.

* docs(events): compare onLocal, onPersistent and single

* feat(events): rename single to queue

onPersistent({ queue: true }) is the work-queue mode: each event goes to
one consumer at a time and is offered again until acknowledged.
delivery: 'single' is still accepted as an alias, and subscription
objects report queue instead of single.

The docs explain the three modes (onLocal, default onPersistent, queue)
and each events recipe says which one it uses and why.

* docs(events): retries are the only repeat for a queued event

* feat(events): queue deliveries fall through to the worker on decline

A queued delivery gets one socket attempt under a 15 s lease instead of
two 60 s ones. A client with no handler for it, or whose handler fails,
sends events.nack and the worker takes it without waiting for the lease.
Declines are relayed to the region holding the lease like acks are, and
a decline for a lease that is no longer the client's is ignored.

* feat(events): drop thrown errors, retry only temporary ones

A queued handler that throws now drops the event and counts one failure
toward the pause. Network errors, timeouts, rate limits, 5xx responses
and errors thrown with retry: true are retried with the existing
backoff. One classifier is shared by puter.js and the events worker
runtime, which inlines it at build time. A dropped event is billed once,
like a completed one. ack() remains as an optional early release.

* docs(events): queued events reach every tab with a handler
2026-10-28 12:58:51 -07:00
Juan Castro ff8dde6895 fix(gui): the desktop's hidden-tab banner is for Puter's own notifications only 2026-10-28 14:20:24 -04:00
Juan Castro d587ffa428 fix(puter-js): subscribe once the worker is active, and retry a subscription the push service hasn't settled 2026-10-28 13:50:19 -04:00
Juan Castro 0fe68dae30 docs(notifications): guide for app push notifications, limits and prices 2026-10-28 12:37:28 -04:00
Juan Castro eb1ad240f2 docs(notifications): describe the confirmation step as what happens; one-line comments and neutral fixtures 2026-10-28 10:06:55 -04:00
Juan Castro d40cc499f5 fix(notifications): enable() rejects on a revoke, onPush() syncs quietly, one permission check per app a minute
enable() no longer opens Puter's prompt after a push_revoked error; apps call requestPermission() from a click and enable() again. onPush()'s page-load sync no longer reports push_unavailable or push_revoked. Hidden-tab banners for an app's messages check the app's push permission once a minute instead of once per message.
2026-10-28 10:06:55 -04:00
Juan Castro 06ef9506fa Merge juancastro/put-2258-push-from-app-subdomains into juancastro/put-2262-web-push 2026-10-28 10:01:02 -04:00
Juan Castro 5513c23042 Merge juancastro/put-2172-notifications-settings into juancastro/put-2258-push-from-app-subdomains 2026-10-28 09:46:54 -04:00
Juan Castro 8aade99cc8 Merge juancastro/put-2171-push-service-worker into juancastro/put-2172-notifications-settings 2026-10-28 09:45:57 -04:00
Juan Castro 6f9719fd65 Merge juancastro/put-2170-puterjs-notifications into juancastro/put-2171-push-service-worker 2026-10-28 09:45:51 -04:00
Juan Castro 352a2fbabd Merge juancastro/put-2167-notification-written-hook into juancastro/put-2170-puterjs-notifications 2026-10-28 09:45:45 -04:00
Juan Castro 59700519a6 Merge juancastro/put-2168-let-users-decide-which-apps-can-send-them-pushes into juancastro/put-2167-notification-written-hook 2026-10-28 09:45:38 -04:00
Juan Castro 8482ff2167 Merge juancastro/put-2251-let-apps-send-notifications-to-their-own-user into juancastro/put-2168-let-users-decide-which-apps-can-send-them-pushes 2026-10-28 09:45:31 -04:00
Juan Castro b42f2acf06 Merge main into juancastro/put-2251-let-apps-send-notifications-to-their-own-user 2026-10-28 09:44:38 -04:00
Daniel Salazar 1b6de39a13 feat(notifications): enable() asks through Puter's prompt after a revoke
When the user turned an app's notifications off in Puter, the browser's
permission on the app's origin no longer re-enables them. enable() asks
again through Puter's permission prompt and retries once.
2026-10-28 01:17:00 -07:00
Daniel Salazar 1aa7caf44c fix(notifications): visible sender for app messages; local banners follow push consent
An app title of only invisible or blank-filler characters no longer leaves
an app message without a sender; it falls back to the app's name, then its
uid. Hidden-tab banners now show only while push is on in this browser, and
for an app's message only when the app holds notifications:push.
2026-10-28 01:06:54 -07:00
Daniel Salazar 40253ae12b feat(notifications): onPush and enable with Web Push on the app's own origin
puter.notifications.onPush(key, handler) handles an app's notifications for
one channel: live over the events socket while the app is connected, and as
a browser push otherwise. enable() asks for notification permission on the
site's origin and subscribes through the site's worker, which imports
push-sw.js (served at js.puter.com/v2/push-sw.js). A new subscription is
confirmed by a challenge push the worker hands back to the page. disable()
removes it.

The GUI uses the same flow through /sw.js when the server publishes
pushWebPushKey.
2026-10-28 01:05:36 -07:00
Daniel Salazar 3b4945f018 feat(events): isOnline presence check for a user and app 2026-10-28 00:53:28 -07:00
Juan Castro fe58213bb5 fix(gui): one enable-push dialog at a time, only for apps that may send pushes
The desktop and the popup show the enable-push dialog only when the
asking app holds notifications:push. The IPC branch answers a second
call unavailable while a dialog is up, as the feedback dialog does. The
silent registration on boot and app launch runs once at a time. A popup
whose opener cannot be attested answers the enable-push request before
it closes.
2026-10-27 16:48:07 -04:00
Juan Castro 6c35780d59 fix(gui): wait for the browser's prompt before closing the permission popup
The deadline on a registration begun from a click counts no time while
the browser's own notification prompt is open, in the permission popup
and in the enable-push dialog. The enable-push dialog keeps one
registration in flight, settles on whenever it resolves while open, and
offers a retry only after a real failure. A permission dialog that ends
in a denial after Allow began a registration turns push back off and
restores the opt-out it cleared.
2026-10-27 16:48:07 -04:00
Juan Castro 415c179770 feat(sdk): puter.notifications.enablePush() and the enable-push popup
An app on its own domain can offer to turn push on in the user's browser
from its own button: puter.notifications.enablePush() opens a popup on
the Puter origin whose single button supplies the click the browser's
prompt needs, and inside the desktop the same dialog is shown over IPC.
The popup hands the opener no token. Docs for the new call and for the
paths that now call the push client's enable().
2026-10-27 16:27:23 -04:00
Juan Castro feb0937b6d feat(gui): register this browser for pushes again where it already allows them
On desktop and dashboard boot, and when an app that holds
notifications:push is launched, a browser whose notification permission
is already granted registers again without a click, unless the user
turned push off in this browser.
2026-10-27 16:27:23 -04:00
Juan Castro f880544ebe feat(gui): turn on browser push when an app is allowed to notify
Allow on a permission request that includes notifications:push also
turns push on in this browser, from the same click, when it is off here.
The decision never depends on it. The permission popup starts the
service worker for such a request, answers the opener first and closes
once the registration is done. The Notifications tab records a browser
the user turned off, per user.
2026-10-27 16:27:23 -04:00
Neal Shah 25a574b57d GUI for custom domains (#4355)
* PUT-2226

* modifications'

* routing fixes

* secondary custom domain verification
2026-10-27 15:54:02 -04:00
Juan Castro 389fd26bee fix(gui): turn on can't run twice at once
The browser card's button stays out while the client is enabling or
disabling, through any redraw in between, and a status read begun before
the press no longer draws the card.
2026-10-27 14:20:17 -04:00
Juan Castro d77ebadfe6 Merge juancastro/put-2171-push-service-worker into juancastro/put-2172-notifications-settings 2026-10-27 14:18:31 -04:00
Juan Castro 7e78a4feef fix(gui): the service worker never reuses cached imports
The worker is registered with updateViaCache set to none, so the scripts
it imports are fetched fresh on every update check.
2026-10-27 14:18:07 -04:00
Juan Castro 7aa4d91ae9 fix(gui): a tap delivered twice routes once
The page remembers the notifications it handed over in the last ten
seconds and drops a repeat, whichever way it arrives.
2026-10-27 14:17:57 -04:00
Juan Castro 5f7ab80721 fix(gui): local banner tags carry the app
An app message's group is tagged under its app, as the server tags a
push, so two apps using the same group name keep separate banners.
2026-10-27 14:17:48 -04:00
Juan Castro b3dbfdbd7b fix(gui): route push taps through the stored notification
A tap from the worker names only a notification; the desktop and the
dashboard read the signed-in user's own row and route from its app,
target and data, as a tap from the URL already did. Without a row the
dashboard opens the list and the desktop does nothing.
2026-10-27 14:17:40 -04:00
Juan Castro 60dfff3b0b Merge juancastro/put-2167-notification-written-hook into juancastro/put-2170-puterjs-notifications 2026-10-27 14:11:58 -04:00
Juan Castro 5a83c4a045 Merge juancastro/put-2170-puterjs-notifications into juancastro/put-2171-push-service-worker 2026-10-27 14:11:58 -04:00
Juan Castro fe8acf007c fix(notifications): read the app off the notification on an update 2026-10-27 14:11:46 -04:00
Juan Castro 8010c9fc94 Merge juancastro/put-2168-let-users-decide-which-apps-can-send-them-pushes into juancastro/put-2167-notification-written-hook 2026-10-27 14:11:10 -04:00
Juan Castro bbf375db14 fix(notifications): notif.written hands out a copy with the audience
Listeners get their own copy of the value, so what the socket carries is
what was written. Both emits name the audience, and an update names the
row's app when the row or the value says which.
2026-10-27 14:10:56 -04:00
Juan Castro fe70e7c6ba Merge juancastro/put-2251-let-apps-send-notifications-to-their-own-user into juancastro/put-2168-let-users-decide-which-apps-can-send-them-pushes 2026-10-27 14:08:01 -04:00
Juan Castro f0d7a69dd4 fix(notifications): cap the sender name on app messages
The app's title or name is cut to the title length, counting code points,
before it is stored on the message.
2026-10-27 14:07:27 -04:00
Juan Castro 5ce131517b fix(notifications): refuse an app message before it spends the day window
The minute window is spent first, so a request the minute window refuses
between the read and the spend leaves the day window untouched.
2026-10-27 14:07:25 -04:00
Daniel Salazar 49b38f3482 fix: seat creation (#4387) 2026-10-27 12:49:31 -04:00
Nariman JelvehandClaude Opus 5.5 241fb7be8a feat(dashboard): simplify the share modal
- The access level and Share button appear beside the field only once
  someone is entered, instead of a full-width button that sat disabled.
- The access list sits directly under the field. Each person's menu ends
  with Remove access (or Cancel invitation), replacing the trash column;
  the inline confirmation stays.
- General access is one row: a lock or globe icon, the switch with its
  explanation beneath, and the level on the right.
- A footer holds Copy link and Done. Copy link is offered for any single
  item, since the link opens it for anyone who has access, and confirms
  in place. Status messages live in the footer; successes fade.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-27 09:08:44 -07:00
dependabot[bot] 0167a9d10e chore(deps): bump fast-uri from 3.1.7 to 3.1.9 in /src/puter-js (#4336)
Bumps [fast-uri](https://github.com/fastify/fast-uri) from 3.1.7 to 3.1.9.
- [Release notes](https://github.com/fastify/fast-uri/releases)
- [Commits](https://github.com/fastify/fast-uri/compare/v3.1.7...v3.1.9)

---
updated-dependencies:
- dependency-name: fast-uri
  dependency-version: 3.1.9
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-27 08:54:47 -07:00
dependabot[bot] 5a879a4346 chore(deps): bump smtp-server from 3.19.23 to 3.19.24 (#4383)
Bumps [smtp-server](https://github.com/nodemailer/smtp-server) from 3.19.23 to 3.19.24.
- [Release notes](https://github.com/nodemailer/smtp-server/releases)
- [Changelog](https://github.com/nodemailer/smtp-server/blob/master/CHANGELOG.md)
- [Commits](https://github.com/nodemailer/smtp-server/compare/v3.19.23...v3.19.24)

---
updated-dependencies:
- dependency-name: smtp-server
  dependency-version: 3.19.24
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <[email protected]>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-27 08:54:33 -07:00
Juan Castro c13bff0563 fix(gui): removing this browser also deletes it on the server when its local record is gone 2026-10-27 11:10:32 -04:00
Juan Castro 2f94ee1088 fix(gui): removing this browser in Settings also forgets its push registration 2026-10-27 11:07:23 -04:00
Juan Castro 3068059058 Merge juancastro/put-2171-push-service-worker into juancastro/put-2172-notifications-settings 2026-10-27 11:06:38 -04:00
Juan Castro 7ee0cbfac9 feat(gui): the service worker tells open tabs when the push subscription changes 2026-10-27 11:06:14 -04:00
Juan Castro dfa6f4effc Merge juancastro/put-2167-notification-written-hook into juancastro/put-2170-puterjs-notifications 2026-10-27 10:44:19 -04:00
Juan Castro 20c63b6282 Merge juancastro/put-2168-let-users-decide-which-apps-can-send-them-pushes into juancastro/put-2167-notification-written-hook 2026-10-27 10:44:19 -04:00
Juan Castro dd44c0704a Merge juancastro/put-2171-push-service-worker into juancastro/put-2172-notifications-settings 2026-10-27 10:44:19 -04:00
Juan Castro 681b9cd463 Merge juancastro/put-2170-puterjs-notifications into juancastro/put-2171-push-service-worker 2026-10-27 10:44:19 -04:00
Juan Castro 9fb66137a3 Merge juancastro/put-2251-let-apps-send-notifications-to-their-own-user into juancastro/put-2168-let-users-decide-which-apps-can-send-them-pushes 2026-10-27 10:44:14 -04:00
Juan Castro 057aa116a4 Merge origin/main into juancastro/put-2251-let-apps-send-notifications-to-their-own-user 2026-10-27 10:44:11 -04:00
Daniel Salazar 1cea1b2a0a perf(thumbnails): keep read URLs stable within an hour (#4379) 2026-10-26 22:16:54 -07:00
Daniel Salazar 6c78d2f9e9 fix(ui): reject failed desktop IPC requests (#4378) 2026-10-26 22:14:19 -07:00
Daniel Salazar e8aa12ace5 fix(puter-js): replay requests with fresh body credentials (#4376) 2026-10-26 22:10:24 -07:00
Daniel Salazar f1964778b4 fix(puter-js): coordinate sign-in callbacks and filesystem sockets (#4375) 2026-10-26 22:09:52 -07:00
Daniel Salazar c8f7b1ceda fix(auth): support recovery codes when disabling two-factor auth (#4374) 2026-10-26 22:07:52 -07:00
Daniel Salazar 0411a92f4e perf(dashboard): share launch-app requests and size recent icons (#4373) 2026-10-26 22:07:33 -07:00
Daniel Salazar 5cfd4d1f53 fix(hosting): honor user-granted subdomain permissions (#4372) 2026-10-26 22:07:04 -07:00
Daniel Salazar e1a36215f6 fix(sharing): preserve team grants and scope link invalidation (#4371) 2026-10-26 22:06:16 -07:00
Daniel Salazar 6f13b1f457 fix(apps): compare normalized index URLs before update checks (#4370) 2026-10-26 22:05:21 -07:00
Daniel Salazar 996d85148e fix(fs): remove app data requested during uninstall (#4369) 2026-10-26 22:05:05 -07:00
Daniel Salazar 09da68424e fix(fs): preserve stored path spelling and app ACL identity (#4368) 2026-10-26 22:04:36 -07:00
Daniel Salazar 7bccde40a8 fix(hosting): use the current authenticated visitor for asset billing (#4364) 2026-10-26 22:04:17 -07:00
Daniel Salazar cf5d2c60a5 perf(permission): resolve default grants through full-access issuers (#4361) 2026-10-26 22:03:45 -07:00
Daniel Salazar b7a0276337 fix(fs): preserve paging and file read-copy contracts (#4367) 2026-10-26 22:03:09 -07:00
Daniel Salazar bbc056cc49 fix(hosting): track trashed roots and cached backing misses (#4363) 2026-10-26 21:58:05 -07:00
Daniel Salazar e2f6c2ed05 fix(cache): widen tombstone window to 5s (#4381) 2026-10-26 21:42:23 -07:00
Daniel Salazar 745b3a4318 perf(auth): coalesce session activity writes across processes (#4360) 2026-10-26 21:37:54 -07:00
Daniel Salazar 2dfd93687f perf(database): defer primary fallback for user lookups (#4359) 2026-10-26 21:37:06 -07:00
Daniel Salazar 91554a137e fix(apps): keep app provenance and permission caches consistent (#4357) 2026-10-26 21:28:40 -07:00
Daniel Salazar 52da7b571c perf(permission): read a large holder's user-to-user grants per permission (#4349)
A user-to-user grant check loaded the holder's whole row set, from one
Redis blob or one SELECT, and filtered it in JS. Every KV share handle a
user receives adds a row, so the cost of each check grew with incoming
shares.

Holders with up to 200 grants keep the whole-set cache. Past that the
cache holds a marker and each check reads only the permissions it names,
off a new (holder_user_id, permission) index. Matches stay exact in JS,
since MySQL compares `permission` case-insensitively. The batched
multi-holder read shares the same chunked query.

Migrations: mysql_mig_51, postgres_mig_40, sqlite 0097.
2026-10-26 20:51:46 -07:00
Nariman Jelveh 9e1ccf7ff3 fix(dashboard): ninth adversarial review of Files — bugs, UX, a11y, perf (#4356)
* fix(dashboard): keep Files keys away from the rows under a publish dialog

With Publish as website or Publish as Worker open and focus on one of its
buttons, the arrow keys moved the listing's selection behind the dialog,
Ctrl/Cmd+A selected every row, and Delete trashed whatever was selected.
The Files shortcuts already stood aside for the share and properties
modals; they now do for the dashboard dialog overlay too.

* fix(dashboard): let type-to-select in Files find names with spaces

Typing "my z" to reach "my zebra.txt" stopped at "my notes.txt": the space
went to the image preview instead of the name being typed, and the search
went on as "myz". A space typed while a name is being typed now belongs to
it; on its own, Space still opens and closes the preview.

* fix(gui): say so when a rename can't reach the server

A rename that failed on the network (offline, connection dropped) put the
old name back without a word, so in Files it looked as if the rename had
been ignored; only failures the server answered with a message were
reported. Every failure is now reported, with the unknown-cause message
when there is no other. The rename's own promise was also left to reject
unhandled on every failure, conflicts included; the error callback already
handles it.

* fix(dashboard): show a grid tile's full name on hover

A tile name cut short to fit shows in full on hover, but in the grid views
the hover never reached it: the badge layer is the size of the whole tile
and sat on top of the name. The name now sits above that layer, so its
tooltip shows; clicking and dragging by the name behave as before.

* fix(dashboard): make the empty-folder notice readable in dark mode

"No files in this directory." and "This folder couldn't be opened." were
styled inline with no color of their own and inherited the page default,
black, which on the dark theme's background could hardly be seen. Both now
share a class with the muted text color the Apps tab's empty state uses,
in either theme.

* fix(dashboard): let a folder be published again once its website is removed

Disassociating a folder's website in Properties took the globe badge off
its row, but the row's entry still said it had a website: its menu kept
Publish as website greyed out, and going into another folder and back
brought the badge back. The entry the menu and the listing read is now
updated with the row, and the sites cache is refreshed, as publishing does.

* fix(dashboard): keep a just-published worker's badge across navigation

Publishing a .js file as a worker badged its row, but the row's entry still
listed no worker. Going into a folder and back redrew the row from that
entry (the folder's cached listing), so the badge went away and the menu
offered Publish as Worker again, which would deploy the file a second time.
The entry now records the worker, as publishing a website records its site.

* fix(dashboard): badge a folder published from inside it once you go up

Publishing the folder on screen (from the listing's background menu) and
going Up showed its row without the globe badge, and with Publish as
website on offer again: the parent's listing came from puter.js's cache,
and adding a website sends no fs event to clear it. Removing a website from
inside the folder (Properties > Disassociate) left the badge behind the
same way. Both now drop the parent folder's cached listing.

* fix(dashboard): don't offer a second website for a published folder

A folder's own row greys out Publish as website once the folder has a
site, but its menu on the sidebar, a breadcrumb or the listing's
background kept offering it, and publishing again gave the folder a second
address. Those menus now check the user's sites as well.

* fix(dashboard): give Files' row menu button a finger-sized target on tablets

On a touch screen with room for the full list columns (an iPad in
landscape, say), a row's '⋯' was a 20x23px target right beside a row whose
tap opens the item, so a near miss opened the file instead of its menu.
On touch it now answers across the row's height and 44px wide; phones'
two-row layout keeps its 44px square, and mouse pointers are unchanged.

* fix(dashboard): keep Files' Back arrow clear of the phone menu button

On phones the breadcrumb row was shorter than the fixed menu button beside
it, so the button hung over the arrows below, and its enlarged tap area
took the top quarter of Back: a tap there opened the menu. The crumb row
is now as tall as the button, which also lines the crumbs up with it, and
on touch the crumbs themselves get a taller tap target.

* fix(dashboard): fit the Files image preview to the image and the listing

The Space preview was a strip the full width of the listing whatever the
image's size (a 64px icon sat in a card 900px wide), and at 3px from each
edge rather than the 20px it was given, its padding coming on top of its
width. In a short listing (a phone in landscape) it ran past the bottom of
the screen and cut off the file name. The card now wraps the image, stays
20px inside the listing both ways, and shrinks the image to keep the name
in view.

* fix(dashboard): let the last grid tile scroll clear of the phone footer

On a phone with a home indicator (a web app on the home screen, or Safari
with its bars collapsed) the Files footer grows by the bottom safe area.
The list makes room for that, but the grid views clear the footer with a
fixed 30px margin, so the bottom 17px or so of the last tile stayed behind
the footer even scrolled to the end. The grids' margin now grows with the
footer; select mode already made room for its bar and is unchanged.

* fix(dashboard): don't claim a large shared selection is restricted

Sharing more than 50 items skips listing who has access, but General
access still said "Restricted: only people with access can open these
items" without having looked, though some of them may be open to anyone
with the link. Choosing Restricted to close them did nothing either, since
it was already the selected option. It now says the link access wasn't
checked, and either choice applies to every item.

* fix(dashboard): don't let "Add to all" lower access on an item it couldn't list

Sharing several items lists who has access to each. When one item's
listing failed, the person's row counted that item as one they couldn't
reach and offered "Add to all", which granted it at the row's mode: someone
who could edit that item was quietly brought down to view. "Add to all"
now extends a grant only to items whose access was listed.

* fix(dashboard): let a click through as soon as a Files dialog closes

The share, properties and publish dialogs fade out for 200ms after they
close, and while they did, their invisible backdrop still took the
pointer: a click on a row right after Escape or Close was lost. The
backdrop now takes clicks only while the dialog is showing.

* fix(dashboard): don't make a removal under way look cancelled on Escape

In the share dialog, Escape on an open "Remove access" confirmation puts
the person's row back. Pressed after Remove was clicked, while the request
was still out, it did the same: the row came back as if the removal had
been called off, and then vanished when it finished. Escape now leaves a
removal that is under way alone.

* fix(dashboard): keep Share down while a share is on its way

Editing the name in the share dialog while a share was still being made
enabled the Share button again, so Enter sent a second share alongside
the first; the first one's end then took the spinner off while the second
was still running, and the two results raced for the status line. The
button now stays down until the share in flight is done.

* fix(gui): find the web address of files inside a published folder again

Files under a folder served as a website carry a link badge and their
address on the site. Working that out read `dir_path` and `address` off
each of the user's sites, which the hosting list no longer returns, so no
file ever had one. In Files the badges publishing just put up were gone at
the next listing. The folder and address are now read from the site's
root_dir, where the list carries them.

* fix(dashboard): show the link badge in the dark list view

In dark mode the list view drew a file's link badge (it is served by a
published website) as its black glyph on the dark row, where it couldn't
be seen. The grid view already gave it a light disc; the list view now
draws the glyph light.

* fix(dashboard): let Undo take back a paste of something cut from its menu

A row menu's Cut puts the item's bare path on the clipboard, where the
Files keys and the selection bar put { path, uid }. Pasting it moved the
item but recorded nothing for Undo (and didn't point Back/Forward at a
moved folder), so Ctrl/Cmd+Z then undid whatever came before, a rename
say. A bare path is now taken as an item like the rest.

* fix(gui): undo one action per Ctrl/Cmd+Z

The Undo shortcut sits in a handler bound to both keydown and keyup, and
ran on each: letting Z go before Ctrl/Cmd, as one usually does, undid a
second action straight after the first. In Files that could put back a
rename or delete an upload the user never meant to touch. It now runs on
the key going down only.

* fix(dashboard): re-sort and re-icon a Files row renamed elsewhere

A file or folder renamed in another tab, on another device or in a desktop
window took its new name in the listing but kept the place its old name
sorted to, and the icon of its old type (notes.txt renamed to notes.png
still showed a text icon). A renamed folder also left Back/Forward on its
old path. Such a row is now handled as a rename made in Files is.

* fix(dashboard): stop failed and cancelled Files uploads throwing uncaught

An upload from Files reports a failure in its progress window, through the
error callback, but the upload's promise also rejected with nobody
listening, so every failed or cancelled upload logged an uncaught error. It
is now handled there.
2026-10-26 17:21:35 -07:00
Filip Kujundžić 8f94514692 feat(ai): add GPT-6.1 Terra to the OpenAI catalog (#4350) 2026-10-26 16:01:44 -07:00
Juan Castro 2ad49e1040 Merge juancastro/put-2171-push-service-worker into juancastro/put-2172-notifications-settings 2026-10-26 18:50:14 -04:00
Juan Castro 57349a6982 fix(gui): keep the service worker on servers without push
Unregistering /sw.js wherever pushEnabled is unset ended the browser's
push subscription, so a single page load on such a server stopped pushes
from every server. Turning push off already ends the subscription.
2026-10-26 18:50:12 -04:00
Juan Castro ac0de9df44 feat(gui): notifications settings read a missing push service as unavailable
Without a push extension the /push routes answer core's plain 404. The
GUI maps that to push_unavailable, so the tab says pushes aren't
available on this server instead of reporting a load failure, and a
device removal only counts as done when the device itself is missing.
The push client doc describes the payload sw.js reads and where the
push endpoints come from.
2026-10-26 16:45:28 -04:00
Juan Castro 7d8711bf5b feat(gui): service worker that shows pushes and routes taps
sw.js shows each push as a notification and, on a tap, focuses or opens
a Puter window and hands it the tapped notification. The desktop routes
the tap to the app the notification names, opening it when needed, or
to the notifications panel. The worker is registered only when the
pushEnabled gui param is true.
2026-10-26 16:45:28 -04:00
Juan Castro af13c33678 feat(gui): notifications settings for push devices and apps
Settings -> Notifications turns pushes on and off for this browser
through a push client an extension registers, lists the user's devices
and the apps allowed to push, and switches Puter's own pushes. The tab
shows only when the pushEnabled gui param is true. The sessions window
shares the user agent formatting with it.
2026-10-26 16:45:28 -04:00
Juan Castro 5cf13547f2 feat(homepage): serve the service worker at /sw.js
/sw.js serves the GUI's sw.js, preceded by one importScripts per script
an extension registered with registerServiceWorkerScript. Whether the
GUI registers the worker follows the pushEnabled gui param, which only
an extension sets. Notification reads also carry the row's app_uid, so
the GUI can route a tap to the app a notification is about.
2026-10-26 16:45:28 -04:00
Juan Castro e181a25f95 feat(puter-js): device calls report push_unavailable without a push service
A deployment with no push service has no /push routes, so core answers
the device calls with its catch-all 404. registerDevice, listDevices and
removeDevice turn that into push_unavailable, kept apart from a missing
device, which comes back as device_not_found.
2026-10-26 16:36:46 -04:00
Juan Castro 5e376bc0c8 feat(puter-js): puter.notifications
An app notifies the user it runs for, reads its own notifications back
and follows new ones, hears clicks on them from the desktop, and asks
for or checks the notifications:push grant. Puter's own apps also
register, list and remove the user's devices.
2026-10-26 16:36:42 -04:00
Juan Castro fceaee6372 feat(notifications): node-local notif.written hook
NotificationService emits notif.written after each non-silent write and
after an in-place rewrite, on the node that wrote the row, so an
extension can act on new notifications without a second bus round trip.
It stays off the published event stream; notif.created carries the
notification itself.
2026-10-26 16:34:18 -04:00
Juan Castro 47a6122c37 refactor(permission): keep the notifications:push constant with the permission code
The consent check that reads it lives with the delivery code, outside
core. The grant flow test checks the grant through the permission
service directly.
2026-10-26 16:30:05 -04:00
Juan Castro 9fabd1d5b3 Merge juancastro/put-2251-let-apps-send-notifications-to-their-own-user into juancastro/put-2168-let-users-decide-which-apps-can-send-them-pushes 2026-10-26 16:28:52 -04:00
Juan Castro 32b28c1994 Merge origin/main into juancastro/put-2251-let-apps-send-notifications-to-their-own-user 2026-10-26 16:28:48 -04:00
Juan Castro c79b3edf4f Merge juancastro/put-2251-let-apps-send-notifications-to-their-own-user into juancastro/put-2168-let-users-decide-which-apps-can-send-them-pushes 2026-10-26 13:12:24 -04:00
Juan Castro d366861321 Merge origin/main into juancastro/put-2251-let-apps-send-notifications-to-their-own-user 2026-10-26 13:12:23 -04:00
Juan Castro c8998075ea fix(push): let users grant apps notifications:push
The grant route now only accepts permissions from a fixed set of
app-grantable families, which refused notifications:push, so an app could
never be given push consent. Register it next to events:background.
2026-10-26 11:55:37 -04:00
Juan Castro 759f1a798d Merge origin/juancastro/put-2251-let-apps-send-notifications-to-their-own-user into juancastro/put-2168-let-users-decide-which-apps-can-send-them-pushes 2026-10-26 11:30:50 -04:00
Juan Castro b57fef8c9d Merge origin/main into juancastro/put-2251-let-apps-send-notifications-to-their-own-user 2026-10-26 11:29:13 -04:00
sabrimete 18f5c9f2c7 fix(ai-chat): admit only chat-capable models from the Alibaba catalog
The feed admission gate now applies uniformly: declared capabilities
must include a chat capability (TG, Reasoning, VU), request modality
must include Text, and response modality must include Text. Signal-less
entries are admitted only when they match a curated model.
2026-10-25 21:07:31 +00:00
sabrimete f0f97a08cf fix(ai-chat): give uncurated Alibaba catalog models a puterId
Uncurated entries admitted from the DashScope catalog feed previously
surfaced with their raw feed id. They now get an
alibaba:<vendor>/<model> puterId, vendor-prefixed ids keep their org,
and others infer the org from the curated family sibling. Entries whose
synthesized puterId collides with a curated entry are skipped so the
model is not listed twice.
2026-10-25 21:04:28 +00:00
Juan Castro ad5f9b6196 fix(push): canonical app uid in the push consent check 2026-10-24 16:03:05 -04:00
Juan Castro 65b966c264 feat(push): notifications:push app permission
Apps need a per-app notifications:push grant for their notifications to
go out as pushes. hasPushConsent checks it as the app acting for the
user, so only that user's grant to that app answers yes, and a revoke
takes effect on the next check. The permission dialog describes it and
the perms docs list it.
2026-10-24 16:03:05 -04:00
Juan Castro c701b8d7c0 fix(notifications): show the sending app on app messages
App messages carry the sending app's uid and name (and its icon when it
is an https URL), set by the server; the desktop toast and the dashboard
panel show the name above the title. Title and text drop invisible
formatting marks and control characters, with newlines kept in text and
whitespace-only text left out. The icon is stored as its normalized URL,
and a target may not hold dot segments or non-ASCII characters. Both
rate-limit windows are read before either is spent, so a refused call
counts against neither. The docs say what ttl means for push delivery.
2026-10-24 16:02:54 -04:00
Juan Castro 4b41b9cd54 feat(notifications): apps can notify the user they act for
Adds the app.message type (audience app-user). An app, its workers and
the tokens it issues can create it through puter-notifications create,
only for their own user and under their own app. The value is validated
against a fixed format, the write is not silent, and each app has a
per-user budget of 30 per minute and 300 per day, configurable with
notificationAppMessageLimits. Holders keep creating the other types
silently and cannot create app.message.
2026-10-24 16:02:54 -04:00
587 changed files with 62023 additions and 7299 deletions

No files matched your search

@@ -47,3 +47,13 @@ jobs:
PUTER_TEST_AI_MOONSHOT_API_KEY: ${{ secrets.PUTER_TEST_AI_MOONSHOT_API_KEY }}
PUTER_TEST_AI_ZAI_API_KEY: ${{ secrets.PUTER_TEST_AI_ZAI_API_KEY }}
PUTER_TEST_AI_ELEVENLABS_API_KEY: ${{ secrets.PUTER_TEST_AI_ELEVENLABS_API_KEY }}
# Realtime conversations through the SDK against live OpenAI. Builds
# the SDK bundle these tests load itself, so it runs even when the
# step above fails; the tests skip themselves without the key.
- name: Run live realtime conversation tests
if: ${{ !cancelled() }}
run: npm run setupExtensions && (cd src/puter-js && npm run build) && npx vitest run --config src/puter-js/tests/api/vitest.config.ts src/puter-js/tests/api/realtime/convoLive.test.ts
env:
CI: 'true'
PUTER_TEST_AI_OPENAI_API_KEY: ${{ secrets.PUTER_TEST_AI_OPENAI_API_KEY }}
+5 -1
View File
@@ -118,9 +118,13 @@ jobs:
fail-fast: false
matrix:
include:
# The base branch lives in this repo; for a fork PR, the head
# repo's copy of it can be arbitrarily stale.
- ref: ${{ github.base_ref }}
repository: ${{ github.repository }}
artifact: base
- ref: ${{ github.head_ref }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
artifact: pr
steps:
@@ -129,7 +133,7 @@ jobs:
uses: actions/checkout@v7
with:
ref: ${{ matrix.ref }}
repository: ${{ github.event.pull_request.head.repo.full_name }}
repository: ${{ matrix.repository }}
- name: Set up Node.js
if: needs.changes.outputs.puterjs == 'true'
+1 -1
View File
@@ -201,6 +201,6 @@ Recipes are tested, copy-pasteable patterns for a task, one layer above the per-
- **Reuse existing UI primitives before writing new ones.** Windows and dialogs are `UIWindow*` functions in [src/gui/src/UI/](src/gui/src/UI/); generic pieces already exist (`UIAlert`, `UIPrompt`, `UIContextMenu`, `UINotification`, `UIPopover`, widgets in [UI/Components/](src/gui/src/UI/Components/)). A new window should read like its neighbors: an async function taking an options object, composing an HTML string, wiring behavior with jQuery, delegating to `UIWindow(...)`.
- **Match the visual language.** Use existing CSS classes (`button`, `button-primary`, window chrome, form styles) and copy the layout patterns of neighboring windows; new styles go in [src/gui/src/css/](src/gui/src/css/) following existing conventions. Verify anything positional (menus, overlays, z-index) on both desktop and mobile viewports.
- **i18n every user-facing string.** No hardcoded UI text — use `i18n('key')` and add the key to [src/gui/src/i18n/translations/en.js](src/gui/src/i18n/translations/en.js). Run `npm run check-translations` before opening the PR.
- **i18n every user-facing string.** No hardcoded UI text — use `i18n('key')` and add the key to [src/gui/src/i18n/translations/en.js](src/gui/src/i18n/translations/en.js).
- **Shared logic goes in helpers/services.** Reusable non-UI logic belongs in [src/gui/src/helpers/](src/gui/src/helpers/) or [src/gui/src/services/](src/gui/src/services/), not copy-pasted between windows.
- **Tests.** Vitest is wired for the GUI (`src/**/*.test.js`; see [appOrder.test.js](src/gui/src/UI/Dashboard/appOrder.test.js) for the shape). Extract pure logic into functions and test those. Desktop behavior driven through puter.js (`puter.ui.*`) is covered by the Playwright harness in [src/puter-js/tests/e2e/](src/puter-js/tests/e2e/) — add a spec there when you change how the desktop renders SDK-driven UI.
+7 -1
View File
@@ -73,6 +73,12 @@ COPY --from=build --chown=node:node /opt/puter .
RUN mkdir -p /etc/puter /var/puter && \
chown -R node:node /etc/puter /var/puter
# Fix outdated HOME_URL in /etc/os-release (see issue #4329)
# The base image may contain a reference to the old phoenix repository.
RUN if [ -f /etc/os-release ]; then \
sed -i 's|HOME_URL="https://github.com/HeyPuter/phoenix"|HOME_URL="https://github.com/HeyPuter/puter"|' /etc/os-release; \
fi
# Self-hosters mount their override at this exact path. The v2 loader
# deep-merges it over config.default.json (see backend/index.ts).
ENV PUTER_CONFIG_PATH=/etc/puter/config.json
@@ -87,4 +93,4 @@ USER node
HEALTHCHECK --interval=30s --timeout=3s --start-period=30s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://puter.localhost:4100/test || exit 1
CMD ["node", "-r", "./dist/src/backend/telemetry.js", "./dist/src/backend/index.js"]
CMD ["node", "-r", "./dist/src/backend/telemetry.js", "./dist/src/backend/index.js"]
+3
View File
@@ -350,6 +350,9 @@
// not, a row past this is swept. Off by default; any value above 0 arms
// the sweep.
// "notificationRetentionDays": 14,
//
// Ms a live notification waits for open tabs to acknowledge it.
// "notificationAckTimeoutMs": 5000,
// ── Alarms / alerting ───────────────────────────────────────────────
// Where system alarms go. Severity is the routing decision — each
+1 -1
View File
@@ -47,7 +47,7 @@ Both are supported ways to define an API. **Prefer a controller when you need fi
Provider sign-in requires the account's authenticator or a recovery code when 2FA is enabled, including signup callbacks that resolve an existing account. The five-minute challenge uses the same single-use `/login/otp` and `/login/recovery-code` verification as password sign-in. The GUI carries it in a URL fragment and removes it before initialization. A session and successful popup-return proof are issued only after verification; cancelling retains the popup's signed retry context without marking it authenticated.
Disabling 2FA through `/user-protected/disable-2fa` requires both password or OIDC revalidation and the current six-digit authenticator `code`. Missing, malformed, or incorrect codes leave the secret and recovery codes intact. A team's requirement for 2FA still prevents disabling it.
Disabling 2FA through `/user-protected/disable-2fa` requires both password or OIDC revalidation and either the current six-digit authenticator `code` or an unused recovery code in `code`. Recovery codes are consumed atomically. Missing or incorrect codes leave the secret and remaining recovery codes intact. A team's requirement for 2FA still prevents disabling it.
Email changes through `/user-protected/change-email` require the password or OIDC revalidation cookie, plus a six-digit `code` from the authenticator when 2FA is enabled. Missing or malformed codes return `400 bad_request`; incorrect codes return `401 code_mismatch`. Both include `factor: 'otp'`. Confirmation links are bound to that verified request. A 2FA-enabled account must request a new link if its existing link was issued without second-factor verification.
+4
View File
@@ -59,6 +59,10 @@ store:
row — and the query seeks past it (`WHERE (col, id) > (?, ?) ORDER BY col,
id`). Any SQL list gaining a cursor must have a deterministic `ORDER BY`
ending in a unique tiebreaker (`id`).
- The notification catch-up (`GET /events/fetch`) holds two keysets in one
sealed cursor: the last `id` it returned, and the last `(replaced_at, id)`
among rows up to that id. Each is its own indexed read, merged into one
page, so a row replaced after a client read it comes back from that cursor.
Cursors that carry a sort also pin it: a request that passes a cursor plus a
conflicting sort is rejected with 400.
+79
View File
@@ -0,0 +1,79 @@
# Browser push client
Puter's desktop shows pushes through `/sw.js`. Core registers it, and shows Settings → Notifications, only when the `pushEnabled` gui param is `true`; core never sets it. A backend extension that delivers pushes sets it with `services.homepage.setGuiParam('pushEnabled', true)` and can load its own code into the worker with `services.homepage.registerServiceWorkerScript(path)`.
Core subscribes the browser to a push service only when the server also publishes `pushWebPushKey` (see [Built-in Web Push client](#built-in-web-push-client)), and it has no `/push/*` routes of its own. The device, app, preference and subscription endpoints below come from that extension. On a deployment without one, such as most self-hosted servers, they answer core's plain 404 (`code: 'not_found'`), which `puter.notifications` reports as `push_unavailable` and the Notifications tab shows as unavailable. A device the extension can't find is `device_not_found`.
## Push payload
`sw.js` reads the push's JSON body. The message is its `puter` field, or `data.puter` for services that only carry string data, as an object or a JSON string:
```ts
{ uid?: string; title?: string; body?: string; icon?: string; group?: string;
target?: { app?: string; path?: string }; data?: unknown; channel?: string;
type?: 'challenge'; challenge?: string }
```
`uid` is the notification's uid, a later push with the same `group` replaces the one showing, and `target` and `data` reach the app when the user taps it.
When the browser replaces the subscription (`pushsubscriptionchange`), `sw.js` posts `{ type: 'puter-push-subscription-changed' }` to every open window on Puter's origin. The worker has no token or session, so the page's push client registers the new subscription.
## Receipts
Before a notification is pushed, the server asks the open clients whether they received it live. It sends `notif.receipt` with `{ uid }` on the user's sockets for the app that sent it, or on the desktop's sockets for Puter's own notifications. The SDK remembers the uids its page received through `onNotification()`, `onPush()` or the desktop's feed, and answers once its page has that notification, or with `{ received: false }` when it never gets it. The server waits up to 5 seconds (`notificationAckTimeoutMs`).
An answer carries `visibilityState`, `focused` and `showsOwn`, the last being whether the page shows the notification itself. A website page shows it itself when it is hidden or unfocused, has an `onPush()` handler and notification permission, and no other tab of the same site is focused. Inside the desktop, the desktop shows it, and an app's `onPush()` result is never shown as a browser notification.
- An answer from a page that is visible and focused, or that shows the notification itself, stops the push.
- Otherwise the notification is pushed. A website's pushes go only to the browsers that subscribed through its own worker, never to Puter's `/sw.js`. An app inside the desktop pushes through `/sw.js`, which shows the app's name and icon.
- A hidden desktop tab shows no banner, so answers only from hidden desktop tabs don't stop a push of Puter's own notifications.
- A client that can't answer, such as an older puter.js, holds the push back while it is connected.
## Registering
The registry is in [src/gui/src/helpers/pushClient.js](../src/gui/src/helpers/pushClient.js). A service script registers its client like this:
```js
service_script(async (api) => {
api.use()['push.registerPushClient']?.(client);
});
```
`use(id)` throws when nothing is registered under `id`, so the form above also runs on a Puter version without the registry and does nothing there. Any script that runs after the GUI bundle can call `use()['push.registerPushClient']?.(client)` the same way. Registering again replaces the client. Registering dispatches `push-client-registered` on `window`, and an open Notifications tab redraws.
With no client registered, the tab says push notifications aren't available in this browser.
### Built-in Web Push client
When the server also sets the `pushWebPushKey` gui param (a VAPID public key), core registers its own client ([webPushClient.js](../src/gui/src/helpers/webPushClient.js)) unless an extension registered one first. It uses `puter.notifications.enable()` / `disable()`, the same standard Web Push flow apps use on their own origins:
1. `GET /push/web/key` for the key, then `pushManager.subscribe()` on the `/sw.js` registration.
2. `POST /push/web/subscriptions { subscription }`. A subscription the server already has answers `{ status: 'active' }`. A new one answers `{ status: 'pending' }`, and the server sends it a push with `{ type: 'challenge', challenge }`.
3. The worker shows that push and posts `{ type: 'puter-push-challenge', challenge }` to the origin's windows; the page sends it to `POST /push/web/subscriptions/verify`. The device is registered once the page hands the confirmation back.
4. `DELETE /push/web/subscriptions { endpoint }` removes it.
Apps on their own origin do the same through `push-sw.js`, which they import into their own worker. A subscription made with an app token is the app's: its pushes are that app's notifications only.
## Interface
```ts
interface PushClient {
enable(): Promise<{ deviceUid: string }>;
disable(): Promise<void>;
status(): Promise<{
permission: 'default' | 'granted' | 'denied';
registered: boolean;
deviceUid?: string;
}>;
}
```
- `enable()` may ask for notification permission, so core calls it from a click: Turn on in the Notifications tab, Allow on a permission prompt that includes `notifications:push` while this browser is off, and the "Turn on notifications" button of the `/action/enable-push` popup and the matching desktop dialog (`UIEnablePushDialog`). Core also calls it without a click, from `helpers/pushSync.js`, when `Notification.permission` is already `granted`, `status()` reports the browser as not registered, the user has not turned push off in this browser, and an app holds `notifications:push`; the browser does not prompt then. The built-in client runs `puter.notifications.enable()`, which subscribes with the `/sw.js` registration and registers the browser through the Web Push flow above (`platform: 'webpush'`). It then resolves the `uid` of this browser's `webpush` device from `listDevices()`. It rejects when permission is refused or the browser wasn't registered.
- `disable()` runs `puter.notifications.disable()`, which sends `DELETE /push/web/subscriptions { endpoint }` and ends the browser's subscription. The tab calls it for Turn off, and for Remove on the device marked as this browser. Remove on any other device is `DELETE /push/devices/:uid`.
- `status()` reports the state without prompting. `permission` is `Notification.permission`. `registered` is true when this browser has a device registered for the signed-in user, and `deviceUid` is that device's `uid`.
After `enable()` resolves, the tab calls `POST /push/devices/:uid/test` and asks the user whether a notification showed up.
The tab also reads `GET /push/apps` and `GET`/`POST /push/preferences`.
Core keeps its own record of a browser the user turned off, `puter.push.browser-off:<user uuid>` in `localStorage`, written by Turn off and by Remove on this browser's device, and cleared when `enable()` resolves. The silent registration above skips a browser with that record, so a device removed here stays removed while one removed from another device, or a new sign-in in a browser that already allows notifications, registers again.
+19 -1
View File
@@ -12,7 +12,17 @@ Generates secrets, writes `.env` + `puter/config/config.json`, downloads `docker
## Requirements
- **Docker** with the `compose` plugin.
- **Docker** with the `compose` plugin. This is the one thing you have to install
yourself — the installer checks for it and stops with an explanation rather
than installing it for you.
- **macOS / Windows:** [Docker Desktop](https://docs.docker.com/get-docker/).
Both ship the `compose` plugin.
- **Linux:** [Docker Engine](https://docs.docker.com/engine/install/) plus the
[`docker-compose-plugin`](https://docs.docker.com/compose/install/) package.
- Confirm before running anything else: `docker compose version` should print a
version, not `command not found`.
- If Docker is installed but the shell can't see it, make sure your user is in
the `docker` group (Linux) or that Docker Desktop is actually running.
- A **domain** with DNS access — you need a wildcard record (`*.your-domain.com` → server IP). Puter routes by subdomain (`api.<domain>`, `site.<domain>`, `app.<domain>`).
- Optional: **TLS certs** (or `certbot` to grab them — see Step 3).
@@ -657,6 +667,14 @@ Migrations re-apply idempotently across pulls. Volumes are preserved.
## Troubleshooting
**`missing required command: docker` — the installer stops immediately.**
Docker isn't on your `PATH` yet. Puter self-hosting is a Docker Compose stack, so
nothing can run without it. Install [Docker Desktop](https://docs.docker.com/get-docker/)
on macOS/Windows or [Docker Engine](https://docs.docker.com/engine/install/) plus
`docker-compose-plugin` on Linux, then confirm `docker compose version` works in
the same shell and re-run the installer. On Linux, a fresh Docker install usually
needs a new login session before your user picks up the `docker` group.
**Site loads but I get a 502 / "Bad Gateway" from Caddy.**
The puter container failed to come up. `docker compose logs puter` will tell you which dependency rejected it (most often DB password mismatch between `.env` and `config.json`).
+35
View File
@@ -314,6 +314,41 @@ describe('thumbnails extension — handleThumbnailRead', () => {
await server?.shutdown();
});
it('keeps read URLs stable within an hour and rotates them at the next hour', async () => {
const key = mintedKey();
const signedAt = Date.UTC(2026, 9, 26, 10);
const read = async () => {
const entry: Record<string, unknown> = {
thumbnail: `s3://${BUCKET}/${key}`,
};
await handleThumbnailRead(entry, {
s3,
s3Presign,
bucketName: BUCKET,
bucketEndpoint: BUCKET_ENDPOINT,
fsEntry: server.stores.fsEntry,
});
return entry.thumbnail as string;
};
vi.useFakeTimers({ toFake: ['Date'] });
try {
vi.setSystemTime(signedAt + 1000);
const first = await read();
vi.setSystemTime(signedAt + 3599_000);
expect(await read()).toBe(first);
expect(new URL(first).searchParams.get('X-Amz-Date')).toBe(
'20261026T100000Z',
);
expect(new URL(first).searchParams.get('X-Amz-Expires')).toBe(
'604800',
);
vi.setSystemTime(signedAt + 3600_000);
expect(await read()).not.toBe(first);
} finally {
vi.useRealTimers();
}
});
// Thumbnails stored before keys were bound to entries keep displaying.
it.each([
['an entry-bound key', () => mintedKey()],
+5 -2
View File
@@ -307,6 +307,9 @@ export const handleThumbnailRead = async (
const thumb = entry.thumbnail;
if (typeof thumb !== 'string' || !thumb) return;
const presignClient = deps.s3Presign;
const signingDate = new Date(
Math.floor(Date.now() / 3_600_000) * 3_600_000,
);
if (isStoragePointer(thumb, deps.bucketEndpoint)) {
const key = resolveThumbnailKey(thumb);
@@ -319,7 +322,7 @@ export const handleThumbnailRead = async (
entry.thumbnail = await getSignedUrl(
presignClient,
new GetObjectCommand({ Bucket: deps.bucketName, Key: key }),
{ expiresIn: 604800 },
{ expiresIn: 604800, signingDate },
);
} else if (thumb.startsWith('data')) {
// Move an inline thumbnail into storage. The row only takes the new
@@ -373,7 +376,7 @@ export const handleThumbnailRead = async (
entry.thumbnail = await getSignedUrl(
presignClient,
new GetObjectCommand({ Bucket: deps.bucketName, Key: key }),
{ expiresIn: 604800 },
{ expiresIn: 604800, signingDate },
);
}
};
+21 -1
View File
@@ -53,9 +53,29 @@ need() {
command -v "$1" >/dev/null 2>&1 || die "missing required command: $1"
}
check_docker() {
if command -v docker >/dev/null 2>&1; then
return 0
fi
echo >&2 ""
echo >&2 "[puter-install] missing required command: docker"
echo >&2 ""
echo >&2 "Docker is required to run Puter self-hosted."
echo >&2 ""
echo >&2 "Installation instructions:"
echo >&2 " - macOS (Apple Silicon & Intel): https://docs.docker.com/desktop/setup/install/mac-install/"
echo >&2 " (Docker Desktop is recommended. Alternatives: OrbStack, Colima)"
echo >&2 " - Linux: https://docs.docker.com/engine/install/"
echo >&2 " - Windows: https://docs.docker.com/desktop/setup/install/windows-install/"
echo >&2 ""
echo >&2 "After installing Docker, make sure it's running and try again."
echo >&2 ""
exit 1
}
# ── Step 1: dependency check ────────────────────────────────────────
log "checking dependencies"
need docker
check_docker
need curl
need openssl
docker compose version >/dev/null 2>&1 \
+774 -749
View File
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -54,7 +54,6 @@
"dev": "npm start",
"build": "npm run setupExtensions && npm run build:ts && cd src/gui && node ./build.js && cd ../puter-js && npm run build",
"build:workerLib": "cd src/puter-js && npm run build && cd ../worker && npm run build",
"check-translations": "node tools/check-translations.js",
"prepare": "husky",
"build:ts": "tsc -p tsconfig.build.json && node ./tools/write-dist-package-json.mjs",
"check:puterjs:types": "node tools/checkPuterjsTypes.mjs",
@@ -79,7 +78,8 @@
"javascript-time-ago": "^2.5.11",
"libphonenumber-js": "1.13.15",
"miniflare": "^4.20260617.1",
"open": "^10.1.0"
"open": "^10.1.0",
"sharp": "^0.35.5"
},
"engines": {
"node": ">=24.0.0"
@@ -108,6 +108,25 @@ describe('AbstractDatabaseClient — unimplemented surface', () => {
});
describe('AbstractDatabaseClient — replica-aware reads', () => {
it('avoids a primary read when the replica has rows in sequential mode', async () => {
const client = new FixtureDatabaseClient([{ id: 1 }], [{ id: 2 }]);
expect(
await client.tryHardRead('SELECT 1', [], { race: false }),
).toEqual([{ id: 1 }]);
expect(client.preads).toEqual([]);
});
it.each([[], new Error('replica unavailable')])(
'falls back once in sequential mode',
async (replica) => {
const client = new FixtureDatabaseClient(replica, [{ id: 2 }]);
expect(
await client.tryHardRead('SELECT 1', [], { race: false }),
).toEqual([{ id: 2 }]);
expect(client.preads).toEqual(['SELECT 1']);
},
);
it('returns replica rows without waiting on the primary', async () => {
const client = new FixtureDatabaseClient([{ id: 1 }], [{ id: 2 }]);
await expect(client.tryHardRead('SELECT 1')).resolves.toEqual([
@@ -256,6 +275,16 @@ describe('AbstractDatabaseClient — SQL generation', () => {
expect(postgres().timestampFromUnixSql()).toBe('to_timestamp(?)');
});
it('reads a timestamp column back as unix seconds in each engine', () => {
expect(sqlite().unixSecondsSql('`t`')).toBe(
"CAST(strftime('%s', `t`) AS INTEGER)",
);
expect(mysql().unixSecondsSql('`t`')).toBe('UNIX_TIMESTAMP(`t`)');
expect(postgres().unixSecondsSql('`t`')).toBe(
'EXTRACT(EPOCH FROM `t`)::bigint',
);
});
it('describes a stored timestamp the way each driver reads it back', () => {
const seconds = Date.UTC(2026, 9, 11, 8, 5, 9) / 1000;
expect(sqlite().timestampValue(seconds)).toBe('2026-10-11 08:05:09');
+20 -5
View File
@@ -138,25 +138,27 @@ export class AbstractDatabaseClient extends PuterClient {
/**
* Like `read()`, but an empty or failed replica read falls back to the
* primary, so replication lag can't hide a row that was just written. The
* primary read starts alongside the replica one rather than after it.
* Without a replica this is a single `read()`.
* primary, so replication lag can't hide a row that was just written. By
* default the primary read starts alongside the replica one; `race: false`
* reads the primary only after the replica returns empty or fails. Without
* a replica this is a single `read()`.
*/
@Span('db.tryHardRead', (query: string) => ({ 'db.statement': query }))
async tryHardRead(
query: string,
params: unknown[] = [],
{ race = true }: { race?: boolean } = {},
): Promise<Record<string, unknown>[]> {
if (!this.hasReadReplica()) return this.read(query, params);
const primary = settle(this.pread(query, params));
const primary = race ? settle(this.pread(query, params)) : null;
try {
const rows = await this.replicaRead(query, params);
if (rows.length > 0) return rows;
} catch {
// replica failed — fall through to primary
}
const result = await primary;
const result = await (primary ?? settle(this.pread(query, params)));
if ('error' in result) throw result.error;
return result.value;
}
@@ -386,6 +388,19 @@ export class AbstractDatabaseClient extends PuterClient {
});
}
/**
* A timestamp column as unix seconds, computed by the database: the mysql
* driver reads a stored UTC timestamp as local time, so parsing it in JS
* can be off by the server's UTC offset.
*/
unixSecondsSql(column: string): string {
return this.case({
postgres: `EXTRACT(EPOCH FROM ${column})::bigint`,
mysql: `UNIX_TIMESTAMP(${column})`,
otherwise: `CAST(strftime('%s', ${column}) AS INTEGER)`,
});
}
/**
* What a read of a timestamp column holding `seconds` returns: sqlite gives
* back the UTC text it stored, mysql2 and pg a `Date` read in node's time
@@ -217,7 +217,8 @@ export class MySQLDatabaseClient extends AbstractDatabaseClient {
return this.configuration === Configuration.REPLICA;
}
// No failover: `tryHardRead` already has a primary read in flight.
// No failover: `tryHardRead` falls back to the primary on an empty result
// or error (raced alongside by default, after the replica with `race: false`).
protected override async replicaRead(
query: string,
params: unknown[],
@@ -27,7 +27,7 @@ import { DatabaseClientFactory } from './index.js';
import { SqliteDatabaseClient } from './SqliteDatabaseClient.js';
/** Highest schema version the migration table can reach. */
const CURRENT_SCHEMA_VERSION = 92;
const CURRENT_SCHEMA_VERSION = 95;
/**
* These suites migrate real files on disk. Idle they finish in well under a
@@ -673,6 +673,38 @@ describe('SqliteDatabaseClient — boot and migrations', { timeout: DISK_MIGRATI
});
});
it('adds kv_share_handles.notify from 0099, off for every existing share', async () => {
const dir = mkdtempSync(join(tmpdir(), 'puter-sqlite-notify-'));
const path = join(dir, 'puter.sqlite');
try {
const before = new SqliteDatabaseClient(
sqliteConfig({ inMemory: false, path, targetVersion: 94 }),
);
await before.onServerStart();
await before.write(
'INSERT INTO `kv_share_handles` (`handle`, `owner_user_id`, ' +
'`grantee_user_id`, `app_uid`, `key_prefix`, `permission`, ' +
'`created_at`) VALUES (?, 1, 1, ?, ?, ?, 0)',
['kvh-before-notify', 'os-global', 'a:', 'kv-share:x'],
);
before.onServerShutdown();
const after = new SqliteDatabaseClient(
sqliteConfig({ inMemory: false, path }),
);
await after.onServerStart();
await expect(
after.read(
'SELECT `notify` FROM `kv_share_handles` WHERE `handle` = ?',
['kvh-before-notify'],
),
).resolves.toEqual([{ notify: 0 }]);
after.onServerShutdown();
} finally {
rmSync(dir, { recursive: true, force: true });
}
});
it('indexes notification.created_at for the retention sweep', async () => {
const rows = await client.read('PRAGMA index_list(`notification`)');
expect(rows.map((r) => String(r.name))).toContain(
@@ -680,6 +712,18 @@ describe('SqliteDatabaseClient — boot and migrations', { timeout: DISK_MIGRATI
);
});
it('indexes notification.replaced_at per mailbox slice for catch-up', async () => {
const columns = await client.read(
'PRAGMA index_info(`idx_notification_replaced`)',
);
expect(columns.map((c) => String(c.name))).toEqual([
'user_id',
'audience',
'app_uid',
'replaced_at',
]);
});
it('leaves an already-migrated database untouched on a second boot', async () => {
const dir = mkdtempSync(join(tmpdir(), 'puter-sqlite-'));
const path = join(dir, 'nested', 'puter.sqlite');
@@ -131,6 +131,9 @@ const AVAILABLE_MIGRATIONS: [number, string[]][] = [
[89, ['0094_deleted-apps.sql']],
[90, ['0095_kv-share-handle-levels.sql']],
[91, ['0096_kv-share-handle-quota-indexes.sql']],
[92, ['0097_user-to-user-holder-permission-index.sql']],
[93, ['0098_notification-groups.sql']],
[94, ['0099_kv-share-handle-notify.sql']],
];
export class SqliteDatabaseClient extends AbstractDatabaseClient {
@@ -0,0 +1,38 @@
-- 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/>.
-- Lets a holder's grant check read only the permissions it names. Guarded, so
-- a replay does nothing.
DROP PROCEDURE IF EXISTS _puter_add_u2u_holder_permission_index;
DELIMITER //
CREATE PROCEDURE _puter_add_u2u_holder_permission_index()
BEGIN
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'user_to_user_permissions'
AND INDEX_NAME = 'idx_user_to_user_permissions_holder_permission'
) THEN
ALTER TABLE `user_to_user_permissions` ADD INDEX `idx_user_to_user_permissions_holder_permission` (`holder_user_id`, `permission`);
END IF;
END//
DELIMITER ;
CALL _puter_add_u2u_holder_permission_index();
DROP PROCEDURE IF EXISTS _puter_add_u2u_holder_permission_index;
@@ -0,0 +1,53 @@
-- 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/>.
-- `notification`.`group_key` and `replaced_at`. See
-- sqlite/0098_notification-groups.sql for the rationale. The key is a hex
-- digest, so ascii and compared byte for byte. No per-file applied-state
-- tracking, so every step is guarded and a replay does nothing.
CALL _puter_add_col('notification', 'group_key', '`group_key` char(64) CHARACTER SET ascii COLLATE ascii_bin DEFAULT NULL');
CALL _puter_add_col('notification', 'replaced_at', '`replaced_at` bigint DEFAULT NULL');
DROP PROCEDURE IF EXISTS _puter_add_notification_group_indexes;
DELIMITER //
CREATE PROCEDURE _puter_add_notification_group_indexes()
BEGIN
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'notification'
AND INDEX_NAME = 'idx_notification_group'
) THEN
ALTER TABLE `notification` ADD INDEX `idx_notification_group`
(`user_id`, `app_uid`, `group_key`);
END IF;
IF NOT EXISTS (
SELECT 1 FROM INFORMATION_SCHEMA.STATISTICS
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'notification'
AND INDEX_NAME = 'idx_notification_replaced'
) THEN
ALTER TABLE `notification` ADD INDEX `idx_notification_replaced`
(`user_id`, `audience`, `app_uid`, `replaced_at`);
END IF;
END//
DELIMITER ;
CALL _puter_add_notification_group_indexes();
DROP PROCEDURE IF EXISTS _puter_add_notification_group_indexes;
@@ -0,0 +1,23 @@
-- 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/>.
-- Notifications through a share handle. See
-- sqlite/0099_kv-share-handle-notify.sql for the column. No per-file
-- applied-state tracking, so it goes through _puter_add_col and a replay does
-- nothing.
CALL _puter_add_col('kv_share_handles', 'notify', '`notify` tinyint(1) NOT NULL DEFAULT 0');
@@ -0,0 +1,21 @@
-- 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/>.
-- Lets a holder's grant check read only the permissions it names.
CREATE INDEX IF NOT EXISTS idx_user_to_user_permissions_holder_permission
ON user_to_user_permissions (holder_user_id, permission);
@@ -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/>.
-- notification.group_key and replaced_at. See
-- sqlite/0098_notification-groups.sql for the rationale. Idempotent via IF NOT
-- EXISTS.
ALTER TABLE notification ADD COLUMN IF NOT EXISTS group_key varchar(64) DEFAULT NULL;
ALTER TABLE notification ADD COLUMN IF NOT EXISTS replaced_at bigint DEFAULT NULL;
CREATE INDEX IF NOT EXISTS idx_notification_group
ON notification (user_id, app_uid, group_key);
CREATE INDEX IF NOT EXISTS idx_notification_replaced
ON notification (user_id, audience, app_uid, replaced_at);
@@ -0,0 +1,22 @@
-- 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/>.
-- Notifications through a share handle. See
-- sqlite/0099_kv-share-handle-notify.sql for the column. Idempotent via
-- IF NOT EXISTS; there is no per-file applied-state tracking.
ALTER TABLE kv_share_handles ADD COLUMN IF NOT EXISTS notify smallint NOT NULL DEFAULT 0;
@@ -0,0 +1,21 @@
-- 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/>.
-- Lets a holder's grant check read only the permissions it names.
CREATE INDEX IF NOT EXISTS `idx_user_to_user_permissions_holder_permission`
ON `user_to_user_permissions` (`holder_user_id`, `permission`);
@@ -0,0 +1,38 @@
-- 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/>.
-- What app message groups need from `notification`:
--
-- - `group_key` : a sha256 hex digest of an app message's `group`, so the
-- notification a newer one replaces is found by index.
-- Null on every other row, and on rows written before
-- this, which therefore never match.
-- - `replaced_at` : epoch milliseconds of the last replacement, null for a
-- row never replaced. Live and fetched copies carry it as
-- their version, so a client can tell a replacement from a
-- copy it already has. Indexed per mailbox slice, so a
-- catch-up reads the rows replaced since its cursor
-- without scanning the ones that never were.
ALTER TABLE `notification` ADD COLUMN `group_key` TEXT DEFAULT NULL;
ALTER TABLE `notification` ADD COLUMN `replaced_at` INTEGER DEFAULT NULL;
CREATE INDEX IF NOT EXISTS `idx_notification_group`
ON `notification` (`user_id`, `app_uid`, `group_key`);
CREATE INDEX IF NOT EXISTS `idx_notification_replaced`
ON `notification` (`user_id`, `audience`, `app_uid`, `replaced_at`);
@@ -0,0 +1,23 @@
-- 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/>.
-- Whether the two parties of a share may send each other notifications through
-- its handle. Off by default, so every existing share reads as not allowing
-- them.
ALTER TABLE `kv_share_handles`
ADD COLUMN `notify` INTEGER NOT NULL DEFAULT 0;
+71 -4
View File
@@ -75,6 +75,15 @@ export type EventMap = {
serverStart: Record<string, never>;
serverPrepareShutdown: Record<string, never>;
serverShutdown: Record<string, never>;
/**
* Emitted by a host extension that knows this server will restart soon.
* Long-lived connections ask their clients to reconnect; nothing is refused
* or closed, so a false alarm is harmless.
*/
serverRestartAnnounced: {
/** How long until the server stops, when known. */
closeInMs?: number;
};
// ---- AI ----
'ai.prompt.validate': {
@@ -208,7 +217,7 @@ export type EventMap = {
*/
is_temp?: boolean;
ip?: string | null;
source?: 'oidc';
source?: 'oidc' | 'team_provision';
req?: unknown;
data?: unknown;
abuse?: unknown;
@@ -268,7 +277,7 @@ export type EventMap = {
ip?: string | null;
fingerprint?: string | null;
reputation?: number | null;
source?: 'oidc';
source?: 'oidc' | 'team_provision';
requires_phone_verification: boolean;
requires_card_verification: boolean;
accepted: boolean;
@@ -549,9 +558,28 @@ export type EventMap = {
appUid: string | null;
value: Record<string, unknown>;
createdAt: number;
/** An earlier notification replaced in place; `createdAt` is when. */
replaced?: true;
/** Events handler runs behind whatever created it. */
handlerDepth?: number;
};
/**
* Every non-silent write or update, only on the node that wrote it. The
* value is the listener's own copy of what the socket carries.
*/
'notif.written': {
userId: number;
uid: string;
type: string;
audience: string;
/** The row's app; null when it names none or an update could not tell. */
appUid: string | null;
value: Record<string, unknown>;
/** An earlier notification rewritten in place. */
updated?: true;
/** Names this one send, for a receipt asked about it. */
sendId: string;
};
/**
* One notification, addressed at whatever sockets each region holds for the
* recipient. `outer.*` reaches peer regions and never sibling nodes, so
@@ -560,9 +588,31 @@ export type EventMap = {
*/
'outer.notif.delivery': {
userId: number;
wire: 'notif.message' | 'notif.unreads' | 'notif.ack';
wire: 'notif.message' | 'notif.unreads' | 'notif.ack' | 'notif.removed';
response: unknown;
};
/**
* How one live notification went, on the node that asked for its receipt:
* from the desktop's sockets, or with `appUid` the app's own.
*/
'notif.delivery.settled': {
sendId?: string;
uid: string;
userId: number;
type: string;
/** The app whose own sockets were asked; absent for the desktop's. */
appUid?: string;
outcome: NotifDeliveryOutcome;
/** A socket in this region acknowledges the receipt. */
ackCapable: boolean;
/** No socket here is an older client that acknowledges nothing. */
everySocketAcks: boolean;
/** Every node here answered the ask, not just this one. */
complete: boolean;
/** A peer region holds a socket for the user. */
peerRegions: boolean;
responders: NotifDeliveryResponder[];
};
// ---- Metering ----
// Recurring charges are pure mechanism here: the metering service knows
@@ -704,6 +754,7 @@ export type EventMap = {
'outer.gui.item.renamed': GuiEvent;
'outer.gui.notif.ack': GuiEvent<{ uid: string }>;
'outer.gui.notif.message': GuiEvent<{ uid: string; notification: unknown }>;
'outer.gui.notif.removed': GuiEvent<{ uid: string }>;
'outer.gui.notif.unreads': GuiEvent<{
unreads: { uid: string; notification: unknown }[];
}>;
@@ -711,7 +762,7 @@ export type EventMap = {
// ---- Subdomains ----
// `uid` is the row uuid — for `delete` it is the only surviving handle a
// listener can use to find state keyed to the row after it is gone.
'subdomain.delete': { subdomain: string; uid?: string };
'subdomain.delete': { subdomain: string; uid?: string; userId: number };
'subdomain.update': { subdomain: string; uid?: string };
'site.htmlServed': {
subdomain: string;
@@ -982,6 +1033,22 @@ export type MatchingEvents<P extends ListenKey> = P extends `${infer Prefix}.*`
? Extract<EventKey, `${Prefix}.${string}`>
: P & EventKey;
/** Acked, not acked, no socket anywhere, or sockets only in a peer region. */
export type NotifDeliveryOutcome =
'received' | 'unreceived' | 'no-socket' | 'remote-only';
/** One socket's receipt for a live notification. */
export interface NotifDeliveryResponder {
visibilityState: 'visible' | 'hidden' | 'unknown';
/** The page's window has focus. Absent from older clients. */
focused?: boolean;
/**
* The page shows its own browser notification while hidden or unfocused.
* Absent from older clients.
*/
showsOwn?: boolean;
}
export type EventMetadata = {
from_outside?: boolean;
/** Set by `emitAndWait` when a listener threw, so a verdict hook can refuse. */
@@ -23,7 +23,7 @@
*/
import type { fetch as undiciFetch } from 'undici';
import { describe, expect, it } from 'vitest';
import { describe, expect, it, vi } from 'vitest';
import type { IConfig } from '../../types.js';
import {
DispatcherInvokeTransport,
@@ -33,6 +33,8 @@ import {
EVENTS_ERROR_HEADER,
EVENTS_HANDLED_HEADER,
EventsWorkerInvokerClient,
INVOKE_CONNECTIONS,
outcomeForStatus,
type WorkerInvokeRequest,
} from './EventsWorkerInvokerClient.js';
@@ -54,14 +56,22 @@ const stubFetch = (
(async () =>
new Response(null, { status, headers })) as unknown as FetchImpl;
/** A stub fetch that never answers until the abort signal fires. */
/**
* A stub fetch that never answers until the abort signal fires, then rejects
* with the signal's reason the way undici does.
*/
const hangingFetch: FetchImpl = ((_url: string, init?: RequestInit) =>
new Promise((_resolve, reject) => {
init?.signal?.addEventListener('abort', () =>
reject(new Error('This operation was aborted')),
reject(init.signal!.reason),
);
})) as unknown as FetchImpl;
/** A stub fetch that fails before any answer, as a refused connection does. */
const refusedFetch: FetchImpl = (async () => {
throw new TypeError('fetch failed');
}) as unknown as FetchImpl;
/** A stub fetch that records the URL it was called with. */
const capturingFetch = (
calls: string[],
@@ -141,13 +151,39 @@ describe('DispatcherInvokeTransport', () => {
});
});
it('answers null with an error when the request times out', async () => {
it('answers null and says so when the request times out', async () => {
const transport = new DispatcherInvokeTransport('http://dispatcher', 's', {
fetchImpl: hangingFetch,
});
const result = await transport.send({ ...CALL, timeoutMs: 10 });
expect(result.status).toBeNull();
expect(result.error).toMatch(/abort/i);
expect(result.timedOut).toBe(true);
});
it('answers null without a timeout when the request fails outright', async () => {
const transport = new DispatcherInvokeTransport('http://dispatcher', 's', {
fetchImpl: refusedFetch,
});
expect(await transport.send(CALL)).toEqual({
status: null,
error: 'fetch failed',
});
});
it('reads a retry-after in seconds, and ignores a date', async () => {
const answer = (value: string) =>
new DispatcherInvokeTransport('http://dispatcher', 's', {
fetchImpl: stubFetch(500, {
[EVENTS_HANDLED_HEADER]: '1',
[EVENTS_ERROR_HEADER]: 'platform-error',
'retry-after': value,
}),
}).send(CALL);
expect((await answer('30')).retryAfterMs).toBe(30_000);
expect(
(await answer('Wed, 21 Oct 2015 07:28:00 GMT')).retryAfterMs,
).toBeUndefined();
});
it('surfaces the runtime`s own error header alongside its status', async () => {
@@ -176,6 +212,97 @@ describe('DispatcherInvokeTransport', () => {
});
});
describe('DispatcherInvokeTransport with every connection busy', () => {
/** A fetch that answers 200 to one call at a time, when told to. */
const heldFetch = () => {
const held: Array<{ answer: () => void; signal: AbortSignal }> = [];
const fetchImpl = ((_url: string, init: RequestInit) =>
new Promise((resolve, reject) => {
const signal = init.signal!;
signal.addEventListener('abort', () => reject(signal.reason));
held.push({
signal,
answer: () =>
resolve(
new Response(null, {
status: 200,
headers: { [EVENTS_HANDLED_HEADER]: '1' },
}),
),
});
})) as unknown as FetchImpl;
return { fetchImpl, held };
};
/** Every connection taken by a call nobody has answered yet. */
const saturated = async () => {
const { fetchImpl, held } = heldFetch();
const transport = new DispatcherInvokeTransport(
'http://dispatcher',
's',
{ fetchImpl },
);
const busy = Array.from({ length: INVOKE_CONNECTIONS }, () =>
transport.send(CALL),
);
await vi.waitFor(() => expect(held).toHaveLength(INVOKE_CONNECTIONS));
const answerAll = async () => {
for (const call of held) call.answer();
await Promise.all(busy);
};
return { transport, held, answerAll };
};
it('waits for a free connection, then gives the run its whole timeout', async () => {
const { transport, held, answerAll } = await saturated();
const waiting = transport.send({ ...CALL, timeoutMs: 400 });
await new Promise((resolve) => setTimeout(resolve, 150));
expect(held).toHaveLength(INVOKE_CONNECTIONS);
held[0].answer();
await vi.waitFor(() =>
expect(held).toHaveLength(INVOKE_CONNECTIONS + 1),
);
// Past 400 ms from when the call was made, not from when it got its
// connection.
await new Promise((resolve) => setTimeout(resolve, 300));
expect(held[INVOKE_CONNECTIONS].signal.aborted).toBe(false);
await answerAll();
await expect(waiting).resolves.toMatchObject({ status: 200 });
});
it('fails a call that never got a connection as Puter`s, not the handler`s', async () => {
const { transport, held, answerAll } = await saturated();
await expect(
transport.send({ ...CALL, timeoutMs: 40 }),
).resolves.toEqual({
status: null,
error: 'no free connection to the events dispatcher',
});
const client = new EventsWorkerInvokerClient({
events: { invokeTimeoutMs: 40 },
} as unknown as IConfig);
client.setTransport(transport);
await expect(client.invoke(REQUEST)).resolves.toMatchObject({
outcome: 'unavailable',
});
expect(held).toHaveLength(INVOKE_CONNECTIONS);
// The slots come back once the calls holding them are answered.
await answerAll();
const after = transport.send(CALL);
await vi.waitFor(() =>
expect(held).toHaveLength(INVOKE_CONNECTIONS + 1),
);
held[INVOKE_CONNECTIONS].answer();
await expect(after).resolves.toMatchObject({ status: 200 });
});
});
const REQUEST: WorkerInvokeRequest = {
script: 'evw-test',
appUid: 'app-test',
@@ -275,7 +402,7 @@ describe('EventsWorkerInvokerClient deploy-on-miss', () => {
expect(calls).toHaveLength(1);
expect(result).toEqual({
outcome: 'retriable',
outcome: 'unavailable',
status: null,
error: 'deploy: stale',
});
@@ -300,7 +427,7 @@ describe('EventsWorkerInvokerClient deploy-on-miss', () => {
expect(handlerCalls).toBe(1);
// The first send plus the one retry — never a third.
expect(calls).toHaveLength(2);
expect(result.outcome).toBe('retriable');
expect(result.outcome).toBe('unavailable');
expect(result.status).toBeNull();
});
@@ -317,7 +444,7 @@ describe('EventsWorkerInvokerClient deploy-on-miss', () => {
expect(calls).toHaveLength(1);
expect(result).toEqual({
outcome: 'retriable',
outcome: 'unavailable',
status: null,
error: 'dispatcher: missing (404)',
});
@@ -342,7 +469,7 @@ describe('EventsWorkerInvokerClient deploy-on-miss', () => {
expect(handlerCalled).toBe(false);
expect(result).toEqual({
outcome: 'retriable',
outcome: 'unavailable',
status: null,
error: 'dispatcher: forbidden (403)',
});
@@ -402,9 +529,205 @@ describe('EventsWorkerInvokerClient deploy-on-miss', () => {
expect(calls).toHaveLength(2);
expect(deployCalls).toHaveLength(0);
expect(result).toEqual({
outcome: 'retriable',
outcome: 'unavailable',
status: null,
error: 'dispatcher: deploy-timeout (503)',
});
});
describe('a script still bound to a rotated key', () => {
/** Answers `bad-key` until it sees the deployed header, then settles. */
const badKeyUntilDeployedFetch = (
headersSeen: Array<Record<string, string>>,
): FetchImpl =>
(async (_url: string, init?: RequestInit) => {
const headers = (init?.headers ?? {}) as Record<string, string>;
headersSeen.push(headers);
return headers[EVENTS_DEPLOYED_HEADER] === '1'
? new Response(null, {
status: 200,
headers: { [EVENTS_HANDLED_HEADER]: '1' },
})
: new Response(null, {
status: 500,
headers: {
[EVENTS_HANDLED_HEADER]: '1',
[EVENTS_ERROR_HEADER]: 'bad-key',
},
});
}) as unknown as FetchImpl;
const clientWith = (fetchImpl: FetchImpl) => {
const client = makeClient();
client.setTransport(
new DispatcherInvokeTransport('http://dispatcher', 's', {
fetchImpl,
}),
);
return client;
};
it('is redeployed, then retried once with the deployed header', async () => {
const headersSeen: Array<Record<string, string>> = [];
const client = clientWith(badKeyUntilDeployedFetch(headersSeen));
const deploys: Array<[string, string]> = [];
client.setMissHandler(async (appUid, script) => {
deploys.push([appUid, script]);
return 'deployed';
});
const result = await client.invoke(REQUEST);
expect(deploys).toEqual([[REQUEST.appUid, REQUEST.script]]);
expect(headersSeen).toHaveLength(2);
expect(headersSeen[1][EVENTS_DEPLOYED_HEADER]).toBe('1');
expect(result).toEqual({ outcome: 'settled', status: 200 });
});
it('backs off, uncounted, when the redeploy is throttled', async () => {
const headersSeen: Array<Record<string, string>> = [];
const client = clientWith(badKeyUntilDeployedFetch(headersSeen));
client.setMissHandler(async () => 'throttled');
const result = await client.invoke(REQUEST);
expect(headersSeen).toHaveLength(1);
expect(result).toEqual({
outcome: 'unavailable',
status: null,
error: 'deploy: throttled',
});
});
it('stops after the one retry when the old key is still being served', async () => {
const calls: unknown[] = [];
const client = clientWith((async () => {
calls.push(null);
return new Response(null, {
status: 500,
headers: {
[EVENTS_HANDLED_HEADER]: '1',
[EVENTS_ERROR_HEADER]: 'bad-key',
},
});
}) as unknown as FetchImpl);
let deploys = 0;
client.setMissHandler(async () => {
deploys++;
return 'deployed';
});
const result = await client.invoke(REQUEST);
expect(deploys).toBe(1);
expect(calls).toHaveLength(2);
expect(result.outcome).toBe('unavailable');
});
it('deploys nothing for a handler`s own failure', async () => {
const client = clientWith(
stubFetch(500, {
[EVENTS_HANDLED_HEADER]: '1',
[EVENTS_ERROR_HEADER]: 'handler-threw',
}),
);
let deploys = 0;
client.setMissHandler(async () => {
deploys++;
return 'deployed';
});
expect((await client.invoke(REQUEST)).outcome).toBe('retriable');
expect(deploys).toBe(0);
});
});
it('reports a deploy that was throttled as Puter`s failure', async () => {
const client = makeClient();
client.setTransport(
new DispatcherInvokeTransport('http://dispatcher', 's', {
fetchImpl: alwaysMissingFetch([]),
}),
);
client.setMissHandler(async () => 'throttled');
expect((await client.invoke(REQUEST)).outcome).toBe('unavailable');
});
});
describe('whose failure an invocation was', () => {
const invokeWith = (fetchImpl: FetchImpl, timeoutMs?: number) => {
const client = new EventsWorkerInvokerClient({
events: timeoutMs ? { invokeTimeoutMs: timeoutMs } : {},
} as unknown as IConfig);
client.setTransport(
new DispatcherInvokeTransport('http://dispatcher', 's', {
fetchImpl,
}),
);
return client.invoke(REQUEST);
};
it('counts a run past the timeout against the handler', async () => {
expect((await invokeWith(hangingFetch, 10)).outcome).toBe('retriable');
});
it('puts a request that never got an answer on Puter', async () => {
expect((await invokeWith(refusedFetch)).outcome).toBe('unavailable');
});
it('puts an unmarked 5xx, 429 or 4xx on Puter', async () => {
for (const status of [500, 502, 429, 404]) {
const result = await invokeWith(stubFetch(status));
expect(result).toEqual({
outcome: 'unavailable',
status,
error: `unmarked ${status}`,
});
}
});
it('puts a handler failed by a Puter API call on Puter, with its wait', async () => {
const result = await invokeWith(
stubFetch(500, {
[EVENTS_HANDLED_HEADER]: '1',
[EVENTS_ERROR_HEADER]: 'platform-error',
'retry-after': '20',
}),
);
expect(result).toEqual({
outcome: 'unavailable',
status: 500,
error: 'platform-error',
retryAfterMs: 20_000,
});
});
it('answers unavailable with no dispatcher configured', async () => {
const result = await makeClient().invoke(REQUEST);
expect(result.outcome).toBe('unavailable');
});
});
describe('outcomeForStatus', () => {
it.each([
[200, true, undefined, 'settled'],
[204, false, undefined, 'settled'],
[400, true, 'handler-terminal', 'terminal'],
[404, true, 'unknown-handler', 'terminal'],
[500, true, 'handler-threw', 'retriable'],
[500, true, 'handler-broken', 'retriable'],
[429, true, undefined, 'retriable'],
[500, true, 'bad-key', 'unavailable'],
[500, true, 'no-token', 'unavailable'],
[500, true, 'platform-error', 'unavailable'],
[500, false, undefined, 'unavailable'],
[429, false, undefined, 'unavailable'],
[403, false, undefined, 'unavailable'],
] as const)(
'reads %i (handled: %s, %s) as %s',
(status, handled, errorCode, expected) => {
expect(outcomeForStatus(status, handled, errorCode)).toBe(expected);
},
);
});
@@ -68,6 +68,23 @@ export const EVENTS_HANDLED_HEADER = 'x-puter-events-handled';
/** Machine-readable reason on the runtime's own failure answers. */
export const EVENTS_ERROR_HEADER = 'x-puter-events-error';
/**
* The runtime's answer to a key it does not hold: the script was deployed under
* a secret that has since rotated, and redeploying it binds the current key.
*/
const BAD_KEY_ERROR = 'bad-key';
/**
* Runtime failure answers that are Puter's fault rather than the handler's: a
* key the script no longer matches, no token to run as, or a handler that
* failed because a Puter API call it made did.
*/
const PLATFORM_ERROR_CODES = new Set([
BAD_KEY_ERROR,
'no-token',
'platform-error',
]);
/**
* Set on the retry after this backend deploys a missing script itself, so the
* dispatcher skips its own deploy-on-miss callback and negative cache and goes
@@ -106,12 +123,19 @@ export const EVENTS_INVOKE_TIMEOUT_MS = 30_000;
/**
* Connections held open to the dispatcher, so a busy fleet is not
* re-handshaking. The 30s invoke abort starts when a request is queued, not
* when it gets a connection, so a saturated pool shows up as timeouts.
* re-handshaking. Also how many calls one transport makes at once: a call past
* that waits for a free one before its invoke timeout starts.
*/
const INVOKE_CONNECTIONS = 256;
export const INVOKE_CONNECTIONS = 256;
const INVOKE_KEEP_ALIVE_MS = 30_000;
/**
* A call waits at most this share of its invoke timeout for a connection, so
* the wait and the run together stay inside the worker lease (twice the
* timeout). Past it the call fails as Puter's, not the handler's.
*/
const CONNECTION_WAIT_SHARE = 0.5;
/** One invocation, as the events service hands it over. */
export interface WorkerInvokeRequest {
/** Script the app's current handler set deploys as. */
@@ -139,27 +163,38 @@ export interface EventsInvokeCall {
deployed?: boolean;
}
/** What one attempt at delivering an invocation came back with. */
export interface EventsInvokeAnswer {
/** `null` when no answer came back. */
status: number | null;
/** Set only when the answer provably came from the script. */
handled?: boolean;
error?: string;
/** Set only by the dispatcher transport, naming its own failure. */
dispatchReason?: string;
/** No answer within the invoke timeout. */
timedOut?: boolean;
/** From a `retry-after` header in seconds. */
retryAfterMs?: number;
}
/**
* Delivers an invocation. `status` null means nothing ran (retriable);
* `handled` is set only when the answer provably came from the script, so an
* unmarked 4xx is not a handler's refusal.
* Delivers an invocation. `handled` is set only when the answer provably came
* from the script, so an unmarked 4xx is not a handler's refusal.
*/
export interface EventsInvokeTransport {
send(call: EventsInvokeCall): Promise<{
status: number | null;
handled?: boolean;
error?: string;
/** Set only by the dispatcher transport, naming its own failure. */
dispatchReason?: string;
}>;
send(call: EventsInvokeCall): Promise<EventsInvokeAnswer>;
}
/**
* `settled`: handler accepted (2xx). `terminal`: handler refused (marked 4xx),
* not retried. `retriable`: no handler answer (5xx, 429, timeout, transport
* failure, unmarked 4xx).
* not retried. `retriable`: the handler failed or ran past the timeout.
* `unavailable`: Puter could not start or finish the run (transport, dispatch
* and deploy failures, unmarked answers, a Puter API failure inside the
* handler); retried like `retriable` but never the handler's fault.
*/
export type WorkerInvokeOutcome = 'settled' | 'terminal' | 'retriable';
export type WorkerInvokeOutcome =
'settled' | 'terminal' | 'retriable' | 'unavailable';
export interface WorkerInvokeResult {
outcome: WorkerInvokeOutcome;
@@ -167,6 +202,8 @@ export interface WorkerInvokeResult {
status: number | null;
/** Why it could not answer, when nothing did. */
error?: string;
/** How long the answer asked to be left alone. */
retryAfterMs?: number;
}
const NO_TRANSPORT =
@@ -187,8 +224,8 @@ export class EventsWorkerInvokerClient extends PuterClient {
/**
* Deploys a script the dispatcher reports missing, in place of the
* rehydrate callback — for the case where that callback lands on a backend
* other than this one. Set once; `invoke()` calls it at most once per
* delivery and never loops.
* other than this one — or one still bound to a rotated key. Set once;
* `invoke()` calls it at most once per delivery and never loops.
*/
setMissHandler(handler: WorkerMissHandler): void {
this.#missHandler = handler;
@@ -202,7 +239,11 @@ export class EventsWorkerInvokerClient extends PuterClient {
async invoke(request: WorkerInvokeRequest): Promise<WorkerInvokeResult> {
const transport = this.#pickTransport();
if (!transport)
return { outcome: 'retriable', status: null, error: NO_TRANSPORT };
return {
outcome: 'unavailable',
status: null,
error: NO_TRANSPORT,
};
const call: EventsInvokeCall = {
script: request.script,
@@ -218,48 +259,50 @@ export class EventsWorkerInvokerClient extends PuterClient {
};
let result = await transport.send(call);
if (result.status === null && result.dispatchReason) {
if (
this.#missHandler &&
SELF_DEPLOYABLE_MISS_REASONS.has(result.dispatchReason)
) {
const outcome = await this.#missHandler(
request.appUid,
request.script,
);
if (outcome !== 'deployed')
return {
outcome: 'retriable',
status: null,
error: `deploy: ${outcome}`,
};
result = await transport.send({ ...call, deployed: true });
} else if (
ALREADY_DEPLOYED_MISS_REASONS.has(result.dispatchReason)
) {
// No deploy to trigger — just give propagation the one retry
// it usually needs, without spending an upload on it.
result = await transport.send({ ...call, deployed: true });
}
if (this.#missHandler && selfDeployable(result)) {
const outcome = await this.#missHandler(
request.appUid,
request.script,
);
if (outcome !== 'deployed')
return {
outcome: 'unavailable',
status: null,
error: `deploy: ${outcome}`,
};
result = await transport.send({ ...call, deployed: true });
} else if (
result.status === null &&
result.dispatchReason &&
ALREADY_DEPLOYED_MISS_REASONS.has(result.dispatchReason)
) {
// No deploy to trigger — just give propagation the one retry
// it usually needs, without spending an upload on it.
result = await transport.send({ ...call, deployed: true });
}
const { status, handled, error } = result;
const { status, handled, error, timedOut, retryAfterMs } = result;
// Past the timeout the handler was most likely still running, which
// is its own doing; any other missing answer never reached it.
if (status === null)
return { outcome: 'retriable', status: null, error };
return {
outcome: timedOut ? 'retriable' : 'unavailable',
status: null,
error,
};
const outcome = outcomeForStatus(status, handled === true);
// An unmarked 4xx (429 aside — that one is always retriable) is not
// the handler's own answer; say so even when the transport did not,
// so the reason a delivery kept retrying shows up in the log rather
// than reading as an unexplained retry.
const unmarkedFourxx =
status !== 429 && status >= 400 && status < 500 && !handled;
const outcome = outcomeForStatus(status, handled === true, error);
// An unmarked failure is not the handler's own answer; say so even
// when the transport did not, so the reason a delivery kept retrying
// shows up in the log rather than reading as an unexplained retry.
const unmarked = status >= 300 && !handled;
const reportedError =
error ?? (unmarkedFourxx ? 'unmarked 4xx' : undefined);
error ?? (unmarked ? `unmarked ${status}` : undefined);
return {
outcome,
status,
...(reportedError ? { error: reportedError } : {}),
...(retryAfterMs ? { retryAfterMs } : {}),
};
}
@@ -291,6 +334,10 @@ export class DispatcherInvokeTransport implements EventsInvokeTransport {
readonly #secret: string;
readonly #fetchImpl: typeof undiciFetch;
#agent: Agent | null = null;
/** Calls holding a connection slot. */
#active = 0;
/** Calls waiting for one, longest-waiting first. */
readonly #waiting = new Set<() => void>();
constructor(
dispatcherUrl: string,
@@ -307,12 +354,14 @@ export class DispatcherInvokeTransport implements EventsInvokeTransport {
this.#agent = null;
}
async send(call: EventsInvokeCall): Promise<{
status: number | null;
handled?: boolean;
error?: string;
dispatchReason?: string;
}> {
async send(call: EventsInvokeCall): Promise<EventsInvokeAnswer> {
// No `timedOut`: the handler never got this call, so the wait is not
// its failure.
if (!(await this.#acquire(call.timeoutMs * CONNECTION_WAIT_SHARE)))
return {
status: null,
error: 'no free connection to the events dispatcher',
};
try {
// `new URL(path, base)` resolves an absolute path against the
// base's origin, dropping any path prefix `base` already carries.
@@ -347,21 +396,45 @@ export class DispatcherInvokeTransport implements EventsInvokeTransport {
dispatchReason: dispatchError,
};
const handled = response.headers.get(EVENTS_HANDLED_HEADER) === '1';
const errorHeader = response.headers.get(EVENTS_ERROR_HEADER);
return {
status: response.status,
handled,
...(errorHeader ? { error: errorHeader } : {}),
};
return answerFrom(response);
} catch (err) {
return {
status: null,
error: err instanceof Error ? err.message : String(err),
};
return failedAnswer(err);
} finally {
this.#release();
}
}
/** A connection slot, or false once `waitMs` passes without one. */
#acquire(waitMs: number): Promise<boolean> {
if (this.#active < INVOKE_CONNECTIONS) {
this.#active++;
return Promise.resolve(true);
}
return new Promise((resolve) => {
const granted = () => {
clearTimeout(timer);
resolve(true);
};
const timer = setTimeout(() => {
this.#waiting.delete(granted);
resolve(false);
}, waitMs);
timer.unref?.();
this.#waiting.add(granted);
});
}
/** Hand the slot to the longest waiter, or give it back. */
#release(): void {
const next = this.#waiting.values().next().value;
if (next) {
this.#waiting.delete(next);
next();
return;
}
this.#active--;
}
/** One pool per process, built on first use and closed with the server. */
#dispatcherFor(): Agent {
this.#agent ??= new Agent({
@@ -373,18 +446,61 @@ export class DispatcherInvokeTransport implements EventsInvokeTransport {
}
}
/** Whether deploying the script is what fixes this answer. */
const selfDeployable = (result: EventsInvokeAnswer): boolean =>
result.status === null
? result.dispatchReason !== undefined &&
SELF_DEPLOYABLE_MISS_REASONS.has(result.dispatchReason)
: result.handled === true && result.error === BAD_KEY_ERROR;
/** What a script's answer says, read the same way by every transport. */
export const answerFrom = (response: {
status: number;
headers: { get(name: string): string | null };
}): EventsInvokeAnswer => {
const errorHeader = response.headers.get(EVENTS_ERROR_HEADER);
const retryAfterMs = parseRetryAfterMs(response.headers.get('retry-after'));
return {
status: response.status,
handled: response.headers.get(EVENTS_HANDLED_HEADER) === '1',
...(errorHeader ? { error: errorHeader } : {}),
...(retryAfterMs ? { retryAfterMs } : {}),
};
};
/** An attempt that threw before any answer came back. */
export const failedAnswer = (err: unknown): EventsInvokeAnswer => ({
status: null,
error: err instanceof Error ? err.message : String(err),
// What `AbortSignal.timeout` rejects with.
...((err as { name?: unknown } | null)?.name === 'TimeoutError'
? { timedOut: true }
: {}),
});
/** Whole seconds only; an HTTP-date form reads as absent. */
const parseRetryAfterMs = (value: string | null): number => {
const seconds = Number(value);
return value && Number.isFinite(seconds) && seconds > 0
? seconds * 1000
: 0;
};
/**
* 2xx took it regardless. 429 is always "not now". Any other 4xx is a refusal
* only when `handled` says the answer is provably the script's own — an
* unmarked 4xx came from something between here and the handler.
* 2xx took it regardless. Anything else not marked as the script's own answer
* came from something between here and the handler. A marked answer is a
* refusal when it is a 4xx other than 429, Puter's failure when the runtime
* names one, and the handler's failure otherwise.
*/
export const outcomeForStatus = (
status: number,
handled: boolean,
errorCode?: string,
): WorkerInvokeOutcome => {
if (status >= 200 && status < 300) return 'settled';
if (!handled) return 'unavailable';
if (errorCode && PLATFORM_ERROR_CODES.has(errorCode)) return 'unavailable';
if (status === 429) return 'retriable';
if (status >= 400 && status < 500)
return handled ? 'terminal' : 'retriable';
if (status >= 400 && status < 500) return 'terminal';
return 'retriable';
};
@@ -0,0 +1,85 @@
/*
* 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 { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
describe('auth elevation cookies over HTTP', () => {
let env: PuterTestEnv;
beforeAll(async () => {
env = await setupPuterTestEnv();
}, 120_000);
afterAll(async () => {
await env?.shutdown();
});
it.each(['bearer', 'cookie'])(
'API host returns a usable token without setting cookies (%s auth)',
async (credential) => {
const { token, password, username } = env.users.user;
const res = await fetch(new URL('/auth/elevate', env.apiOrigin), {
method: 'POST',
headers: {
'content-type': 'application/json',
...(credential === 'cookie'
? { cookie: `puter_auth_token=${token}` }
: { authorization: `Bearer ${token}` }),
},
body: JSON.stringify({ password }),
});
expect(res.status).toBe(200);
expect(res.headers.get('set-cookie')).toBeNull();
const body = (await res.json()) as {
elevated: boolean;
token: string;
};
expect(body.elevated).toBe(true);
expect(typeof body.token).toBe('string');
const user = await env.server.stores.user.getByUsername(username);
const { verifyStepUpSession } =
await import('../../core/http/middleware/stepUpSession.js');
expect(
verifyStepUpSession(
{
headers: { 'x-puter-elevation': body.token },
actor: { user: { uuid: user!.uuid } },
} as never,
{ tokenService: env.server.services.token },
),
).toBe(true);
},
);
it('GUI host sets the elevation cookie and omits the raw token for cookie auth', async () => {
const { token, password } = env.users.user;
const res = await fetch(new URL('/auth/elevate', env.origin), {
method: 'POST',
headers: {
'content-type': 'application/json',
cookie: `puter_auth_token=${token}`,
},
body: JSON.stringify({ password }),
});
expect(res.status).toBe(200);
expect(res.headers.get('set-cookie')).toContain('puter_elevated=');
expect(await res.json()).toEqual({ elevated: true });
});
});
@@ -42,6 +42,10 @@ import {
adminOnlyGate,
requireUserActorGate,
} from '../../core/http/middleware/gates.js';
import {
configureRateLimit,
listConfiguredRateLimitBackends,
} from '../../core/http/middleware/rateLimit.js';
import {
ROUTES_METADATA_KEY,
type CollectedRoute,
@@ -50,7 +54,10 @@ import {
import { PuterServer } from '../../server.js';
import { createSecret as otpCreateSecret } from '../../services/auth/OTPUtil.js';
import { kvSharePermission } from '../../services/kvShare/kvShares.js';
import { FULL_API_ACCESS } from '../../services/permission/consts.js';
import {
FULL_API_ACCESS,
NOTIFICATIONS_PUSH_PERMISSION,
} from '../../services/permission/consts.js';
import { setupTestServer } from '../../testUtil.js';
import { resetCardVerificationStatusCache } from '../../util/cardFallback.js';
import { FS_READ_LIMIT } from '../fs/limits.js';
@@ -1750,6 +1757,40 @@ describe('AuthController.handleLoginOtp + handleLoginRecoveryCode', () => {
).rejects.toMatchObject({ statusCode: 400 });
});
it('refuses the sign-in while the used-token store is down', async () => {
const { user, secret, otpToken } = await startOtpLogin();
const token = await otpToken();
const { default: backend } = listConfiguredRateLimitBackends();
configureRateLimit({
default: 'redis',
redis: {
multi: () => {
throw new Error('store down');
},
},
});
const logged = vi.spyOn(console, 'error').mockImplementation(() => {});
const res = makeRes();
try {
await expect(
controller.handleLoginOtp(
makeReq({ token, code: liveTotp(user.username, secret) }),
res,
),
).rejects.toThrow('store down');
} finally {
logged.mockRestore();
configureRateLimit({
default: backend,
redis: server.clients.redis,
kv: server.stores.kv,
metering: server.services.metering,
});
}
expect(res.body).toBeUndefined();
});
it('a wrong code leaves the token usable for a retry', async () => {
const { user, secret, otpToken } = await startOtpLogin();
const token = await otpToken();
@@ -2179,6 +2220,78 @@ describe('AuthController grant flows', () => {
).toContain(permission);
});
it('grant-user-app: grants and revokes notifications:push for an app', async () => {
const app = await server.stores.app.create(
{
name: `tgp-${uuidv4()}`,
title: 'TestGrantPushApp',
index_url: 'https://example.test/index.html',
},
{ ownerUserId: issuer.id },
);
const user = await server.stores.user.getById(issuer.id);
const appActor = makeActor({
user: user!,
app: { uid: app.uid, id: app.id },
});
const hasPush = () =>
server.services.permission.check(
appActor,
NOTIFICATIONS_PUSH_PERMISSION,
);
expect(await hasPush()).toBe(false);
// Anything past the bare permission names nothing a prompt describes.
await expect(
inCtx(issuerActor, () =>
controller.handleGrantUserApp(
makeReq(
{
app_uid: app.uid,
permission: `${NOTIFICATIONS_PUSH_PERMISSION}:extra`,
},
{ actor: issuerActor },
),
makeRes(),
),
),
).rejects.toMatchObject({
statusCode: 400,
legacyCode: 'permission_not_grantable',
});
expect(await hasPush()).toBe(false);
const res = makeRes();
await inCtx(issuerActor, () =>
controller.handleGrantUserApp(
makeReq(
{
app_uid: app.uid,
permission: NOTIFICATIONS_PUSH_PERMISSION,
},
{ actor: issuerActor },
),
res,
),
);
expect(res.body).toEqual({});
expect(await hasPush()).toBe(true);
await inCtx(issuerActor, () =>
controller.handleRevokeUserApp(
makeReq(
{
app_uid: app.uid,
permission: NOTIFICATIONS_PUSH_PERMISSION,
},
{ actor: issuerActor },
),
makeRes(),
),
);
expect(await hasPush()).toBe(false);
});
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.
@@ -7612,6 +7725,46 @@ describe('AuthController permission revokes', () => {
);
expect(res.body).toEqual({});
});
it('revoke-user-user: refuses a key-value share grant and leaves it standing', async () => {
const { actor: ownerActor, user: owner } = await makeUserAndActor();
const { user: holder } = await makeUserAndActor();
const permissions = ['kv-share', 'kv-share-read', 'kv-share-write'].map(
(root) => `${root}:${owner.uuid}:os-global:notes:${uuidv4()}`,
);
for (const permission of permissions)
await server.stores.permission.upsertUserUserPerm(
holder.id,
owner.id,
permission,
{},
);
for (const permission of permissions)
await expect(
inCtx(ownerActor, () =>
controller.handleRevokeUserUser(
makeReq(
{ target_username: holder.username, permission },
{ actor: ownerActor },
),
makeRes(),
),
),
).rejects.toMatchObject({
statusCode: 400,
message: expect.stringContaining('puter.kv.unshare()'),
});
expect(
(
await server.stores.permission.findUserUserPermsByIssuer(
holder.id,
owner.id,
permissions,
)
).sort(),
).toEqual([...permissions].sort());
});
});
// ── Permission checks + listing ────────────────────────────────────
+57 -13
View File
@@ -3304,6 +3304,14 @@ export class AuthController extends PuterController {
{ legacyCode: 'bad_request' },
);
}
// Only unsharing retires the handle along with its grant; withdrawing
// the grant alone would leave the share listed and dead.
if (typeof permission === 'string' && isKvSharePermission(permission))
throw new HttpError(
400,
'Stop a key-value share with `puter.kv.unshare()` (`DELETE /kv/shares/:handle`)',
{ legacyCode: 'bad_request' },
);
await this.services.permission.revokeUserUserPermission(
req.actor!,
target_username,
@@ -3810,8 +3818,9 @@ export class AuthController extends PuterController {
let app = await this.stores.app.getByUid(app_uid);
if (!app && resolvedFromOrigin) {
// Hosted-subdomain origins get the site owner stamped as the
// app's creator at bootstrap; external origins stay unowned.
// Hosted-subdomain and custom-domain origins get the site owner
// stamped as the app's creator at bootstrap; external origins
// stay unowned.
// Canonical origin only, so alternate hosts share one row.
const canonicalOrigin =
this.services.auth.canonicalizeOrigin(origin);
@@ -3827,9 +3836,10 @@ export class AuthController extends PuterController {
// An origin's uid is a deterministic uuidv5, so a deleted app
// reappears here under the identical uid. Withdraw any cross-app
// data grants left pointing at it before this new row can inherit
// consent the user gave its predecessor. Only *this* path can reuse
// a uid: `AppStore.create` mints a random uuid4, which no deleted
// app can ever hold again.
// consent the user gave its predecessor. Only `createFromOrigin`
// reuses a uid, here or wherever else an origin app is bootstrapped:
// `AppStore.create` mints a random uuid4, which no deleted app can
// ever hold again.
//
// Called directly rather than through `app.changed`: the token is
// issued below, so this has to be able to stop that, and
@@ -3840,6 +3850,9 @@ export class AuthController extends PuterController {
app_uid,
'uid reused by a new app',
);
// Key-value shares the predecessor left read as gone already,
// being older than this row; this only cleans them up.
this.services.kvShare.retireSharesOlderThanApp(app);
}
if (!app) {
throw new HttpError(404, `App ${app_uid} does not exist`, {
@@ -4275,7 +4288,27 @@ export class AuthController extends PuterController {
);
}
this.#requireAccountTwoFactor(user, req.body?.code);
const code = req.body?.code;
if (
user.otp_enabled &&
typeof code === 'string' &&
/^[A-Z0-9]{8}$/.test(code)
) {
await this.#assertSecondFactorAttemptsRemain(user.uuid);
const consumed = await this.stores.user.consumeOtpRecoveryCode(
user.id,
hashRecoveryCode(code),
);
if (!consumed) {
await this.#recordSecondFactorFailure(user.uuid);
throw new HttpError(401, 'Incorrect code.', {
legacyCode: 'code_mismatch',
fields: { factor: 'otp' },
});
}
} else {
this.#requireAccountTwoFactor(user, code);
}
await this.stores.user.update(user.id, {
otp_enabled: false,
@@ -4485,11 +4518,14 @@ export class AuthController extends PuterController {
}
const token = signStepUpToken(this.services.token, user as never);
res.cookie(
STEP_UP_COOKIE_NAME,
token,
stepUpCookieOptions(this.config),
);
const isApiHost = req.subdomains?.at(-1) === 'api';
if (!isApiHost) {
res.cookie(
STEP_UP_COOKIE_NAME,
token,
stepUpCookieOptions(this.config),
);
}
// A browser reads its elevation back from the httpOnly cookie and never
// needs the raw value; handing it to page JS would put the second factor
@@ -4498,7 +4534,9 @@ export class AuthController extends PuterController {
// password/TOTP — this only avoids needless exposure, it isn't a gate.
const cookieName = this.config.cookie_name ?? 'puter_token';
const usedSessionCookie =
!!req.token && req.token === req.cookies?.[cookieName];
!isApiHost &&
!!req.token &&
req.token === req.cookies?.[cookieName];
res.json(
usedSessionCookie ? { elevated: true } : { elevated: true, token },
);
@@ -4611,13 +4649,19 @@ export class AuthController extends PuterController {
await checkRateLimit(`${scope}:${userUid}`, limit, window);
}
/** An `otp-login` JWT completes one sign-in; a second redemption is refused. */
/**
* An `otp-login` JWT completes one sign-in; a second redemption is refused.
* Fails closed: while the store can't say whether the token was used, the
* sign-in is refused rather than letting a captured token replay.
*/
async #consumeOtpLoginToken(token: string): Promise<void> {
const digest = crypto.createHash('sha256').update(token).digest('hex');
const firstUse = await checkRateLimit(
`login-otp-token-used:${digest}`,
1,
OTP_LOGIN_TOKEN_USED_WINDOW_MS,
undefined,
{ failOpen: false },
);
if (!firstUse) {
throw new HttpError(400, 'Invalid token.', {
@@ -0,0 +1,89 @@
import { v4 as uuidv4 } from 'uuid';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { hashRecoveryCode } from '../../services/auth/OTPUtil.js';
import {
createTestUser,
setupPuterTestEnv,
type PuterTestEnv,
} from '../../testUtil.js';
describe('2FA after signing in with a recovery code', () => {
let env: PuterTestEnv;
const password = 'lost-authenticator-password';
beforeAll(async () => {
env = await setupPuterTestEnv();
}, 120_000);
afterAll(async () => {
await env?.shutdown();
});
const post = (
path: string,
body: Record<string, unknown>,
cookie?: string,
) =>
fetch(new URL(path, env.origin), {
method: 'POST',
headers: {
'content-type': 'application/json',
...(cookie ? { cookie } : {}),
},
body: JSON.stringify(body),
});
it('allows an unused recovery code to disable 2FA after password revalidation', async () => {
const username = `lost2fa_${uuidv4().slice(0, 8)}`;
await createTestUser(env.server, { username, password });
const user = (await env.server.stores.user.getByUsername(username))!;
await env.server.stores.user.update(user.id, {
email: `${username}@example.com`,
email_confirmed: true,
otp_enabled: true,
otp_secret: 'TESTSECRETBASE32',
otp_recovery_codes: ['RECOVER1', 'RECOVER2', 'RECOVER3']
.map(hashRecoveryCode)
.join(','),
});
// Sign in with a recovery code: works, and yields a web session.
const login = await post('/login', { username, password });
expect(login.status).toBe(202);
const { otp_jwt_token } = await login.json();
const recovered = await post('/login/recovery-code', {
token: otp_jwt_token,
code: 'RECOVER1',
});
expect((await recovered.json()).next_step).toBe('complete');
const cookie = recovered.headers
.getSetCookie()
.find((value) => value.startsWith('puter_auth_token='))!
.split(';')[0]!;
const reused = await post(
'/user-protected/disable-2fa',
{ password, code: 'RECOVER1' },
cookie,
);
expect(reused.status).toBe(401);
expect(
(await env.server.stores.user.getById(user.id, { force: true }))!
.otp_enabled,
).toBeTruthy();
const disable = await post(
'/user-protected/disable-2fa',
{ password, code: 'RECOVER2' },
cookie,
);
expect(disable.status).toBe(200);
expect(await disable.json()).toMatchObject({ success: true });
const after = await env.server.stores.user.getById(user.id, {
force: true,
});
expect(after!.otp_enabled).toBeFalsy();
expect(after!.otp_secret).toBeNull();
expect(after!.otp_recovery_codes).toBeNull();
});
});
@@ -21,7 +21,7 @@
import { EventEmitter } from 'node:events';
import type { Request, RequestHandler, Response } from 'express';
import { beforeEach, describe, expect, it } from 'vitest';
import { runWithContext } from '../../core/context.js';
import { Context, runWithContext } from '../../core/context.js';
import { isHttpError } from '../../core/http/HttpError.js';
import { configureRateLimit } from '../../core/http/middleware/rateLimit.js';
import { DriverController } from './DriverController.js';
@@ -278,6 +278,86 @@ describe('DriverController — concurrent acquire/release', () => {
expect(res2.body).toMatchObject({ success: true, result: 'pong' });
});
// A driver that keeps working after the caller hangs up (async video
// polling) must keep counting against the cap until it settles.
it('holds the slot while the driver still works after a disconnect', async () => {
let finish = () => {};
const driver = {
...makeSyntheticDriver(),
ping: () =>
new Promise<string>((resolve) => {
finish = () => resolve('pong');
}),
};
const handler = captureCallHandler(buildController(driver));
const req = () =>
makeReq({ interface: 'test-iface', method: 'ping' }, 'working');
const res1 = new StubRes();
const first = callInScope(handler, req(), res1 as unknown as Response);
await new Promise((r) => setImmediate(r));
res1.emit('close');
await new Promise((r) => setImmediate(r));
await expect(
callInScope(handler, req(), new StubRes() as unknown as Response),
).rejects.toSatisfy(
(e) =>
isHttpError(e) &&
(e as { statusCode: number }).statusCode === 429,
);
finish();
await first;
await new Promise((r) => setImmediate(r));
const res2 = new StubRes();
const second = callInScope(handler, req(), res2 as unknown as Response);
await new Promise((r) => setImmediate(r));
finish();
await second;
res2.emit('finish');
expect(res2.body).toMatchObject({ success: true, result: 'pong' });
});
it('holds the slot for work a driver keeps past its response', async () => {
let settleWork = () => {};
const driver = {
...makeSyntheticDriver(),
ping: async () => {
Context.get('keepDriverSlot')?.(
new Promise<void>((resolve) => {
settleWork = resolve;
}),
);
return 'pong';
},
};
const handler = captureCallHandler(buildController(driver));
const req = () =>
makeReq({ interface: 'test-iface', method: 'ping' }, 'kept');
const res1 = new StubRes();
await callInScope(handler, req(), res1 as unknown as Response);
res1.emit('finish');
await new Promise((r) => setImmediate(r));
await expect(
callInScope(handler, req(), new StubRes() as unknown as Response),
).rejects.toSatisfy(
(e) =>
isHttpError(e) &&
(e as { statusCode: number }).statusCode === 429,
);
settleWork();
await new Promise((r) => setImmediate(r));
const res2 = new StubRes();
await callInScope(handler, req(), res2 as unknown as Response);
res2.emit('finish');
settleWork();
expect(res2.body).toMatchObject({ success: true, result: 'pong' });
});
it('does not attach release listeners when the driver declares no concurrent config', async () => {
// With no spec there is nothing to release, so the gate must not
// wire `finish`/`close` release listeners. The client-disconnect
@@ -50,6 +50,7 @@ import { PermissionUtil } from '../../services/permission/permissionUtil.js';
import type { WithLifecycle } from '../../types';
import { withSpan } from '../../util/span.js';
import { PuterController } from '../types.js';
import { CallSlot } from './callSlot.js';
type DriverInstance = WithLifecycle & Record<string, unknown>;
@@ -259,6 +260,17 @@ export class DriverController extends PuterController {
// -- Handlers ----------------------------------------------------
#handleCall = async (req: Request, res: Response): Promise<void> => {
const slot = new CallSlot();
const call = this.#call(req, res, slot);
slot.keep(call);
await call;
};
#call = async (
req: Request,
res: Response,
slot: CallSlot,
): Promise<void> => {
const {
interface: ifaceName,
method,
@@ -395,10 +407,9 @@ export class DriverController extends PuterController {
// Per-method rate-limit and concurrent specs both live on the
// driver's resolved meta. Rate-limit is single-shot; concurrent
// acquires a slot that must be released when the response is done
// — we hook `res.finish` / `res.close` for that so streamed
// responses hold their slot until the stream drains, and aborted
// requests still give the slot back.
// acquires a slot held until the response is done and the call's
// work has settled, so streamed responses hold it until the stream
// drains and work that continues after a disconnect still counts.
const rateLimitSpec = resolvePerMethod(driverMeta?.rateLimit, method);
if (
!(await checkDriverRateLimit(req, ifaceName, method, rateLimitSpec))
@@ -430,18 +441,8 @@ export class DriverController extends PuterController {
legacyCode: 'too_many_requests',
});
}
let released = false;
const release = () => {
if (released) return;
released = true;
void handle.release();
};
// If the handler throws before responding, the express error
// handler will eventually send a response — `finish` fires then,
// so we still release. `close` covers client aborts.
res.once('finish', release);
res.once('close', release);
if (res.destroyed || res.writableFinished) release();
slot.attach(handle, res);
Context.set('keepDriverSlot', (work) => slot.keep(work));
}
// Stash the requested driver name in Context so multi-provider
@@ -0,0 +1,73 @@
/*
* 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 { EventEmitter } from 'node:events';
import type { Response } from 'express';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { CallSlot } from './callSlot.js';
const handle = () => ({
release: vi.fn(() => Promise.resolve()),
renew: vi.fn(() => Promise.resolve()),
});
const deferred = () => {
let resolve = () => {};
const promise = new Promise<void>((r) => {
resolve = r;
});
return { promise, resolve };
};
afterEach(() => {
vi.useRealTimers();
});
describe('CallSlot', () => {
it('renews a slot held by a long call and stops once it is released', async () => {
vi.useFakeTimers();
const slot = new CallSlot();
const slotHandle = handle();
const res = new EventEmitter();
const call = deferred();
slot.keep(call.promise);
slot.attach(slotHandle, res as unknown as Response);
res.emit('close');
await vi.advanceTimersByTimeAsync(25 * 60 * 1000);
expect(slotHandle.renew).toHaveBeenCalledTimes(2);
expect(slotHandle.release).not.toHaveBeenCalled();
call.resolve();
await vi.advanceTimersByTimeAsync(0);
expect(slotHandle.release).toHaveBeenCalledOnce();
await vi.advanceTimersByTimeAsync(25 * 60 * 1000);
expect(slotHandle.renew).toHaveBeenCalledTimes(2);
});
it('releases once the response is done if no work is outstanding', async () => {
const slot = new CallSlot();
const slotHandle = handle();
const res = new EventEmitter();
slot.attach(slotHandle, res as unknown as Response);
res.emit('finish');
expect(slotHandle.release).toHaveBeenCalledOnce();
expect(slotHandle.renew).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,80 @@
/*
* 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 type { Response } from 'express';
// Under the concurrency store's one-hour orphan TTL.
const SLOT_RENEW_MS = 10 * 60 * 1000;
interface SlotHandle {
release(): Promise<void>;
renew?(): Promise<void>;
}
/**
* A driver call's concurrency slot, held until the response is done and the
* call's work has settled, including work a driver carries on after the caller
* hung up or was answered.
*/
export class CallSlot {
#handle?: SlotHandle;
#work = new Set<Promise<unknown>>();
#responded = false;
#released = false;
#renew?: NodeJS.Timeout;
attach(handle: SlotHandle, res: Response): void {
this.#handle = handle;
const responded = () => {
this.#responded = true;
this.#maybeRelease();
};
res.once('finish', responded);
res.once('close', responded);
if (res.destroyed || res.writableFinished) responded();
if (!this.#released && this.#work.size > 0) this.#startRenewing();
}
keep(work: Promise<unknown>): void {
if (this.#released) return;
this.#work.add(work);
if (this.#handle) this.#startRenewing();
const settled = () => {
this.#work.delete(work);
this.#maybeRelease();
};
work.then(settled, settled);
}
#startRenewing(): void {
if (this.#renew) return;
this.#renew = setInterval(() => {
void this.#handle?.renew?.();
}, SLOT_RENEW_MS);
this.#renew.unref?.();
}
#maybeRelease(): void {
if (this.#released || !this.#handle) return;
if (!this.#responded || this.#work.size > 0) return;
this.#released = true;
clearInterval(this.#renew);
void this.#handle.release();
}
}
@@ -104,6 +104,24 @@ export class EventsController extends PuterController {
});
}
/**
* POST /events/subscriptions/update — replace one subscription's `context`
* in place. An id the caller does not hold reads as absent.
*/
@Post('/subscriptions/update', {
subdomain: 'api',
requireAuth: true,
allowAccessToken: true,
})
async update(req: Request, res: Response): Promise<void> {
const actor = this.#requireActor(req);
const { sub } = await this.services.events.updateDurable(
actor,
bodyRecord(req),
);
res.json(sub);
}
/**
* GET /events/fetch — what the caller missed, one page at a time.
*
+55 -6
View File
@@ -93,6 +93,18 @@ export interface TieredQuota {
*/
export const EVENTS_SESSION_SUBSCRIPTIONS_PER_SOCKET = 50;
/**
* Live subscriptions one user may hold through a share handle someone else
* minted for them, across all of their connections.
*
* Those rows sit in the owner's keyspace, where each one costs a filter
* evaluation on the owner's writes. The count also takes in connections that
* dropped and haven't been reaped yet, so the cap sits far above what
* reconnects leave behind. The owner's own rows through the handle don't
* count.
*/
export const EVENTS_SESSION_SUBSCRIPTIONS_PER_KV_HANDLE = 1000;
/**
* Durable subscriptions one account may hold, across every app.
*
@@ -125,6 +137,13 @@ export const EVENTS_DURABLE_SUBSCRIPTIONS_MAX =
*/
export const SUSPENDED_ROW_TTL_DAYS = 30;
/**
* Subscriptions stopped by a revoked grant that one account keeps for `list`.
* They hold no quota slot, so past this the oldest are deleted when the account
* subscribes again, keeping its rows bounded.
*/
export const EVENTS_REVOKED_SUBSCRIPTIONS_KEPT = 500;
/**
* Subscribe calls per minute, per user.
*
@@ -155,6 +174,13 @@ export const EVENTS_LIST_LIMIT = userWindow('events:list', 120);
*/
export const EVENTS_ACK_LIMIT = userWindow('events:ack', 600);
/**
* How long a connected client holds a queued delivery before the events worker
* may take it. A client that has no handler for it, or whose handler fails,
* declines it at once; this only bounds one that never answers.
*/
export const EVENTS_SOCKET_LEASE_MS = 15_000;
/**
* Share-handle mint + revoke calls per minute, per user.
*
@@ -197,17 +223,35 @@ export const EVENTS_KV_HANDLES_PER_APP = tiered(10_000, 2_000, 0);
export const EVENTS_KV_HANDLES_PER_HOLDER = 256;
/**
* Recipients one account may share with, per minute and per hour. A batch
* spends one per recipient, on top of the per-call mint budget.
* Recipients one account may share with, per minute and per hour. A call spends
* one per recipient that doesn't already hold the share, on top of the per-call
* mint budget.
*/
export const KV_SHARE_GRANT_LIMITS = [
userWindow('kvShare:grants:minute', 300),
userWindow('kvShare:grants:hour', 2_000, 60 * 60_000),
] as const;
/**
* Recipients one account may name in share calls per minute, existing shares
* included. Each one is looked up before the call knows whether it changes
* anything, so a client that shares again on every load is bounded here.
*/
export const KV_SHARE_LOOKUP_LIMIT = userWindow('kvShare:lookups', 1_200);
/** Recipients one `POST /kv/shares` call may name. */
export const KV_SHARE_BATCH_MAX = 50;
/**
* Notifications one party of a share may send the other through share handles,
* per minute and per day, in one app. Keeps one share from spending the
* recipient's whole notification allowance.
*/
export const KV_SHARE_NOTIFY_LIMITS = [
userWindow('kvShare:notify:minute', 30),
userWindow('kvShare:notify:day', 500, 24 * 60 * 60_000),
] as const;
// -- Handler surface -------------------------------------------------
/**
@@ -304,8 +348,11 @@ export const EVENTS_KV_MATCHED_SUBSCRIPTIONS_PER_EVENT = tiered(512, 128, 128);
*/
export const EVENTS_KV_FILTER_EVALUATIONS_PER_EVENT = tiered(2048, 512, 512);
/** Omit all inline KV values when the matched audience exceeds this count. */
export const EVENTS_KV_VALUE_OMIT_MATCHED_SUBSCRIPTIONS = 128;
/**
* Deliveries of one key-value change that carry its value inline, in the order
* the fan-out serves them. Later ones carry the key alone.
*/
export const EVENTS_KV_VALUE_DELIVERIES_PER_EVENT = 128;
/**
* Largest inline KV value in serialized bytes. Larger values are omitted;
@@ -397,7 +444,9 @@ export const EVENTS_REGION_PENDING_CEILING = 1_000_000;
// a handler that answers "no" is not. Retrying on a fixed cadence turns one
// broken deploy into a permanent load on whatever is failing, so the wait
// doubles per attempt up to a ceiling, and a run of failures stops the
// subscription rather than retrying it forever.
// subscription rather than retrying it forever. A run Puter could not start or
// finish backs off the same way but is not the handler's failure, so it never
// counts toward that run.
/** Wait before the first retry of a delivery a handler could not take. */
export const EVENTS_RETRY_BASE_MS = 2_000;
@@ -406,7 +455,7 @@ export const EVENTS_RETRY_BASE_MS = 2_000;
export const EVENTS_RETRY_MAX_MS = 5 * 60 * 1000;
/**
* Failures in a row before a subscription is suspended. Counted per
* Handler failures in a row before a subscription is suspended. Counted per
* subscription and reset by the first delivery a handler takes, so an
* occasional failure never accumulates into one.
*/
@@ -49,6 +49,8 @@ type Layers = ConstructorParameters<typeof EventsWorkerDeployer>[0];
const makeLayers = (owners: Record<string, Owner | null>) => {
const redis = new MockRedis.Cluster(['redis://localhost:7001']);
const created: string[] = [];
/** Apps whose handler sources were read, once per read. */
const sourceReads: string[] = [];
const layers = {
config: {
events: { workerRuntime: true, internalSecret: 'sekret' },
@@ -60,16 +62,20 @@ const makeLayers = (owners: Record<string, Owner | null>) => {
clients: { redis },
stores: {
eventHandler: {
allForApp: async (appUid: string) => [
{
appUid,
name: 'h',
source: 'async () => {}',
sourceHash: SOURCE_HASH,
createdAt: 0,
updatedAt: 0,
},
],
setForApp: async () => [{ name: 'h', sourceHash: SOURCE_HASH }],
allForApp: async (appUid: string) => {
sourceReads.push(appUid);
return [
{
appUid,
name: 'h',
source: 'async () => {}',
sourceHash: SOURCE_HASH,
createdAt: 0,
updatedAt: 0,
},
];
},
},
app: {
getByUid: async (appUid: string) => {
@@ -104,7 +110,12 @@ const makeLayers = (owners: Record<string, Owner | null>) => {
},
},
};
return { layers: layers as unknown as Layers, created, redis };
return {
layers: layers as unknown as Layers,
created,
redis,
sourceReads,
};
};
describe('EventsWorkerDeployer.ensure', () => {
@@ -127,8 +138,10 @@ describe('EventsWorkerDeployer.ensure', () => {
expect(b).toBe('no-owner');
});
it('refuses once the app has deployed past the hourly cap', async () => {
const { layers, redis } = makeLayers({ 'app-c': { ownerUserId: 3 } });
it('refuses once the app has deployed past the hourly cap, before reading or building any source', async () => {
const { layers, redis, sourceReads, created } = makeLayers({
'app-c': { ownerUserId: 3 },
});
const deployer = new EventsWorkerDeployer(layers);
const hour = new Date().toISOString().slice(0, 13);
await redis.set(
@@ -137,6 +150,24 @@ describe('EventsWorkerDeployer.ensure', () => {
);
expect(await deployer.ensure('app-c', SCRIPT)).toBe('throttled');
expect(sourceReads).toEqual([]);
expect(created).toEqual([]);
});
it('answers a stale script without spending the hourly cap', async () => {
const { layers, redis, sourceReads } = makeLayers({
'app-e': { ownerUserId: 5 },
});
const deployer = new EventsWorkerDeployer(layers);
const stale = eventsWorkerScript(
handlerSetHash([{ name: 'h', sourceHash: 'b'.repeat(64) }]),
'',
);
expect(await deployer.ensure('app-e', stale)).toBe('stale');
expect(sourceReads).toEqual([]);
const hour = new Date().toISOString().slice(0, 13);
expect(await redis.get(`ev:deploys:{app-e}:${hour}`)).toBeNull();
});
it('deploys normally under the cap', async () => {
+30 -30
View File
@@ -26,10 +26,14 @@ import {
INTERNAL_ADMISSION_BYPASS,
INTERNAL_DEPLOY_TARGET,
} from '../../drivers/workers/WorkerDriver.js';
import type { EventsInvokeCall } from '../../clients/events/EventsWorkerInvokerClient.js';
import type {
EventsInvokeAnswer,
EventsInvokeCall,
} from '../../clients/events/EventsWorkerInvokerClient.js';
import {
EVENTS_HANDLED_HEADER,
EVENTS_INVOKE_PATH,
answerFrom,
failedAnswer,
} from '../../clients/events/EventsWorkerInvokerClient.js';
import type { puterServices } from '../../services/index.js';
import {
@@ -37,7 +41,10 @@ import {
eventsWorkerScope,
eventsWorkerScript,
} from '../../services/events/workerRuntime.js';
import { generateEventsWorkerSource } from '../../services/events/workerSource.js';
import {
generateEventsWorkerSource,
handlerSetHash,
} from '../../services/events/workerSource.js';
import type { puterStores } from '../../stores/index.js';
import type { IConfig, LayerInstances } from '../../types.js';
import { EVENTS_WORKER_DEPLOYS_PER_HOUR } from './limits.js';
@@ -113,20 +120,24 @@ export class EventsWorkerDeployer {
script: string,
): Promise<EventsDeployOutcome> {
const { stores, drivers } = this.#layers;
const scope = eventsWorkerScope(this.#layers.config);
const handlers = await stores.eventHandler.allForApp(appUid);
if (handlers.length === 0) return 'no-handlers';
const generated = generateEventsWorkerSource(handlers);
// Names and hashes are enough to tell a stale request, so the set's
// sources are read and built only once the throttle has let it through.
const set = await stores.eventHandler.setForApp(appUid);
if (set.length === 0) return 'no-handlers';
// Two apps that published byte-identical sets share one script, which
// is sound: the source is the same, and everything per-app arrives with
// the invocation. Whoever deploys first owns it.
if (
eventsWorkerScript(
generated.setHash,
eventsWorkerScope(this.#layers.config),
) !== script
)
if (eventsWorkerScript(handlerSetHash(set), scope) !== script)
return 'stale';
if (await this.#tooManyDeploysThisHour(appUid)) return 'throttled';
const handlers = await stores.eventHandler.allForApp(appUid);
const generated = generateEventsWorkerSource(handlers);
// The set can move between the two reads.
if (eventsWorkerScript(generated.setHash, scope) !== script)
return 'stale';
if (generated.tooLarge) {
console.error(
@@ -140,8 +151,6 @@ export class EventsWorkerDeployer {
generated.broken.join(', '),
);
if (await this.#tooManyDeploysThisHour(appUid)) return 'throttled';
const owner = await this.#owner(appUid);
if (!owner) return 'no-owner';
@@ -289,11 +298,7 @@ export class LocalEventsInvokeTransport {
this.#deployer = deployer;
}
async send(call: EventsInvokeCall): Promise<{
status: number | null;
handled?: boolean;
error?: string;
}> {
async send(call: EventsInvokeCall): Promise<EventsInvokeAnswer> {
const request = (): Request =>
new Request(
`http://${call.script}.workers.puter.localhost${EVENTS_INVOKE_PATH}`,
@@ -323,10 +328,7 @@ export class LocalEventsInvokeTransport {
return { status: null, error: 'script not resident' };
return retried;
} catch (err) {
return {
status: null,
error: err instanceof Error ? err.message : String(err),
};
return failedAnswer(err);
}
}
@@ -337,7 +339,7 @@ export class LocalEventsInvokeTransport {
async #dispatch(
request: Request,
timeoutMs: number,
): Promise<{ status: number; handled: boolean } | null> {
): Promise<EventsInvokeAnswer | null> {
let timer: ReturnType<typeof setTimeout> | undefined;
const timeout = new Promise<'timeout'>((resolve) => {
timer = setTimeout(() => resolve('timeout'), timeoutMs);
@@ -348,12 +350,10 @@ export class LocalEventsInvokeTransport {
this.#services.localworkerservice.dispatchEventsWorker(request),
timeout,
]);
if (response === 'timeout') throw new Error('invocation timed out');
if (response === 'timeout')
throw new DOMException('invocation timed out', 'TimeoutError');
if (response === null) return null;
return {
status: response.status,
handled: response.headers.get(EVENTS_HANDLED_HEADER) === '1',
};
return answerFrom(response);
} finally {
clearTimeout(timer);
}
@@ -0,0 +1,80 @@
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
describe('recursive readdir with offset', () => {
let env: PuterTestEnv;
beforeAll(async () => {
env = await setupPuterTestEnv();
}, 120_000);
afterAll(async () => {
await env?.shutdown();
});
const post = async (path: string, token: string, body: unknown) =>
fetch(new URL(path, env.apiOrigin), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(body),
});
it('skips entries before applying the recursive page limit', async () => {
const { username, token } = env.users.user;
const base = `/${username}/Documents/rec-offset`;
for (const path of [base, `${base}/a`, `${base}/b`, `${base}/c`]) {
expect((await post('/fs/mkdir', token, { path })).status).toBe(200);
}
const list = async (body: Record<string, unknown>) => {
const response = await post('/fs/readdir', token, {
path: base,
recursive: true,
...body,
});
expect(response.status).toBe(200);
const page = (await response.json()) as {
items: Array<{ name: string }>;
};
return page.items.map((item) => item.name);
};
expect(await list({ limit: 3 })).toEqual(['a', 'b', 'c']);
// Non-recursive honours offset.
const flat = await post('/fs/readdir', token, {
path: base,
limit: 1,
offset: 2,
});
expect(
((await flat.json()) as Array<{ name: string }>).map((e) => e.name),
).toEqual(['c']);
expect(await list({ limit: 1, offset: 2 })).toEqual(['c']);
expect(await list({ offset: 2 })).toEqual(['c']);
expect(await list({ offset: 20 })).toEqual([]);
expect(await list({ limit: 1, offset: 1, sortOrder: 'desc' })).toEqual([
'b',
]);
const first = await post('/fs/readdir', token, {
path: base,
recursive: true,
limit: 1,
offset: 1,
});
const page = await first.json();
expect(page.items.map((item: { name: string }) => item.name)).toEqual([
'b',
]);
expect(page.cursor).toBeTypeOf('string');
expect(await list({ limit: 1, cursor: page.cursor })).toEqual(['c']);
// A cursor wins over offset, and an invalid offset is ignored, as
// recursive listings did before they honoured offset.
expect(
await list({ limit: 1, cursor: page.cursor, offset: 1 }),
).toEqual(['c']);
expect(await list({ limit: 1, offset: -1 })).toEqual(['a']);
});
});
@@ -1335,6 +1335,7 @@ export class FSController extends PuterController {
parent.path,
{
limit,
offset,
cursor:
typeof body.cursor === 'string'
? body.cursor
@@ -0,0 +1,114 @@
import { v4 as uuidv4 } from 'uuid';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { makeActor } from '../../core/actor.js';
import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
describe('/auth/check-app-acl', () => {
let env: PuterTestEnv;
beforeAll(async () => {
env = await setupPuterTestEnv();
}, 120_000);
afterAll(async () => {
await env?.shutdown();
});
const post = (path: string, body: unknown, token: string) =>
fetch(new URL(path, env.apiOrigin), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(body),
});
const makeDirAndApp = async () => {
const { username, token } = env.users.user;
const server = env.server;
const user = (await server.stores.user.getByUsername(username))!;
const path = `/${username}/Documents/cacl-${uuidv4()}`;
expect((await post('/fs/mkdir', { path }, token)).status).toBe(200);
const entry = (await server.stores.fsEntry.getEntryByPath(path))!;
const app = await (
server.stores.app.create as unknown as (
f: Record<string, unknown>,
o: { ownerUserId: number },
) => Promise<{ uid: string; id: number }>
)(
{
name: `cacl-${uuidv4()}`,
title: 'Check app acl',
index_url: `https://cacl-${uuidv4()}.test/`,
},
{ ownerUserId: user.id },
);
return { user, path, entry, app };
};
it('allows a granted file without poisoning the app permission cache', async () => {
const { token } = env.users.user;
const server = env.server;
const { user, path, entry, app } = await makeDirAndApp();
const grant = await post(
'/auth/grant-user-app',
{ app_uid: app.uid, permission: `fs:${entry.uuid}:read` },
token,
);
expect(grant.status).toBe(200);
const res = await post(
'/auth/check-app-acl',
{ subject: { path }, app: app.uid, mode: 'read' },
token,
);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ allowed: true });
const asApp = makeActor({ user, app: { uid: app.uid, id: app.id } });
const descriptor = () => server.services.acl.fsDescriptor(path);
expect(
await server.services.acl.check(asApp, descriptor(), 'read'),
).toBe(true);
});
it('denies an app with no grant', async () => {
const { path, app } = await makeDirAndApp();
const res = await post(
'/auth/check-app-acl',
{ subject: { path }, app: app.uid, mode: 'read' },
env.users.user.token,
);
expect(res.status).toBe(200);
expect(await res.json()).toEqual({ allowed: false });
});
it('refuses app and access tokens', async () => {
const { token, apiToken } = env.users.user;
const { path, entry, app } = await makeDirAndApp();
const grant = await post(
'/auth/grant-user-app',
{ app_uid: app.uid, permission: `fs:${entry.uuid}:read` },
token,
);
expect(grant.status).toBe(200);
const minted = await post(
'/auth/get-user-app-token',
{ app_uid: app.uid },
token,
);
expect(minted.status).toBe(200);
const appToken = ((await minted.json()) as { token: string }).token;
for (const credential of [appToken, apiToken]) {
const res = await post(
'/auth/check-app-acl',
{ subject: { path }, app: app.uid, mode: 'read' },
credential,
);
expect(res.status).toBe(403);
}
});
});
@@ -0,0 +1,204 @@
/*
* 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 { randomUUID } from 'node:crypto';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
let env: PuterTestEnv;
beforeAll(async () => {
env = await setupPuterTestEnv();
}, 120_000);
afterAll(async () => {
await env?.shutdown();
});
const post = (path: string, body: unknown) =>
fetch(new URL(path, env.apiOrigin), {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${env.users.user.token}`,
},
body: JSON.stringify(body),
});
const write = (path: string, content: string) =>
post('/fs/write', {
fileMetadata: { path, size: content.length, contentType: 'text/plain' },
fileContent: content,
});
const read = async (path: string) => {
const url = new URL('/read', env.apiOrigin);
url.searchParams.set('file', path);
const response = await fetch(url, {
headers: { Authorization: `Bearer ${env.users.user.token}` },
});
return response.text();
};
const makeDir = async (name: string) => {
const path = `/${env.users.user.username}/Documents/${name}-${randomUUID().slice(0, 8)}`;
expect((await post('/fs/mkdir', { path })).status).toBe(200);
return path;
};
describe('legacy /copy onto an existing file', () => {
it('replaces the file with overwrite', async () => {
const dir = await makeDir('copy-onto');
expect((await write(`${dir}/a.txt`, 'aaa')).status).toBe(200);
expect((await write(`${dir}/b.txt`, 'bbbbbb')).status).toBe(200);
const copy = await post('/copy', {
source: `${dir}/a.txt`,
destination: `${dir}/b.txt`,
overwrite: true,
});
expect(copy.status).toBe(200);
const [result] = (await copy.json()) as Array<{
copied: { name: string; path: string };
overwritten?: { name: string };
}>;
expect(result!.copied.name).toBe('b.txt');
expect(result!.overwritten?.name).toBe('b.txt');
expect(await read(`${dir}/b.txt`)).toBe('aaa');
expect(await read(`${dir}/a.txt`)).toBe('aaa');
});
it('refuses without overwrite and leaves the file alone', async () => {
const dir = await makeDir('copy-onto-refused');
await write(`${dir}/a.txt`, 'aaa');
await write(`${dir}/b.txt`, 'bbbbbb');
const copy = await post('/copy', {
source: `${dir}/a.txt`,
destination: `${dir}/b.txt`,
});
expect(copy.status).toBe(409);
expect(((await copy.json()) as { code: string }).code).toBe(
'item_with_same_name_exists',
);
expect(await read(`${dir}/b.txt`)).toBe('bbbbbb');
});
it('still copies into a directory destination under the same name', async () => {
const dir = await makeDir('copy-into');
await write(`${dir}/a.txt`, 'aaa');
expect((await post('/fs/mkdir', { path: `${dir}/sub` })).status).toBe(
200,
);
const copy = await post('/copy', {
source: `${dir}/a.txt`,
destination: `${dir}/sub`,
});
expect(copy.status).toBe(200);
expect(await read(`${dir}/sub/a.txt`)).toBe('aaa');
});
it('refuses to overwrite a file with itself', async () => {
const dir = await makeDir('copy-self');
await write(`${dir}/a.txt`, 'aaa');
for (const destination of [`${dir}/a.txt`, dir]) {
const copy = await post('/copy', {
source: `${dir}/a.txt`,
destination,
overwrite: true,
});
expect(copy.status).toBe(400);
}
expect(await read(`${dir}/a.txt`)).toBe('aaa');
});
});
describe('signed folder listing', () => {
const signedListing = async (path: string) => {
const signed = await post('/sign', {
items: [{ path, action: 'read' }],
});
expect(signed.status).toBe(200);
const readUrl = new URL(
(await signed.json()).signatures[0].read_url as string,
);
return fetch(
new URL(`${readUrl.pathname}${readUrl.search}`, env.apiOrigin),
);
};
it('lists every child of a folder larger than one query', async () => {
const dir = await makeDir('big');
const parent = (await env.server.stores.fsEntry.getEntryByPath(dir))!;
const total = 10_050;
const now = Math.floor(Date.now() / 1000);
for (let start = 0; start < total; start += 500) {
const rows: unknown[][] = [];
for (let i = start; i < Math.min(total, start + 500); i++) {
const name = `f${String(i).padStart(5, '0')}.txt`;
rows.push([
randomUUID(),
parent.userId,
parent.id,
parent.uuid,
name,
`${dir}/${name}`,
0,
now,
now,
now,
0,
]);
}
await env.server.clients.db.write(
`INSERT INTO fsentries (uuid, user_id, parent_id, parent_uid, name, path, is_dir, modified, created, accessed, size) VALUES ${rows.map(() => '(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)').join(', ')}`,
rows.flat(),
);
}
const response = await signedListing(dir);
expect(response.status).toBe(200);
expect(response.headers.get('content-type')).toMatch(
/^application\/json/,
);
const children = (await response.json()) as Array<{
fsentry_name: string;
write_url?: string;
}>;
expect(children).toHaveLength(total);
expect(children[0]!.fsentry_name).toBe('f00000.txt');
expect(children.at(-1)!.fsentry_name).toBe('f10049.txt');
expect(new Set(children.map((c) => c.fsentry_name)).size).toBe(total);
expect(children.some((c) => c.write_url)).toBe(false);
}, 120_000);
it('answers a small folder in one body', async () => {
const dir = await makeDir('small');
await write(`${dir}/one.txt`, '1');
const response = await signedListing(dir);
expect(response.status).toBe(200);
expect(response.headers.get('content-length')).not.toBeNull();
const children = (await response.json()) as Array<{
fsentry_name: string;
}>;
expect(children.map((c) => c.fsentry_name)).toEqual(['one.txt']);
});
});
@@ -153,6 +153,34 @@ describe('legacy file downloads over HTTP', () => {
},
);
it.each(routes)(
'GET %s answers a ranged read of an empty file with an empty 200',
async (route) => {
const file = await writeFile(
`empty-${route.replace(/\W/g, '')}.bin`,
Buffer.alloc(0),
);
const ranged = await requestFor(route, file, {
headers: { Range: 'bytes=0-511' },
});
expect(ranged.status).toBe(200);
expect(await ranged.text()).toBe('');
},
);
it('GET /read answers byte_count on an empty file with an empty 200', async () => {
const file = await writeFile('empty-byte-count.bin', Buffer.alloc(0));
for (const query of ['byte_count=512', 'offset=0']) {
const url = new URL(`/read?${query}`, env.apiOrigin);
url.searchParams.set('file', file.path);
const response = await fetch(url, {
headers: { Authorization: `Bearer ${env.users.user.token}` },
});
expect(response.status).toBe(200);
expect(await response.text()).toBe('');
}
});
// Storage hands over one chunk and then goes quiet, as a large object
// does while the client is still reading.
const stallReadsOf = (objectKey: string) => {
@@ -0,0 +1,160 @@
import type { Request, RequestHandler, Response } from 'express';
import { v4 as uuidv4 } from 'uuid';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import type { Actor } from '../../core/actor.js';
import { runWithContext } from '../../core/context.js';
import { PuterRouter } from '../../core/http/PuterRouter.js';
import { PuterServer } from '../../server.js';
import { setupTestServer } from '../../testUtil.js';
import type { LegacyFSController } from './LegacyFSController.js';
const PNG_2X2 =
'iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAACXBIWXMAAAPoAAAD6AG1e1JrAAAAEUlEQVR4nGP4z8DwH4QZYAwAR8oH+WdZbrcAAAAASUVORK5CYII=';
let server: PuterServer;
let handler: RequestHandler;
type Driver = {
create: (a: Record<string, unknown>) => Promise<Record<string, unknown>>;
};
beforeAll(async () => {
server = await setupTestServer({
api_base_url: 'http://api.test.local',
static_hosting_domain: 'site.test',
} as unknown as Parameters<typeof setupTestServer>[0]);
await server.services.appIcon.ensureIconsDirectory();
const controller = server.controllers
.legacyFs as unknown as LegacyFSController;
const router = new PuterRouter();
controller.registerRoutes(router);
handler = router.routes.find(
(r) =>
r.path === '/get-launch-apps' &&
String(r.method).toLowerCase() === 'get',
)!.handler as RequestHandler;
}, 60_000);
afterAll(async () => {
await server?.shutdown();
});
const makeActor = async (): Promise<{ actor: Actor; id: number }> => {
const username = `v13-${Math.random().toString(36).slice(2, 10)}`;
const u = await server.stores.user.create({
username,
uuid: uuidv4(),
password: null,
email: `${username}@test.local`,
free_storage: 100 * 1024 * 1024,
requires_email_confirmation: false,
});
const r = (await server.stores.user.getById(u.id))!;
return {
id: r.id,
actor: {
user: {
id: r.id,
uuid: r.uuid,
username: r.username,
email: r.email ?? null,
email_confirmed: true,
},
} as unknown as Actor,
};
};
const createViaDriver = async (actor: Actor, icon: string) =>
runWithContext({ actor }, () =>
(server.drivers.apps as unknown as Driver).create({
object: {
name: `v13-${Math.random().toString(36).slice(2, 10)}`,
title: 'V',
index_url: `https://v13-${Math.random().toString(36).slice(2, 10)}.test/`,
icon,
},
}),
);
const launchApps = async (actor: Actor, appUid: string, userId: number) => {
await server.clients.db.write(
'INSERT INTO `app_opens` (`app_uid`, `user_id`, `ts`) VALUES (?, ?, ?)',
[appUid, userId, Math.floor(Date.now() / 1000)],
);
let body: { recent: Array<Record<string, unknown>> } | undefined;
const res = {
json: (v: unknown) => {
body = v as typeof body;
return res;
},
status: () => res,
setHeader: () => res,
} as unknown as Response;
await handler(
{
query: { icon_size: '64' },
headers: {},
body: {},
actor,
} as unknown as Request,
res,
() => {},
);
return body!.recent.find((e) => e.uuid === appUid)!;
};
describe('/get-launch-apps recent icon, verified', () => {
it('a valid data-URL icon written through the driver is migrated to a short URL', async () => {
const { actor, id } = await makeActor();
const app = await createViaDriver(
actor,
`data:image/png;base64,${PNG_2X2}`,
);
await vi.waitFor(
async () => {
const row = await server.stores.app.getByUid(app.uid as string);
expect(String(row?.icon).slice(0, 80)).toMatch(
/^https?:\/\/.*\/app-icon\/app-/,
);
},
{ timeout: 10_000 },
);
const entry = await launchApps(actor, app.uid as string, id);
expect(String(entry.icon).length).toBeLessThan(200);
expect(entry.iconCdnUrl).toMatch(/-64\.png$/);
}, 30_000);
it('returns an icon endpoint when migration cannot decode the stored icon', async () => {
const { actor, id } = await makeActor();
// PNG signature passes the write-path sniff; sharp rejects the rest.
const corrupt = Buffer.concat([
Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]),
Buffer.alloc(1024 * 1024, 0x41),
]).toString('base64');
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
let app: Record<string, unknown>;
try {
app = await createViaDriver(
actor,
`data:image/png;base64,${corrupt}`,
);
// Give the background pipeline time to try and fail.
await vi.waitFor(
() =>
expect(
warn.mock.calls.some(
([m]) => m === '[app-icon] icon processing failed',
),
).toBe(true),
{ timeout: 10_000 },
);
} finally {
warn.mockRestore();
}
const row = await server.stores.app.getByUid(app!.uid as string);
expect(String(row?.icon).startsWith('data:image/png')).toBe(true);
const entry = await launchApps(actor, app!.uid as string, id);
expect(entry.icon).toBe(
`http://api.test.local/app-icon/${app!.uid}/64`,
);
}, 30_000);
});
@@ -5293,4 +5293,39 @@ describe('LegacyFSController GET /get-launch-apps', () => {
expect(returned).toEqual(recentUids);
});
it('skips the recent opens for recent=0 and keeps recommended', async () => {
const { actor, userId } = await makeUser();
const app = await makeHostedApp(userId, 'unregistered-skip');
await recordOpen(userId, app.uid);
const call = async (query: Record<string, unknown>) => {
const { res, captured } = makeRes();
await withActor(actor, async () => {
await getLaunchAppsHandler()(
makeReq({ actor, query }),
res,
() => {
throw new Error('handler called next() unexpectedly');
},
);
});
return captured.body as {
recommended: unknown[];
recent: unknown[];
};
};
const full = await call({});
expect(full.recent.length).toBeGreaterThan(0);
const opens = vi.spyOn(server.stores.app, 'getRecentAppOpens');
try {
const skipped = await call({ recent: '0' });
expect(skipped.recent).toEqual([]);
expect(skipped.recommended).toEqual(full.recommended);
expect(opens).not.toHaveBeenCalled();
} finally {
opens.mockRestore();
}
});
});
@@ -26,6 +26,7 @@ import {
assertResolvedActor,
isAccessTokenActor,
isAppActor,
isPlainUserActor,
makeActor,
} from '../../core/actor.js';
import { Context } from '../../core/context.js';
@@ -95,6 +96,7 @@ import {
assertAccess,
assertCanCreate,
assertCanMoveInto,
contextBoundStream,
emitGuiItemEvent,
expandClientPath,
findOverwriteTarget,
@@ -102,6 +104,7 @@ import {
getString,
type GuiItemEventName,
loadLegacyAssociatedApps,
rangeForEntry,
replacedByMove,
resolveReadRange,
resolveV1Selector,
@@ -381,7 +384,9 @@ export class LegacyFSController extends PuterController {
let recent: unknown[] = [];
const userId = req.actor?.user?.id;
if (userId) {
// `recent=0`: a caller that only shows `recommended`.
const wantsRecent = req.query.recent !== '0';
if (userId && wantsRecent) {
const recentUids = await this.stores.app.getRecentAppOpens(
userId,
{ limit: 10 },
@@ -418,9 +423,9 @@ export class LegacyFSController extends PuterController {
return {
...toAppSummary(app, {
config: this.config,
apiBaseUrl: this.config.api_base_url,
iconSize,
}),
icon: app.icon ?? null,
index_url: backingGone ? null : app.index_url,
...(backingGone
? { privateAccess: buildHostedBackingDenial() }
@@ -801,10 +806,23 @@ export class LegacyFSController extends PuterController {
this.stores.fsEntry,
body.source,
);
const destinationParent = await resolveV1Selector(
const destination = await resolveV1Selector(
this.stores.fsEntry,
body.destination,
);
// A file destination names the copy itself, replaced only with
// `overwrite`.
let newName = getString(body, 'new_name');
let destinationParent = destination;
if (!destination.isDir && !newName && destination.parentUid) {
const parent = await this.stores.fsEntry.getEntryByUuid(
destination.parentUid,
);
if (parent) {
destinationParent = parent;
newName = destination.name;
}
}
await assertAccess(
this.services.acl,
@@ -829,14 +847,14 @@ export class LegacyFSController extends PuterController {
? await findOverwriteTarget(
this.stores.fsEntry,
destinationParent,
getString(body, 'new_name') ?? source.name,
newName ?? source.name,
)
: null;
const copy = await this.services.fs.copy(userId, {
source,
destinationParent,
newName: getString(body, 'new_name'),
newName,
overwrite: overwriteRequested,
dedupeName: getBoolean(body, 'dedupe_name', 'change_name') ?? false,
});
@@ -1289,7 +1307,10 @@ export class LegacyFSController extends PuterController {
});
}
const range = resolveReadRange(req.headers.range, query);
const range = rangeForEntry(
entry,
resolveReadRange(req.headers.range, query),
);
const download = await this.services.fs.readContent(entry, {
range,
});
@@ -1790,19 +1811,50 @@ export class LegacyFSController extends PuterController {
// owner, so the layout it reveals is theirs to reveal, and the child
// signatures expire; the paths are the only part that outlives them.
if (entry.isDir) {
const children = await this.services.fs.listDirectory(entry.uuid);
const signedChildren = children.map((child) => {
const signChild = (child: FSEntry) => {
const { write_url: _, ...rest } = signEntry(child, signingCfg, {
ttlSeconds: NON_OWNER_SIGNATURE_TTL_SECONDS,
});
return { ...rest, path: maskEntryPath(child) };
});
res.json(signedChildren);
};
const pages = this.services.fs.listDirectoryPages(entry.uuid);
const first = await pages.next();
const firstPage = first.done ? [] : first.value;
const second = await pages.next();
if (second.done) {
res.json(firstPage.map(signChild));
return;
}
// Past one page the listing streams, so a large folder isn't held
// in memory whole. A failure partway cuts the response off.
const secondPage = second.value;
const serialize = (child: FSEntry) =>
JSON.stringify(signChild(child));
async function* allPages() {
yield firstPage;
yield secondPage;
yield* pages;
}
async function* body() {
let separator = '[';
for await (const page of allPages()) {
if (page.length === 0) continue;
yield separator + page.map(serialize).join(',');
separator = ',';
}
yield separator === '[' ? '[]' : ']';
}
res.type('application/json');
await pipeToResponse(contextBoundStream(body()), res);
return;
}
// File: stream bytes with Range support.
const range = resolveReadRange(req.headers.range, query);
const range = rangeForEntry(
entry,
resolveReadRange(req.headers.range, query),
);
const download = await this.services.fs.readContent(entry, {
range,
});
@@ -1992,6 +2044,11 @@ export class LegacyFSController extends PuterController {
*/
checkAppAcl = async (req: Request, res: Response): Promise<void> => {
const actor = this.#requireActor(req);
// Sessions only: for an app, this would be a window onto its neighbours' grants.
if (!isPlainUserActor(actor))
throw new HttpError(403, 'actor must be a user', {
legacyCode: 'forbidden',
});
const body = asRecord(req.body);
const subjectRef = body.subject;
@@ -2022,7 +2079,7 @@ export class LegacyFSController extends PuterController {
legacyCode: 'subject_does_not_exist',
});
let app: { uid: string } | null = null;
let app: { uid: string; id: number } | null = null;
if (typeof appRef === 'string') {
app =
(await this.stores.app.getByUid(appRef)) ??
@@ -2036,7 +2093,7 @@ export class LegacyFSController extends PuterController {
// Build an actor-under-user shape for the check.
const actorForApp = makeActor({
user: actor.user,
app: { uid: (app as { uid: string }).uid },
app: { uid: app.uid, id: app.id },
});
const allowed = await acl.check(actorForApp, descriptor, mode);
res.json({ allowed });
@@ -2084,10 +2141,12 @@ export class LegacyFSController extends PuterController {
// by default, so opt in by naming the downloader as who pays.
req.egressActor = actor;
const range =
const range = rangeForEntry(
entry,
typeof req.headers.range === 'string'
? req.headers.range
: undefined;
: undefined,
);
const download = await this.services.fs.readContent(entry, {
range,
});
@@ -17,15 +17,19 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { Writable } from 'node:stream';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { Context, runWithContext } from '../../core/context.js';
import type { PuterServer } from '../../server.js';
import { createTestUser, setupTestServer } from '../../testUtil.js';
import {
asRecord,
contextBoundStream,
fsEntryMimeType,
getBoolean,
getString,
loadLegacyAssociatedApps,
rangeForEntry,
resolveReadRange,
signEntry,
signEntryThumbnail,
@@ -705,3 +709,50 @@ describe('resolveReadRange', () => {
).toThrow('Invalid byte_count');
});
});
describe('rangeForEntry', () => {
it('drops the range for an empty file only', () => {
const entry = (size: number) => ({ size }) as FSEntry;
expect(rangeForEntry(entry(0), 'bytes=0-511')).toBeUndefined();
expect(rangeForEntry(entry(10), 'bytes=0-4')).toBe('bytes=0-4');
});
});
describe('contextBoundStream', () => {
it("reads every chunk in the caller's context", async () => {
// A sink slower than the source that completes writes from outside
// any request, so later chunks are asked for from its drain.
const pending: Array<() => void> = [];
const flush = runWithContext({}, () =>
setInterval(() => pending.splice(0).forEach((done) => done()), 5),
);
const sink = new Writable({
highWaterMark: 1,
write(_chunk, _encoding, done) {
pending.push(done);
},
});
const seen: unknown[] = [];
try {
await runWithContext({ actor: 'caller' as never }, async () => {
async function* chunks() {
for (let i = 0; i < 40; i++) {
await new Promise((resolve) => setTimeout(resolve, 1));
seen.push(Context.get('actor'));
yield 'x';
}
}
await new Promise((resolve, reject) => {
contextBoundStream(chunks())
.pipe(sink)
.on('finish', resolve)
.on('error', reject);
});
});
} finally {
clearInterval(flush);
}
expect(seen).toHaveLength(40);
expect(seen.every((actor) => actor === 'caller')).toBe(true);
});
});
@@ -17,7 +17,9 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { AsyncResource } from 'node:async_hooks';
import { posix as pathPosix } from 'node:path';
import { Readable } from 'node:stream';
import { contentType as contentTypeFromMime } from 'mime-types';
import type { FSEntry } from '../../stores/fs/FSEntry.js';
import type { FSEntryStore } from '../../stores/fs/FSEntryStore.js';
@@ -150,6 +152,29 @@ export function resolveReadRange(
return undefined;
}
/**
* A response body read from `chunks` for `pipeToResponse`. Each chunk is pulled
* in the caller's async context, which share masking reads, not in whichever
* socket event asked for more.
*/
export function contextBoundStream(
chunks: AsyncGenerator<string, void, undefined>,
): Readable {
const next = AsyncResource.bind(() => chunks.next());
const stop = AsyncResource.bind(() => chunks.return(undefined));
return Readable.from({
[Symbol.asyncIterator]: () => ({ next, return: stop }),
});
}
/** No range is satisfiable on an empty file, so it reads whole: empty, 200. */
export function rangeForEntry(
entry: FSEntry,
range: string | undefined,
): string | undefined {
return entry.size === 0 ? undefined : range;
}
// Accepts either `{ path }` or `{ uid }` or `{ id }` or `{ parent, name }`
// from a legacy body field. Returns a resolved entry (throwing 404 if not
// found or 400 if no usable ref is present).
@@ -17,6 +17,9 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { mkdir, mkdtemp, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import path from 'node:path';
import type { Request, RequestHandler, Response } from 'express';
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest';
import { v4 as uuidv4 } from 'uuid';
@@ -466,3 +469,63 @@ describe('HomepageController GET /show/*splat', () => {
expect(html).toContain('/alice/Documents');
});
});
// ── /sw.js ──────────────────────────────────────────────────────────
describe('HomepageController GET /sw.js', () => {
const headersOf = (res: Response): Record<string, string> =>
Object.fromEntries(
(res.set as ReturnType<typeof vi.fn>).mock.calls.map(
([key, value]) => [String(key).toLowerCase(), String(value)],
),
);
it('serves the worker with root scope, as JavaScript, revalidated each time', async () => {
const root = await mkdtemp(path.join(tmpdir(), 'puter-sw-route-'));
await mkdir(path.join(root, 'dist'));
await writeFile(path.join(root, 'dist', 'sw.js'), '// worker\n');
const config = server.services.homepage.config as {
gui_assets_root?: string;
env?: string;
};
const prev = { root: config.gui_assets_root, env: config.env };
config.gui_assets_root = root;
config.env = 'prod';
const { res, captured } = makeRes();
try {
await callRoute('get', '/sw.js', makeReq({ path: '/sw.js' }), res);
} finally {
config.gui_assets_root = prev.root;
config.env = prev.env;
}
expect(captured.statusCode).toBe(200);
expect(captured.body).toBe('// worker\n');
expect(headersOf(res)).toMatchObject({
'content-type': 'text/javascript; charset=utf-8',
'cache-control': 'no-cache',
'service-worker-allowed': '/',
});
});
it('is a 404 when there is no worker to serve', async () => {
const config = server.services.homepage.config as {
gui_assets_root?: string;
};
const prev = config.gui_assets_root;
config.gui_assets_root = path.join(tmpdir(), 'no-such-gui-root');
const { res, captured } = makeRes();
try {
await callRoute('get', '/sw.js', makeReq({ path: '/sw.js' }), res);
} finally {
config.gui_assets_root = prev;
}
expect(captured.statusCode).toBe(404);
});
it('is registered for the root subdomain only', () => {
const route = router.routes.find(
(r) => r.method === 'get' && r.path === '/sw.js',
);
expect(route?.options.subdomain).toBe('');
});
});
@@ -198,6 +198,20 @@ export class HomepageController extends PuterController {
return sendShell(req, res, {}, launch);
});
// -- /sw.js - the service worker, scoped to the whole origin --
router.get('/sw.js', { subdomain: '' }, async (_req, res) => {
const source = await homepage.serviceWorkerSource();
if (source === null) {
res.status(404).send('Not found');
return;
}
res.set('Content-Type', 'text/javascript; charset=utf-8');
res.set('Cache-Control', 'no-cache');
res.set('Service-Worker-Allowed', '/');
res.send(source);
});
// -- Fallback static mount -----------------------------------
// Serves lingering GUI assets (images, fonts, lib files, etc.)
// out of <gui_assets_root>/src. Falls through to the 404 handler
@@ -198,7 +198,7 @@ describe('HostingController /delete-site', () => {
);
expect(emitSpy).toHaveBeenCalledWith(
'subdomain.delete',
{ subdomain: site.subdomain, uid: String(site.uuid) },
{ subdomain: site.subdomain, uid: String(site.uuid), userId },
{},
);
} finally {
@@ -42,6 +42,7 @@ import {
EVENTS_KV_HANDLES_PER_USER,
KV_SHARE_BATCH_MAX,
KV_SHARE_GRANT_LIMITS,
KV_SHARE_LOOKUP_LIMIT,
} from '../events/limits.js';
const BOOT_TIMEOUT_MS = 120_000;
@@ -67,6 +68,7 @@ const resetBudgets = async (env: PuterTestEnv) => {
const user = await env.server.stores.user.getByUsername(name);
for (const scope of [
EVENTS_KV_HANDLE_LIMIT.scope,
KV_SHARE_LOOKUP_LIMIT.scope,
...KV_SHARE_GRANT_LIMITS.map((window) => window.scope),
])
await env.server.clients.redis.del(`rate:${scope}:${user!.id}`);
@@ -123,6 +125,7 @@ describe('/kv/shares', () => {
'handle',
'holder',
'label',
'notify',
'prefix',
].sort(),
);
@@ -131,6 +134,42 @@ describe('/kv/shares', () => {
appUid: null,
holder: env.users.other.username,
label: 'Groceries',
notify: false,
});
});
it('takes notify, keeps it on a re-share that leaves it out, and shows it to both sides', async () => {
const prefix = `notify-${uuidv4().slice(0, 8)}:`;
const on = await shareWithOther({ prefix, notify: true });
expect(on).toMatchObject({ prefix, notify: true });
const kept = await shareWithOther({ prefix, access: 'write' });
expect(kept).toMatchObject({ handle: on.handle, notify: true });
const listed = async (path: string, token: string) =>
(
(await (await req('GET', path, token)).json()) as {
items: Array<Record<string, unknown>>;
}
).items.find((row) => row.handle === on.handle);
expect(
await listed('/kv/shares?limit=200', env.users.user.token),
).toMatchObject({ notify: true });
expect(
await listed('/kv/shares/with-me?limit=200', env.users.other.token),
).toMatchObject({ notify: true });
const off = await shareWithOther({ prefix, notify: false });
expect(off).toMatchObject({ handle: on.handle, notify: false });
const refused = await req('POST', '/kv/shares', env.users.user.token, {
prefix,
recipient: env.users.other.username,
notify: 'yes',
});
expect(refused.status).toBe(400);
await expect(refused.json()).resolves.toMatchObject({
code: 'kv_share_notify_invalid',
});
});
@@ -169,6 +208,7 @@ describe('/kv/shares', () => {
appUid: null,
holder: env.users.other.username,
label: 'Trip',
notify: false,
createdAt: shared.createdAt,
revokedAt: null,
revokedBy: null,
@@ -190,6 +230,7 @@ describe('/kv/shares', () => {
access: 'write',
appUid: null,
label: 'Trip',
notify: false,
createdAt: shared.createdAt,
});
expect(withMeText).not.toContain(env.users.user.username);
@@ -622,6 +663,162 @@ describe('/kv/shares', () => {
}
});
});
describe('sharing again', () => {
let ownerId: number;
let third: string;
beforeAll(async () => {
third = `kv-again-${uuidv4().slice(0, 8)}`;
await createTestUser(env.server, {
username: third,
password: 'pw-test-1234',
});
ownerId = (await env.server.stores.user.getByUsername(
env.users.user.username,
))!.id!;
});
const shareAt = (prefix: string, body: Record<string, unknown>) =>
req('POST', '/kv/shares', env.users.user.token, {
prefix,
...body,
});
/** Leaves `left` units in one of the owner's windows. */
const drain = (
window: { scope: string; limit: number; window: number },
left = 0,
) =>
claimRateLimit(
`${window.scope}:${ownerId}`,
window.limit,
window.window,
window.limit - left,
);
it('spends neither the mint nor the grant budget when every share stays as it is', async () => {
const prefix = `again-${uuidv4().slice(0, 8)}:`;
const recipients = [env.users.other.username, third];
const made = (await (
await shareAt(prefix, { recipients, label: 'Room' })
).json()) as { items: Array<{ handle: string }> };
await drain(EVENTS_KV_HANDLE_LIMIT);
for (const window of KV_SHARE_GRANT_LIMITS) await drain(window);
try {
const again = await shareAt(prefix, {
recipients: [...recipients, third],
});
expect(again.status).toBe(200);
const { items } = (await again.json()) as {
items: Array<{ handle: string; label: string }>;
};
expect(items.map((item) => item.handle)).toEqual([
made.items[0].handle,
made.items[1].handle,
made.items[1].handle,
]);
expect(items[0].label).toBe('Room');
const single = await shareAt(prefix, {
recipient: env.users.other.username,
access: 'read',
});
expect(single.status).toBe(200);
} finally {
await resetBudgets(env);
}
});
it('spends the mint budget on a change, and grant units only on recipients without the share', async () => {
const prefix = `again-${uuidv4().slice(0, 8)}:`;
const other = env.users.other.username;
expect((await shareAt(prefix, { recipient: other })).status).toBe(
200,
);
for (const window of KV_SHARE_GRANT_LIMITS) await drain(window);
try {
// A level change on a standing share takes no grant unit.
const raised = await shareAt(prefix, {
recipients: [other],
access: 'write',
});
expect(raised.status).toBe(200);
// Naming an unknown user next to it costs what a new share
// would, so the window refuses both.
for (const extra of ['nobody-by-this-name', third]) {
const refused = await shareAt(prefix, {
recipients: [other, extra],
access: 'write',
});
expect(refused.status).toBe(429);
}
} finally {
await resetBudgets(env);
}
await drain(EVENTS_KV_HANDLE_LIMIT);
try {
const lowered = await shareAt(prefix, {
recipient: other,
access: 'read',
});
expect(lowered.status).toBe(429);
await expect(lowered.json()).resolves.toMatchObject({
code: 'too_many_requests',
});
} finally {
await resetBudgets(env);
}
});
it('counts turning notify on or off as a change', async () => {
const prefix = `again-${uuidv4().slice(0, 8)}:`;
const other = env.users.other.username;
expect((await shareAt(prefix, { recipient: other })).status).toBe(
200,
);
await drain(EVENTS_KV_HANDLE_LIMIT);
try {
expect(
(await shareAt(prefix, { recipient: other })).status,
).toBe(200);
expect(
(
await shareAt(prefix, {
recipient: other,
notify: true,
})
).status,
).toBe(429);
} finally {
await resetBudgets(env);
}
});
it('spends the lookup budget on every recipient named', async () => {
const prefix = `again-${uuidv4().slice(0, 8)}:`;
const other = env.users.other.username;
expect((await shareAt(prefix, { recipient: other })).status).toBe(
200,
);
await resetBudgets(env);
await drain(KV_SHARE_LOOKUP_LIMIT, 1);
try {
expect(
(await shareAt(prefix, { recipient: other })).status,
).toBe(200);
const refused = await shareAt(prefix, { recipient: other });
expect(refused.status).toBe(429);
await expect(refused.json()).resolves.toMatchObject({
code: 'too_many_requests',
});
} finally {
await resetBudgets(env);
}
});
});
});
describe('/events/kv-handles beside /kv/shares', () => {
@@ -69,6 +69,7 @@ const shareBody = (share: KvShareView) => ({
appUid: publicAppUid(share.appUid),
holder: share.holderUsername,
label: share.label,
notify: share.notify,
createdAt: share.createdAt,
});
@@ -128,8 +129,8 @@ export class KvShareController extends PuterController {
/**
* POST /kv/shares — share a region with another account, `read` unless the
* body asks otherwise. Sharing the same region with the same account again
* changes its level or label in place. `recipients` shares it with several
* at once and reports each one on its own item.
* changes its level, label or `notify` flag in place. `recipients` shares
* it with several at once and reports each one on its own item.
*/
@Post('/shares', {
subdomain: 'api',
@@ -145,6 +146,7 @@ export class KvShareController extends PuterController {
access: body.access,
appUid: body.appUid,
label: body.label,
notify: body.notify,
};
const recipients = batchRecipients(body);
if (recipients) {
@@ -206,6 +208,7 @@ export class KvShareController extends PuterController {
appUid: publicAppUid(row.appUid),
holder: row.holderUsername,
label: row.label,
notify: row.notify,
createdAt: row.createdAt,
revokedAt: row.revokedAt,
revokedBy: row.revokedBy,
@@ -234,6 +237,7 @@ export class KvShareController extends PuterController {
access: row.access,
appUid: publicAppUid(row.appUid),
label: row.label,
notify: row.notify,
createdAt: row.createdAt,
})),
...(page.cursor ? { cursor: page.cursor } : {}),
@@ -21,9 +21,9 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { setupPuterTestEnv, type PuterTestEnv } from '../../testUtil.js';
/**
* Route-level coverage for the notification endpoint. Dismissing is the only
* mark the desktop performs over HTTP, so this suite is what catches the route
* going missing or a gate turning a legitimate dismiss away.
* Route-level coverage for the notification endpoints the desktop calls over
* HTTP, so this suite is what catches a route going missing or a gate turning
* a legitimate dismiss or removal away.
*/
describe('notification endpoints over HTTP', () => {
let env: PuterTestEnv;
@@ -85,4 +85,34 @@ describe('notification endpoints over HTTP', () => {
});
expect(after?.acknowledged).toBeFalsy();
});
it('removes a notification', async () => {
const owner = env.users.user;
const { userId, uid } = await seedNotification(owner.username);
const res = await post('/notif/delete', owner.token, { uid });
expect(res.status).toBe(200);
await expect(res.json()).resolves.toEqual({});
const after = await env.server.stores.notification.getByUid(uid, {
userId,
});
expect(after).toBeNull();
});
it('refuses an anonymous removal', async () => {
const { userId, uid } = await seedNotification(env.users.other.username);
const res = await fetch(new URL('/notif/delete', env.apiOrigin), {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ uid }),
});
expect(res.status).toBeGreaterThanOrEqual(400);
const after = await env.server.stores.notification.getByUid(uid, {
userId,
});
expect(after?.uid).toBe(uid);
});
});
@@ -183,3 +183,74 @@ describe('NotificationController.markAck', () => {
expect(after?.acknowledged).toBeFalsy();
});
});
// ── /notif/delete ───────────────────────────────────────────────────
describe('NotificationController.remove', () => {
it('deletes the underlying notification row', async () => {
const { actor, userId } = await makeUser();
const created = await server.stores.notification.create({
userId,
value: { title: 't' },
});
const { res, captured } = makeRes();
await controller.remove(
makeReq({ body: { uid: created.uid }, actor }),
res,
);
expect(captured.body).toEqual({});
const after = await server.stores.notification.getByUid(
created.uid as string,
{ userId },
);
expect(after).toBeNull();
});
it('succeeds for a uid that names no row', async () => {
const { actor } = await makeUser();
const { res, captured } = makeRes();
await controller.remove(
makeReq({ body: { uid: 'no-such-uid' }, actor }),
res,
);
expect(captured.body).toEqual({});
});
it('rejects a missing uid with 400', async () => {
const { actor } = await makeUser();
const { res } = makeRes();
await expect(
controller.remove(makeReq({ body: {}, actor }), res),
).rejects.toMatchObject({ statusCode: 400 });
});
it('throws 401 when there is no actor on the request', async () => {
const { res } = makeRes();
await expect(
controller.remove(makeReq({ body: { uid: 'whatever' } }), res),
).rejects.toMatchObject({ statusCode: 401 });
});
it("does not delete another user's notification", async () => {
const a = await makeUser();
const b = await makeUser();
const created = await server.stores.notification.create({
userId: a.userId,
value: {},
});
const { res } = makeRes();
await controller.remove(
makeReq({ body: { uid: created.uid }, actor: b.actor }),
res,
);
const after = await server.stores.notification.getByUid(
created.uid as string,
{ userId: a.userId },
);
expect(after?.uid).toBe(created.uid);
});
});
@@ -20,16 +20,44 @@
import type { Request, Response } from 'express';
import { Controller, Post } from '../../core/http/decorators.js';
import { HttpError } from '../../core/http/HttpError.js';
import type { NotificationService } from '../../services/notification/NotificationService.js';
import { PuterController } from '../types.js';
/** Both routes fire per interaction; the ceiling catches a loop, not a user. */
const MARK_ROUTE_OPTIONS = {
subdomain: 'api',
requireUserActor: true,
allowFullAccessToken: true,
rateLimit: {
scope: 'notification-mark',
limit: 300,
window: 60_000,
key: 'user',
},
} as const;
/** The uid and owner a mark names, or the 400/401 the request earns. */
const markTarget = (req: Request): { uid: string; userId: number } => {
const uid = req.body?.uid;
if (typeof uid !== 'string' || uid.length === 0) {
throw new HttpError(400, '`uid` must be a non-empty string', {
legacyCode: 'bad_request',
});
}
const userId = req.actor?.user?.id;
if (!userId)
throw new HttpError(401, 'Unauthorized', {
legacyCode: 'unauthorized',
});
return { uid, userId };
};
/**
* GUI-facing notification endpoint. Supplements the `puter-notifications`
* driver (which handles CRUD via `/drivers/call`) with the one mutation route
* the puter desktop client calls directly.
* GUI-facing notification endpoints. Supplement the `puter-notifications`
* driver (which handles reads via `/drivers/call`) with the mutations the puter
* desktop client calls directly.
*
* It emits `outer.gui.notif.ack` via the NotificationService so other open tabs
* for the same user see the state change immediately.
* Each goes through the NotificationService, which tells the user's other open
* tabs (`notif.ack`, `notif.removed`) so they update immediately.
*/
@Controller('/notif')
export class NotificationController extends PuterController {
@@ -37,50 +65,21 @@ export class NotificationController extends PuterController {
* POST /notif/mark-ack — user dismissed a notification. Sets `acknowledged`
* timestamp; pushes ack event to sockets.
*/
@Post('/mark-ack', {
subdomain: 'api',
requireUserActor: true,
allowFullAccessToken: true,
// Fires per notification interaction, so the ceiling stays
// generous — it is here to catch a loop, not to pace a user.
rateLimit: {
scope: 'notification-mark',
limit: 300,
window: 60_000,
key: 'user',
},
})
@Post('/mark-ack', MARK_ROUTE_OPTIONS)
async markAck(req: Request, res: Response): Promise<void> {
const uid = req.body?.uid;
if (typeof uid !== 'string' || uid.length === 0) {
throw new HttpError(400, '`uid` must be a non-empty string', {
legacyCode: 'bad_request',
});
}
const userId = req.actor?.user?.id;
if (!userId)
throw new HttpError(401, 'Unauthorized', {
legacyCode: 'unauthorized',
});
const { uid, userId } = markTarget(req);
await this.services.notification.markAcknowledged(uid, userId);
res.json({});
}
const notifService = this.services.notification as unknown as
| NotificationService
| undefined;
if (notifService?.markAcknowledged) {
await notifService.markAcknowledged(uid, userId);
} else {
// Fallback: direct store call if service isn't wired
await (
this.stores as Record<string, unknown> as {
notification: {
markAcknowledged: (
uid: string,
userId: number,
) => Promise<boolean>;
};
}
).notification.markAcknowledged(uid, userId);
}
/**
* POST /notif/delete — user removed a notification from their history. A
* uid naming none of their rows succeeds too: it is gone either way.
*/
@Post('/delete', MARK_ROUTE_OPTIONS)
async remove(req: Request, res: Response): Promise<void> {
const { uid, userId } = markTarget(req);
await this.services.notification.remove(uid, userId);
res.json({});
}
}
@@ -511,6 +511,31 @@ describe('PuterAIController.openaiChatCompletions', () => {
const completeArgs = completeSpy.mock.calls[0]![0];
expect(completeArgs.provider).toBe('openai-completion');
expect(completeArgs.fallback).toBeUndefined();
});
it('forwards fallback: false so the driver stops at the first route', async () => {
const completeSpy = stubChatComplete({
message: { role: 'assistant', content: 'hi there' },
finish_reason: 'stop',
usage: { prompt_tokens: 4, completion_tokens: 2 },
});
const { res } = makeRes();
await controller.openaiChatCompletions(
makeReq({
body: {
model: 'gpt-test',
provider: 'openai-completion',
fallback: false,
messages: [{ role: 'user', content: 'hi' }],
},
actor: makeUserActor(),
}),
res,
);
expect(completeSpy.mock.calls[0]![0].fallback).toBe(false);
});
it('still pins OpenAI when the caller names no model, so the default model does not move', async () => {
@@ -448,6 +448,7 @@ export class PuterAIController extends PuterController {
}
: {}),
...openaiCompatProvider(body),
...(body.fallback === false ? { fallback: false } : {}),
};
const result = await this.#complete(res, completeArgs);
@@ -545,8 +546,7 @@ export class PuterAIController extends PuterController {
}
} else if (ev.type === 'usage') {
usageDetails = ev.usageDetails as
| UsageDetails
| undefined;
UsageDetails | undefined;
finishReason = ev.finish_reason as string | undefined;
} else if (ev.type === 'error') {
res.write(
@@ -649,6 +649,7 @@ export class PuterAIController extends PuterController {
: {}),
...(finiteMaxTokens(body.max_tokens) ?? {}),
...openaiCompatProvider(body),
...(body.fallback === false ? { fallback: false } : {}),
};
const completionId = `cmpl-${randomId()}`;
@@ -694,8 +695,7 @@ export class PuterAIController extends PuterController {
sendChunk(ev.text);
} else if (ev.type === 'usage') {
usageDetails = ev.usageDetails as
| UsageDetails
| undefined;
UsageDetails | undefined;
finishReason = ev.finish_reason as string | undefined;
} else if (ev.type === 'error') {
res.write(
@@ -749,8 +749,7 @@ export class PuterAIController extends PuterController {
text: extractTextContent(
(
messageResult.message as
| Record<string, unknown>
| undefined
Record<string, unknown> | undefined
)?.content,
),
index: 0,
@@ -869,6 +868,7 @@ export class PuterAIController extends PuterController {
}
: {}),
provider: providerName,
...(body.fallback === false ? { fallback: false } : {}),
};
const responseId = generateId('resp');
@@ -324,6 +324,18 @@ describe('parseAnthropicRequest', () => {
expect(args.provider).toBe('claude');
expect(args.normalize).toBe(false);
expect(args.streamToolInput).toBe(true);
expect(args.fallback).toBeUndefined();
});
it('forwards fallback: false and nothing else for fallback', () => {
expect(
parseAnthropicRequest({ messages: [], fallback: false }, headers)
.fallback,
).toBe(false);
expect(
parseAnthropicRequest({ messages: [], fallback: 'no' }, headers)
.fallback,
).toBeUndefined();
});
it('maps thinking, tool_choice, stop_sequences, top_p/top_k', () => {
@@ -431,6 +431,7 @@ export const parseAnthropicRequest = (
: {}),
...(betas.length ? { anthropicBetas: betas } : {}),
provider: toStringOrEmpty(body.provider) || 'claude',
...(body.fallback === false ? { fallback: false } : {}),
};
// Silently ignored: metadata, service_tier, inference_geo, container,
// fallbacks, diagnostics.
@@ -151,6 +151,12 @@ describe('StaticAssetsController client_libs_root', () => {
const sentPutility = await callGetHandler(router, '/putility/v1');
expect(sentPutility).toEqual({ filename: 'putility.js/v1.js', root });
for (const route of ['/push-sw.js', '/v2/push-sw.js']) {
expect(findRoute(router, 'get', route)?.options.subdomain).toBe('js');
const sentWorker = await callGetHandler(router, route);
expect(sentWorker).toEqual({ filename: 'puter.js/push-sw.js', root });
}
});
it('does not register the client_libs routes when the root is unset', () => {
@@ -186,6 +192,11 @@ describe('StaticAssetsController puterjs_root', () => {
expect(v1.root).toBe(root);
const bareV1 = await callGetHandler(router, '/v1');
expect(bareV1.filename).toBe('puter.js');
for (const route of ['/push-sw.js', '/v2/push-sw.js']) {
const worker = await callGetHandler(router, route);
expect(worker).toEqual({ filename: 'push-sw.js', root });
}
});
it('serves puter.dev.js when present (dev webpack build)', async () => {
@@ -26,9 +26,9 @@ import type { PuterRouter } from '../../core/http/PuterRouter';
/**
* Static asset routes.
*
* /puter.js/v1, /puter.js/v2 → any subdomain /v1, /v2, /putility/v1 → js
* subdomain /sdk/* → root subdomain — puter-js bundle /dist/_, /src/_,
* /assets/* → root subdomain
* /puter.js/v1, /puter.js/v2 → any subdomain /v1, /v2, /push-sw.js,
* /putility/v1 → js subdomain /sdk/* → root subdomain — puter-js bundle
* /dist/_, /src/_, /assets/* → root subdomain
*
* Each block depends on its config root (`client_libs_root`, `gui_assets_root`,
* `puterjs_root`). When unset, that block is skipped — deployments that don't
@@ -55,6 +55,12 @@ export class StaticAssetsController extends PuterController {
router.get('/putility/v1', { subdomain: 'js' }, (_req, res) => {
res.sendFile('putility.js/v1.js', { root });
});
// `/v2/push-sw.js` is the path older docs gave.
for (const route of ['/push-sw.js', '/v2/push-sw.js']) {
router.get(route, { subdomain: 'js' }, (_req, res) => {
res.sendFile('puter.js/push-sw.js', { root });
});
}
}
// puter-js SDK mount. GUI loads it at `/sdk/puter.dev.js`; the
@@ -100,6 +106,11 @@ export class StaticAssetsController extends PuterController {
router.get('/v2', { subdomain: 'js' }, (_req, res) => {
res.sendFile(puterJsFile, { root: puterjsRoot });
});
for (const route of ['/push-sw.js', '/v2/push-sw.js']) {
router.get(route, { subdomain: 'js' }, (_req, res) => {
res.sendFile('push-sw.js', { root: puterjsRoot });
});
}
}
}
@@ -0,0 +1,146 @@
/*
* 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 { randomUUID } from 'node:crypto';
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
import { runWithContext } from '../../core/context.js';
import {
setupPuterTestEnv,
TEST_OTHER_USER_CREDENTIALS,
TEST_USER_CREDENTIALS,
type PuterTestEnv,
} from '../../testUtil.js';
/** PROPFIND has no paging, so a folder must list whole however large. */
describe('PROPFIND on a folder larger than one query', () => {
let env: PuterTestEnv;
const total = 10_050;
let dir: string;
let dirUuid: string;
beforeAll(async () => {
env = await setupPuterTestEnv();
const { username } = env.users.user;
dir = `/${username}/Documents/big-${randomUUID().slice(0, 8)}`;
const owner = (await env.server.stores.user.getByUsername(username))!;
const parent = (await env.server.stores.fsEntry.getEntryByPath(
`/${username}/Documents`,
))!;
dirUuid = randomUUID();
const now = Math.floor(Date.now() / 1000);
const insert = (rows: unknown[][]) =>
env.server.clients.db.write(
`INSERT INTO fsentries (uuid, user_id, parent_id, parent_uid, name, path, is_dir, modified, created, accessed, size) VALUES ${rows.map(() => '(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)').join(', ')}`,
rows.flat(),
);
await insert([
[
dirUuid,
owner.id,
parent.id,
parent.uuid,
dir.split('/').at(-1),
dir,
1,
now,
now,
now,
0,
],
]);
const folder = (await env.server.stores.fsEntry.getEntryByPath(dir))!;
for (let start = 0; start < total; start += 500) {
const rows: unknown[][] = [];
for (let i = start; i < Math.min(total, start + 500); i++) {
const name = `f${String(i).padStart(5, '0')}.txt`;
rows.push([
randomUUID(),
owner.id,
folder.id,
folder.uuid,
name,
`${dir}/${name}`,
0,
now,
now,
now,
0,
]);
}
await insert(rows);
}
const actor = { user: owner };
await runWithContext({ actor: actor as never }, () =>
env.server.services.share.share(actor as never, {
uid: dirUuid,
recipient: { username: env.users.other.username },
mode: 'read',
}),
);
}, 180_000);
afterAll(async () => {
await env?.shutdown();
});
const propfind = async (
path: string,
credentials: { username: string; password: string },
) => {
const port = new URL(env.apiOrigin).port;
const auth = Buffer.from(
`${credentials.username}:${credentials.password}`,
).toString('base64');
const response = await fetch(
`http://dav.puter.localhost:${port}${encodeURI(path)}`,
{
method: 'PROPFIND',
headers: { Authorization: `Basic ${auth}`, Depth: '1' },
},
);
return { status: response.status, body: await response.text() };
};
const hrefsOf = (xml: string) =>
[...xml.matchAll(/<D:href>([^<]*)<\/D:href>/g)].map((m) => m[1]!);
it('lists every child', async () => {
const { status, body } = await propfind(dir, TEST_USER_CREDENTIALS);
expect(status).toBe(207);
expect(body.endsWith('</D:multistatus>')).toBe(true);
const hrefs = hrefsOf(body);
expect(hrefs).toHaveLength(1 + total);
expect(new Set(hrefs).size).toBe(1 + total);
expect(body).toContain(`${dir}/f10049.txt`);
}, 60_000);
it("masks every page of a share recipient's listing", async () => {
const masked = `/${env.users.user.username}/${dirUuid}/${dir.split('/').at(-1)}`;
const { status, body } = await propfind(
masked,
TEST_OTHER_USER_CREDENTIALS,
);
expect(status).toBe(207);
const hrefs = hrefsOf(body);
expect(hrefs).toHaveLength(1 + total);
expect(hrefs.every((href) => href.startsWith(masked))).toBe(true);
expect(body).not.toContain('/Documents/');
}, 60_000);
});
@@ -24,6 +24,7 @@ import { posix as pathPosix } from 'node:path';
import { pipeline } from 'node:stream/promises';
import type { Actor } from '../../core/actor.js';
import { HttpError } from '../../core/http/HttpError.js';
import { pipeToResponse } from '../../core/http/pipeToResponse.js';
import {
assertNotSuspended,
assertTeam2fa,
@@ -58,6 +59,7 @@ import {
import { Context } from '../../core/context.js';
import type { FSEntry } from '../../stores/fs/FSEntry.js';
import {
contextBoundStream,
emitGuiItemEvent,
type GuiItemEventName,
} from '../fs/legacyFsHelpers.js';
@@ -600,19 +602,41 @@ export class WebDAVController extends PuterController {
propfindEntry(maskEntryPath(entry), entry, entry.isDir),
];
if (depth !== '0' && entry.isDir) {
const children = await this.services.fs.listDirectory(
entry.uuid,
{},
);
for (const child of children) {
responses.push(
propfindEntry(maskEntryPath(child), child, child.isDir),
);
}
if (depth === '0' || !entry.isDir) {
this.#sendMultistatus(res, responses);
return;
}
this.#sendMultistatus(res, responses);
const toXml = (children: FSEntry[]) =>
children
.map((child) =>
propfindEntry(maskEntryPath(child), child, child.isDir),
)
.join('\n');
const pages = this.services.fs.listDirectoryPages(entry.uuid);
const first = await pages.next();
if (!first.done && first.value.length > 0)
responses.push(toXml(first.value));
const second = await pages.next();
if (second.done) {
this.#sendMultistatus(res, responses);
return;
}
// Past one page the listing streams, so a large folder isn't held in
// memory whole. A failure partway cuts the response off.
const secondPage = second.value;
async function* body() {
yield `${MULTISTATUS_OPEN}${responses.join('\n')}`;
yield `\n${toXml(secondPage)}`;
for await (const page of pages)
if (page.length > 0) yield `\n${toXml(page)}`;
yield MULTISTATUS_CLOSE;
}
res.status(207).set({
'Content-Type': 'application/xml; charset=utf-8',
});
await pipeToResponse(contextBoundStream(body()), res);
}
/**
@@ -1289,8 +1313,12 @@ function escapeXml(text: string): string {
.replace(/'/g, '&#39;');
}
const MULTISTATUS_OPEN =
'<?xml version="1.0" encoding="utf-8"?>\n<D:multistatus xmlns:D="DAV:">\n';
const MULTISTATUS_CLOSE = '\n</D:multistatus>';
function wrapMultistatus(inner: string): string {
return `<?xml version="1.0" encoding="utf-8"?>\n<D:multistatus xmlns:D="DAV:">\n${inner}\n</D:multistatus>`;
return `${MULTISTATUS_OPEN}${inner}${MULTISTATUS_CLOSE}`;
}
/**
+16 -2
View File
@@ -63,10 +63,17 @@ export interface KnownContextFields {
driverName: string;
/**
* Aborts when the client disconnects before the response has finished (set
* by DriverController). Long-running drivers poll it so work nobody will
* receive stops early and is never metered.
* by DriverController). Some drivers (Gemini, Replicate) stop early on it.
* Async video and image jobs keep polling and are metered after a
* disconnect, since the provider bills them either way.
*/
abortSignal: AbortSignal;
/**
* Keeps the call's concurrency slot until `work` settles (set by
* DriverController when the method is capped), for work that outlives the
* response.
*/
keepDriverSlot: (work: Promise<unknown>) => void;
/**
* Aborts the chat driver's upstream request. A client disconnect doesn't
* fire it: the upstream bills a request it has accepted either way, so the
@@ -80,6 +87,11 @@ export interface KnownContextFields {
* different provider's translation of it.
*/
strictUpstreamErrors: boolean;
/**
* Set while Puter files something into an account for it (mail delivery),
* so filesystem events never report that write as the request actor's own.
*/
platformWrite: boolean;
}
// Every key of `KnownContextFields`, checked both ways by the compiler, so
@@ -90,8 +102,10 @@ const KNOWN_KEY_FLAGS: Record<keyof KnownContextFields, true> = {
requestId: true,
driverName: true,
abortSignal: true,
keepDriverSlot: true,
upstreamSignal: true,
strictUpstreamErrors: true,
platformWrite: true,
};
const KNOWN_KEYS: ReadonlySet<string> = new Set(Object.keys(KNOWN_KEY_FLAGS));
@@ -0,0 +1,163 @@
/*
* 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 { EventEmitter } from 'node:events';
import type { NextFunction, Request, RequestHandler, Response } from 'express';
import { describe, expect, it, vi } from 'vitest';
import { isHttpError } from '../HttpError';
import {
createLargeJsonBodyParser,
DEFAULT_JSON_LIMIT_BYTES,
} from './largeJsonBody';
const MiB = 1024 * 1024;
// A complete request with no socket reads as already consumed to body-parser,
// so the parsers call straight through, leaving any preset `body` in place.
const makeReq = (
bytes: number | undefined,
user: string | null = 'u1',
body?: unknown,
) =>
({
complete: true,
body,
headers: {
'content-type': 'application/json',
...(bytes === undefined ? {} : { 'content-length': String(bytes) }),
...(user ? { authorization: user } : {}),
},
}) as unknown as Request;
const makeRes = () => {
const res = new EventEmitter() as EventEmitter & {
setHeader: ReturnType<typeof vi.fn>;
};
res.setHeader = vi.fn();
return res;
};
const authProbe = vi.fn<RequestHandler>((req, _res, next) => {
if (req.headers.authorization) {
(req as { actor?: unknown }).actor = {
user: { uuid: req.headers.authorization },
};
}
next();
});
const run = (mw: RequestHandler, req: Request, res = makeRes()) =>
new Promise<{ err: unknown; res: ReturnType<typeof makeRes> }>(
(resolve) => {
mw(
req,
res as unknown as Response,
((err?: unknown) => resolve({ err, res })) as NextFunction,
);
},
);
const statusOf = (err: unknown) => (isHttpError(err) ? err.statusCode : err);
describe('createLargeJsonBodyParser', () => {
const create = ({
budgetBytes = 200 * MiB,
perUserBytes = 128 * MiB,
accepts,
}: {
budgetBytes?: number;
perUserBytes?: number;
accepts?: (req: Request) => boolean;
} = {}) =>
createLargeJsonBodyParser({
authProbe,
limitBytes: 128 * MiB,
budgetBytes,
perUserBytes,
accepts,
});
it.each([
['no Content-Length', undefined],
['a body within the default limit', DEFAULT_JSON_LIMIT_BYTES],
['a body over the large limit', 128 * MiB + 1],
])('leaves %s to the global parser without probing', async (_, bytes) => {
authProbe.mockClear();
const { err } = await run(create(), makeReq(bytes));
expect(err).toBeUndefined();
expect(authProbe).not.toHaveBeenCalled();
});
it('leaves an unauthenticated large body to the global parser', async () => {
const mw = create({ budgetBytes: 60 * MiB });
const { err } = await run(mw, makeReq(60 * MiB, null));
expect(err).toBeUndefined();
// Nothing was charged, so a signed-in caller still fits.
expect((await run(mw, makeReq(60 * MiB))).err).toBeUndefined();
});
it('charges nothing for a client that left during the probe', async () => {
const mw = create({ budgetBytes: 60 * MiB });
const gone = Object.assign(makeRes(), { closed: true });
expect((await run(mw, makeReq(60 * MiB), gone)).err).toBeUndefined();
expect((await run(mw, makeReq(60 * MiB))).err).toBeUndefined();
});
it('refuses with 503 past the budget until a response closes', async () => {
const mw = create({ budgetBytes: 130 * MiB });
const first = await run(mw, makeReq(60 * MiB, 'u1'));
await run(mw, makeReq(60 * MiB, 'u2'));
const refused = await run(mw, makeReq(60 * MiB, 'u3'));
expect(statusOf(refused.err)).toBe(503);
// Load shedding is recorded, not paged.
expect(isHttpError(refused.err) && refused.err.alarmSeverity).toBe(
'info',
);
expect(refused.res.setHeader).toHaveBeenCalledWith('Retry-After', '5');
first.res.emit('close');
expect((await run(mw, makeReq(60 * MiB, 'u3'))).err).toBeUndefined();
});
it("refuses one user's bodies with 429 past their share", async () => {
const mw = create({ perUserBytes: 100 * MiB });
const first = await run(mw, makeReq(60 * MiB, 'u1'));
const refused = await run(mw, makeReq(60 * MiB, 'u1'));
expect(statusOf(refused.err)).toBe(429);
expect(refused.res.setHeader).toHaveBeenCalledWith('Retry-After', '5');
// Another user isn't held back by it.
expect((await run(mw, makeReq(60 * MiB, 'u2'))).err).toBeUndefined();
first.res.emit('close');
expect((await run(mw, makeReq(60 * MiB, 'u1'))).err).toBeUndefined();
});
it('refuses with 413 a parsed body `accepts` turns down', async () => {
const mw = create({
accepts: (req) => (req.body as { ok?: boolean }).ok === true,
});
const refused = await run(mw, makeReq(60 * MiB, 'u1', { ok: false }));
expect(statusOf(refused.err)).toBe(413);
expect(
(await run(mw, makeReq(60 * MiB, 'u2', { ok: true }))).err,
).toBeUndefined();
});
});
@@ -0,0 +1,134 @@
/*
* 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 express, { type Request, type RequestHandler } from 'express';
import { actorUid } from '../../actor';
import { HttpError } from '../HttpError';
/** The global JSON parser's limit, which applies before auth. */
export const DEFAULT_JSON_LIMIT_BYTES = 50 * 1024 * 1024;
interface LargeJsonBodyOptions {
/** The global auth probe; it skips requests that already carry an actor. */
authProbe: RequestHandler;
limitBytes: number;
/** Declared bytes of large bodies a process holds at once. */
budgetBytes: number;
/** Declared bytes of large bodies one user holds at once. */
perUserBytes: number;
/** Checked once the body is parsed; a body it refuses gets a 413. */
accepts?: (req: Request) => boolean;
}
/**
* Parses JSON bodies over the default limit, up to `limitBytes`, once the
* caller's header or cookie credential resolves. Unauthenticated requests and
* requests without a Content-Length fall through to the global parser and its
* limit.
*
* The parsed body lives as long as the request, so a body's bytes count against
* both budgets until its response closes. Over the user's budget gets a 429,
* over the process budget a 503.
*/
export const createLargeJsonBodyParser = ({
authProbe,
limitBytes,
budgetBytes,
perUserBytes,
accepts,
}: LargeJsonBodyOptions): RequestHandler => {
const json = express.json({ limit: limitBytes });
const textAsJson = express.json({
limit: limitBytes,
type: (req) =>
req.headers['content-type'] === 'text/plain;actually=json',
});
let inFlightBytes = 0;
const inFlightByUser = new Map<string, number>();
return (req, res, next) => {
const declared = Number(req.headers['content-length']);
if (!(declared > DEFAULT_JSON_LIMIT_BYTES && declared <= limitBytes)) {
next();
return;
}
void authProbe(req, res, () => {
// `res.closed`: the client left during the probe, so `close` has
// already fired and would never release a charge.
if (!req.actor || res.closed) {
next();
return;
}
const user = req.actor.user.uuid ?? actorUid(req.actor);
const userBytes = inFlightByUser.get(user) ?? 0;
if (userBytes + declared > perUserBytes) {
res.setHeader('Retry-After', '5');
next(
new HttpError(
429,
'Too many large requests in progress for this account',
{ legacyCode: 'too_many_requests' },
),
);
return;
}
if (inFlightBytes + declared > budgetBytes) {
res.setHeader('Retry-After', '5');
next(
new HttpError(503, 'Too many large requests in progress', {
code: 'large_body_capacity',
// Load shedding, not a fault: record it, don't page.
alarmSeverity: 'info',
}),
);
return;
}
inFlightBytes += declared;
inFlightByUser.set(user, userBytes + declared);
res.once('close', () => {
inFlightBytes -= declared;
const left = (inFlightByUser.get(user) ?? 0) - declared;
if (left > 0) inFlightByUser.set(user, left);
else inFlightByUser.delete(user);
});
json(req, res, (err?: unknown) => {
if (err) {
next(err);
return;
}
textAsJson(req, res, (err?: unknown) => {
if (err) {
next(err);
return;
}
if (accepts && !accepts(req)) {
// The same answer the default limit gives.
next(
new HttpError(413, 'request entity too large', {
legacyCode: 'client_bad_request',
}),
);
return;
}
next();
});
});
});
};
};
@@ -476,9 +476,8 @@ function actorMatchesExpectedApp(
}
/**
* Mirror of `resolvePrivateIdentity` for public hosted apps. Reads the sticky
* `puter.public.hosted.actor.token` cookie first, then the same
* session/bootstrap fallbacks.
* Fresh authenticated identities replace the sticky public billing cookie when
* the visitor switches accounts.
*/
export async function resolvePublicHostedIdentity(opts: {
req: Request;
@@ -500,37 +499,6 @@ export async function resolvePublicHostedIdentity(opts: {
const cookies = (req as Request & { cookies?: Record<string, string> })
.cookies;
const { name: publicCookieNameV2, legacyName: publicCookieNameLegacy } =
HOSTED_ASSET_COOKIES.public;
const publicCookieToken =
(typeof cookies?.[publicCookieNameV2] === 'string'
? cookies[publicCookieNameV2]
: null) ??
(typeof cookies?.[publicCookieNameLegacy] === 'string'
? cookies[publicCookieNameLegacy]
: null);
if (publicCookieToken) {
try {
const claims = await authService.verifyHostedAssetToken(
'public',
publicCookieToken,
{
appUid: expectedAppUid,
subdomain: expectedSubdomain,
host: expectedHost,
},
);
return {
source: 'public-cookie',
userUid: claims.userUid,
sessionUuid: claims.sessionUuid,
hasValidPublicCookie: true,
};
} catch {
/* fall through — v1-signed cookies land here and get re-minted */
}
}
const existingActor = req.actor;
if (
existingActor?.user?.uuid &&
@@ -586,6 +554,37 @@ export async function resolvePublicHostedIdentity(opts: {
}
}
const { name: publicCookieNameV2, legacyName: publicCookieNameLegacy } =
HOSTED_ASSET_COOKIES.public;
const publicCookieToken =
(typeof cookies?.[publicCookieNameV2] === 'string'
? cookies[publicCookieNameV2]
: null) ??
(typeof cookies?.[publicCookieNameLegacy] === 'string'
? cookies[publicCookieNameLegacy]
: null);
if (publicCookieToken) {
try {
const claims = await authService.verifyHostedAssetToken(
'public',
publicCookieToken,
{
appUid: expectedAppUid,
subdomain: expectedSubdomain,
host: expectedHost,
},
);
return {
source: 'public-cookie',
userUid: claims.userUid,
sessionUuid: claims.sessionUuid,
hasValidPublicCookie: true,
};
} catch {
/* fall through — v1-signed cookies land here and get re-minted */
}
}
return { source: 'none' };
}
@@ -2591,3 +2591,239 @@ describe('createPuterSiteMiddleware — private-app gate decisions', () => {
},
);
});
describe('public hosted asset billing identity', () => {
it.each(['actor', 'session', 'query'] as const)(
'replaces an older billing cookie with the current %s identity',
async (source) => {
const owner = await makeUserWithHome();
const previous = await makeUser();
const current = await makeUser();
const home = (await server.stores.fsEntry.getEntryByPath(
`/${owner.username}`,
))!;
const sub = `switch-${uuidv4().slice(0, 8)}`;
const host = `${sub}.site.puter.localhost`;
await server.stores.subdomain.create({
userId: owner.id,
subdomain: sub,
rootDirId: home.id,
});
await writeFile(
owner.id,
`/${owner.username}/index.html`,
Buffer.from('hello'),
);
const app = await server.stores.app.create(
{ name: sub, title: sub, index_url: `http://${host}/` },
{ ownerUserId: owner.id },
);
const previousToken = await server.services.auth.getUserAppToken(
actorFor(previous),
app.uid,
);
const previousActor =
(await server.services.auth.authenticateFromToken(
previousToken,
))!;
const currentToken = await server.services.auth.getUserAppToken(
actorFor(current),
app.uid,
);
const oldCookie = await server.services.auth.createHostedAssetToken(
'public',
{
appUid: app.uid,
userUid: previous.uuid,
sessionUuid: previousActor.session?.uid,
subdomain: sub,
host,
},
);
const cookieName = HOSTED_ASSET_COOKIES.public.name;
const req = makeReq({
hostname: host,
path: '/index.html',
cookies: {
[cookieName]: oldCookie,
...(source === 'session' ? { session: currentToken } : {}),
},
query:
source === 'query'
? { 'puter.auth.token': currentToken }
: {},
});
if (source === 'actor')
req.actor =
(await server.services.auth.authenticateFromToken(
currentToken,
))!;
const { res, out } = makeRes();
await buildMiddleware({ cookie_name: 'session' })(
req,
res,
vi.fn(),
);
await new Promise<void>((resolve) => setImmediate(resolve));
expect(out.statusCode).toBe(200);
expect(req.egressActor?.user?.uuid).toBe(current.uuid);
const refreshed = out.cookies[cookieName];
expect(refreshed).toBeTruthy();
expect(
(
await server.services.auth.verifyHostedAssetToken(
'public',
refreshed,
{ appUid: app.uid, host },
)
).userUid,
).toBe(current.uuid);
const nextReq = makeReq({
hostname: host,
path: '/index.html',
cookies: { [cookieName]: refreshed },
});
await runMiddleware(buildMiddleware(), nextReq);
expect(nextReq.egressActor?.user?.uuid).toBe(current.uuid);
},
);
it('bills recognized visitors, reuses the parent session, and falls back to the owner', async () => {
const owner = await makeUserWithHome();
const visitor = await makeUser();
const home = (await server.stores.fsEntry.getEntryByPath(
`/${owner.username}`,
))!;
const sub = `egress-${uuidv4().slice(0, 8)}`;
const host = `${sub}.site.puter.localhost`;
await server.stores.subdomain.create({
userId: owner.id,
subdomain: sub,
rootDirId: home.id,
});
await writeFile(
owner.id,
`/${owner.username}/index.html`,
Buffer.from('hello'),
);
const app = await server.stores.app.create(
{ name: sub, title: sub, index_url: `http://${host}/` },
{ ownerUserId: owner.id },
);
const token = await server.services.auth.getUserAppToken(
actorFor(visitor),
app.uid,
);
const authenticated =
await server.services.auth.authenticateFromToken(token);
const serve = async (query = {}, cookies = {}, headers = {}) => {
const req = makeReq({ hostname: host, path: '/index.html', query });
Object.assign(req, { cookies, headers });
const { res, out } = makeRes();
await buildMiddleware()(req, res, vi.fn());
await new Promise<void>((resolve) => setImmediate(resolve));
expect(out.statusCode).toBe(200);
return { req, out };
};
const create = vi.spyOn(server.stores.session, 'create');
try {
const first = await serve({ 'puter.auth.token': token });
expect(first.req.egressActor?.user?.uuid).toBe(visitor.uuid);
const cookieName = HOSTED_ASSET_COOKIES.public.name;
const cookie = first.out.cookies[cookieName];
expect(cookie).toBeTruthy();
const claims = await server.services.auth.verifyHostedAssetToken(
'public',
cookie,
{ appUid: app.uid, host },
);
expect(claims.sessionUuid).toBe(authenticated?.session?.uid);
const next = await serve({}, { [cookieName]: cookie });
expect(next.req.egressActor?.user?.uuid).toBe(visitor.uuid);
expect(next.out.cookies).toEqual({});
const blockedCookies = await serve(
{},
{},
{ referer: `http://${host}/?puter.auth.token=${token}` },
);
expect(blockedCookies.req.egressActor?.user?.uuid).toBe(
visitor.uuid,
);
expect(create).not.toHaveBeenCalled();
const anonymous = await serve();
expect(anonymous.req.egressActor?.user?.uuid).toBe(owner.uuid);
const invalid = await serve({}, { [cookieName]: 'invalid' });
expect(invalid.req.egressActor?.user?.uuid).toBe(owner.uuid);
await server.services.auth.revokeAllSessionsForUserId(visitor.id);
const revoked = await serve({}, { [cookieName]: cookie });
expect(revoked.req.egressActor?.user?.uuid).toBe(owner.uuid);
} finally {
create.mockRestore();
}
});
});
describe('site rooted in the owner tree, root moved to Trash', () => {
it('stops serving a trashed folder and resumes when restored', async () => {
const owner = await makeUserWithHome();
const dir = await server.services.fs.mkdir(owner.id, {
path: `/${owner.username}/Documents/site`,
createMissingParents: true,
});
const body = Buffer.from('<html>trashed</html>');
await server.services.fs.write(owner.id, {
fileMetadata: {
path: `/${owner.username}/Documents/site/index.html`,
size: body.byteLength,
},
fileContent: body,
});
const sub = `tr-${Math.random().toString(36).slice(2, 8)}`;
await server.stores.subdomain.create({
userId: owner.id,
subdomain: sub,
rootDirId: dir.id,
});
const serveIndex = async () => {
const mw = createPuterSiteMiddleware(hostingConfig, {
clients: server.clients,
stores: server.stores,
services: server.services,
});
const { res, out } = makeRes();
await mw(
makeReq({
hostname: `${sub}.site.puter.localhost`,
path: '/',
}),
res,
vi.fn(),
);
await new Promise<void>((resolve) => setImmediate(resolve));
return out;
};
expect((await serveIndex()).statusCode).toBe(200);
const trash = (await server.stores.fsEntry.getEntryByPath(
`/${owner.username}/Trash`,
))!;
const entry = (await server.stores.fsEntry.getEntryById(dir.id))!;
await server.services.fs.move(owner.id, {
source: entry,
destinationParent: trash,
});
const moved = (await server.stores.fsEntry.getEntryById(dir.id))!;
expect(moved.path.startsWith(`/${owner.username}/Trash/`)).toBe(true);
const out = await serveIndex();
expect(out.statusCode).toBe(404);
const documents = (await server.stores.fsEntry.getEntryByPath(
`/${owner.username}/Documents`,
))!;
await server.services.fs.move(owner.id, {
source: moved,
destinationParent: documents,
});
expect((await serveIndex()).statusCode).toBe(200);
});
});
+42 -39
View File
@@ -27,6 +27,7 @@ import { MANAGE_PERM_PREFIX } from '../../../services/permission/consts';
import type { puterStores } from '../../../stores';
import type { IConfig, LayerInstances } from '../../../types';
import { awaitLater } from '../../../util/concurrency';
import { isTrashedPath } from '../../../util/hostedAppBacking';
import { makeActor } from '../../actor';
import {
buildAppCenterFallback,
@@ -109,10 +110,6 @@ const isServableEntryPath = (
return !isWorkersSourcePath(relPath) && !isSiteConfigPath(relPath);
};
/** Inside some owner's top-level Trash, which is where Delete puts things. */
const isTrashedPath = (path: string): boolean =>
/^\/[^/]+\/Trash(\/|$)/u.test(path);
/**
* Suggested value for `config.hosting_csp`. Not applied unless configured.
*
@@ -333,6 +330,7 @@ export const createPuterSiteMiddleware = (
const associatedApp = (await appLookup) as AppRow | null;
let hostedVisitorUid: string | undefined;
const isPrivateApp = Boolean(associatedApp?.is_private);
const privateApp = isPrivateApp ? associatedApp : null;
@@ -400,6 +398,8 @@ export const createPuterSiteMiddleware = (
return;
}
hostedVisitorUid = identity.userUid;
// Entitlement check runs on every request — matching v1. The
// sticky cookie is an identity shortcut, not an access cache;
// the marketplace extension already caches access decisions
@@ -509,11 +509,8 @@ export const createPuterSiteMiddleware = (
res.status(404).type('text/plain').send('Subdomain not found');
return;
} else {
// Public hosted site. Mint the public hosted-actor cookie if
// we can identify the visitor — lets the hosted page make
// cross-origin requests as the actor without needing a
// host-scoped main session cookie. No-op for anonymous
// visitors.
// Remember the authenticated visitor for asset billing across
// requests that do not carry the original app token.
try {
const identity = await resolvePublicHostedIdentity({
req,
@@ -526,6 +523,7 @@ export const createPuterSiteMiddleware = (
expectedSubdomain: subdomain,
expectedHost: host,
});
hostedVisitorUid = identity.userUid;
if (
identity.userUid &&
!(identity as { hasValidPublicCookie?: boolean })
@@ -574,7 +572,7 @@ export const createPuterSiteMiddleware = (
.send(SUBDOMAIN_404);
return;
}
if (!rootEntry.isDir) {
if (!rootEntry.isDir || isTrashedPath(rootEntry.path)) {
res.status(404)
.type('text/html; charset=UTF-8')
.send(SUBDOMAIN_404);
@@ -584,37 +582,27 @@ export const createPuterSiteMiddleware = (
// A site rooted in someone else's directory stands on the `manage`
// grant that authorized publishing it, and serves that whole subtree
// with the ACL bypassed — so the grant is re-read on every request.
// Trash is its own check: a grant is keyed on the node, not its
// location, so it survives the owner deleting the directory. Sites
// rooted in their publisher's own tree skip all of this.
if (rootEntry.userId !== site.user_id) {
let stillPublishable = false;
if (!isTrashedPath(rootEntry.path)) {
try {
stillPublishable = await layers.services.acl.check(
makeActor({
user: {
id: owner.id,
uuid: owner.uuid,
username: owner.username,
},
}),
{
path: rootEntry.path,
resolveAncestors: () =>
layers.services.fs.getAncestorChain(
rootEntry.path,
),
try {
stillPublishable = await layers.services.acl.check(
makeActor({
user: {
id: owner.id,
uuid: owner.uuid,
username: owner.username,
},
MANAGE_PERM_PREFIX,
);
} catch (e) {
// Fail closed — an unreadable grant is not a held one.
console.warn(
'[puter-site] publish grant recheck failed',
e,
);
}
}),
{
path: rootEntry.path,
resolveAncestors: () =>
layers.services.fs.getAncestorChain(rootEntry.path),
},
MANAGE_PERM_PREFIX,
);
} catch (e) {
// Fail closed — an unreadable grant is not a held one.
console.warn('[puter-site] publish grant recheck failed', e);
}
if (!stillPublishable) {
res.status(404)
@@ -837,7 +825,22 @@ export const createPuterSiteMiddleware = (
// hosting is unauthenticated by design, so most visitors are nobody in
// particular. Set unconditionally because hosting subdomains are not
// metered by default: this is what opts the response in.
req.egressActor = req.actor ?? {
let visitorActor = req.actor;
if (hostedVisitorUid && visitorActor?.user?.uuid !== hostedVisitorUid) {
try {
const visitor =
await layers.stores.user.getByUuid(hostedVisitorUid);
if (visitor && !visitor.suspended) {
visitorActor = makeActor({
user: visitor,
app: associatedApp ?? undefined,
});
}
} catch {
// Unknown requesters are billed to the site owner.
}
}
req.egressActor = visitorActor ?? {
user: {
uuid: owner.uuid,
id: owner.id,
+136 -7
View File
@@ -84,6 +84,10 @@ export function sweepMemoryWindows() {
)
memoryWindows.delete(k);
}
for (const [k, entry] of memoryCounters) {
if (entry.index < Math.floor(now / entry.windowMs) - 1)
memoryCounters.delete(k);
}
}
{
@@ -155,6 +159,97 @@ async function peekMemory(key, limit, windowMs) {
return timestamps.length < limit;
}
// -- Counted claims --------------------------------------------------
//
// `claim` keeps a member per unit, which is fine for a few units and not for
// thousands. The counted form keeps two counters per key, the current fixed
// window and the one before it, and slides by weighting the previous one by
// how much of it the window still covers. It can be off by a little at window
// edges, and its buckets are separate from the other forms'.
/** The sliding-window estimate from the two fixed windows it overlaps. */
const slidingCount = (previous, current, now, windowMs) =>
previous * (1 - (now % windowMs) / windowMs) + current;
/** How many of `units` a counted claim that left `used` spent may keep. */
const countedAdmits = (used, limit, units) =>
units - Math.min(units, Math.max(0, Math.ceil(used - limit)));
/** Key → `{ index, current, previous, windowMs }`, swept with the windows. */
const memoryCounters = new Map();
async function claimCountMemory(key, limit, windowMs, units) {
const now = Date.now();
const index = Math.floor(now / windowMs);
let entry = memoryCounters.get(key);
if (!entry) {
if (memoryCounters.size >= MEMORY_MAX_KEYS) {
memoryCounters.delete(memoryCounters.keys().next().value);
}
entry = { index, current: 0, previous: 0, windowMs };
memoryCounters.set(key, entry);
} else if (entry.index !== index) {
entry.previous = entry.index === index - 1 ? entry.current : 0;
entry.current = 0;
entry.index = index;
}
entry.windowMs = windowMs;
const used = slidingCount(entry.previous, entry.current, now, windowMs);
const admitted = countedAdmits(used + units, limit, units);
entry.current += admitted;
return admitted;
}
async function claimCountRedis(redis, key, limit, windowMs, units) {
const now = Date.now();
const index = Math.floor(now / windowMs);
// The hash tag keeps both windows in one slot, so one MULTI reads both.
const current = `ratec:{${key}}:${index}`;
const results = await redis
.multi()
.incrby(current, units)
.pexpire(current, 2 * windowMs)
.get(`ratec:{${key}}:${index - 1}`)
.exec();
const used = slidingCount(
Number(multiResult(results, 2) ?? 0),
Number(multiResult(results, 0)),
now,
windowMs,
);
const admitted = countedAdmits(used, limit, units);
if (admitted < units) await redis.decrby(current, units - admitted);
return admitted;
}
async function claimCountKv(kv, key, limit, windowMs, units) {
const now = Date.now();
const index = Math.floor(now / windowMs);
const current = `ratec:${key}:${index}`;
const [counted, previous] = await Promise.all([
kv.incr({
key: current,
pathAndAmountMap: { '': units },
expireAt: Math.ceil((now + 2 * windowMs) / 1000),
}),
kv.get({ key: `ratec:${key}:${index - 1}` }),
]);
const used = slidingCount(
Number(previous.res ?? 0),
Number(counted.res),
now,
windowMs,
);
const admitted = countedAdmits(used, limit, units);
if (admitted < units) {
await kv.decr({
key: current,
pathAndAmountMap: { '': units - admitted },
});
}
return admitted;
}
// -- Redis backend ---------------------------------------------------
// ioredis MULTI/EXEC reports per-command failures inside the exec() result
@@ -475,6 +570,10 @@ function instrumentBackendPair(name, pair) {
withSpan('rate_limit.claim', attrs, () =>
pair.claim(key, limit, windowMs, units),
),
count: (key, limit, windowMs, units) =>
withSpan('rate_limit.count', attrs, () =>
pair.count(key, limit, windowMs, units),
),
acquire: (key, limit) =>
withSpan('rate_limit.acquire', attrs, () =>
pair.acquire(key, limit),
@@ -486,6 +585,7 @@ const memoryBackendPair = instrumentBackendPair('memory', {
rate: checkMemory,
peek: peekMemory,
claim: claimMemory,
count: claimCountMemory,
acquire: acquireMemoryConcurrent,
});
@@ -536,6 +636,8 @@ export function configureRateLimit({
peekRedis(redis, key, limit, windowMs),
claim: (key, limit, windowMs, units) =>
claimRedis(redis, key, limit, windowMs, units),
count: (key, limit, windowMs, units) =>
claimCountRedis(redis, key, limit, windowMs, units),
acquire: (key, limit) => acquireRedisConcurrent(redis, key, limit),
});
}
@@ -545,6 +647,8 @@ export function configureRateLimit({
peek: (key, limit) => peekKv(kv, key, limit),
claim: (key, limit, windowMs, units) =>
claimKv(kv, key, limit, windowMs, units),
count: (key, limit, windowMs, units) =>
claimCountKv(kv, key, limit, windowMs, units),
acquire: (key, limit) => acquireKvConcurrent(kv, key, limit),
});
}
@@ -874,6 +978,32 @@ export async function claimRateLimit(key, limit, windowMs, units, backend) {
}
}
/**
* `claimRateLimit` for budgets measured in many units per call: a claim costs
* the same however many units it spends (see "Counted claims" above). Its
* buckets are its own, so a key is only ever claimed one way. Fails open,
* admitting every unit.
*/
export async function claimCountedRateLimit(
key,
limit,
windowMs,
units,
backend,
) {
if (!(units > 0)) return 0;
const bk = resolveBackend(backend);
try {
return await bk.count(key, limit, windowMs, units);
} catch (err) {
console.error(
'[rate-limit] imperative counted claim failed, failing open:',
err,
);
return units;
}
}
/**
* Charge a route-shaped rate-limit spec from inside a handler.
*
@@ -1049,8 +1179,8 @@ export function concurrencyGate(opts) {
* Acquire a concurrent slot for a driver call. Mirrors `checkDriverRateLimit`
* but returns an acquisition handle: caller MUST invoke `release()` on the
* returned object exactly once, even on thrown errors — typically in a
* `finally`. `ok: false` means the slot was full; callers should reject with
* 429 in that case.
* `finally`. `renew()` keeps a long-held slot past the orphan TTL. `ok: false`
* means the slot was full; callers should reject with 429 in that case.
*
* `opts` is the resolved per-method spec from the driver's decorator (or
* imperative `concurrent` field). Omitting `opts` (driver declares nothing)
@@ -1060,7 +1190,7 @@ export function concurrencyGate(opts) {
*/
export async function acquireDriverConcurrent(req, ifaceName, method, opts) {
if (!opts || typeof opts.limit !== 'number') {
return { ok: true, release: noop };
return { ok: true, release: noop, renew: noop };
}
const slot = await charge(
req,
@@ -1072,8 +1202,7 @@ export async function acquireDriverConcurrent(req, ifaceName, method, opts) {
label: '[concurrent] driver acquire',
},
);
if (!slot) return { ok: true, release: noop };
if (!slot.ok) return { ok: false, release: noop };
const { release } = guardSlot(slot, '[concurrent] driver');
return { ok: true, release };
if (!slot) return { ok: true, release: noop, renew: noop };
if (!slot.ok) return { ok: false, release: noop, renew: noop };
return guardSlot(slot, '[concurrent] driver');
}
@@ -37,6 +37,7 @@ import {
acquireDriverConcurrent,
checkDriverRateLimit,
checkRateLimit,
claimCountedRateLimit,
claimRateLimit,
concurrencyGate,
consumeRouteRateLimit,
@@ -1487,6 +1488,13 @@ describe('rate + concurrent limiting — kv backend', () => {
expect(await claimRateLimit(key, 5, 60_000, 1)).toBe(0);
});
it('claims counted units, admitting only what fits', async () => {
const key = uniqueScope();
expect(await claimCountedRateLimit(key, 5, 60_000, 3)).toBe(3);
expect(await claimCountedRateLimit(key, 5, 60_000, 3)).toBe(2);
expect(await claimCountedRateLimit(key, 5, 60_000, 1)).toBe(0);
});
it('claims against a full bucket as full while expired rows are unswept', async () => {
const key = uniqueScope();
await seedUnswept(`rate:${key}:`, 3);
@@ -2010,6 +2018,103 @@ describe('acquireConcurrent (imperative)', () => {
});
});
describe('claimCountedRateLimit', () => {
const uniqueKey = () =>
`counted-${Math.random().toString(36).slice(2, 10)}`;
const WINDOW = 10_000;
/** The start of a fixed window, so nothing before it carries in. */
const windowStart = (n) => vi.setSystemTime(n * WINDOW);
afterEach(() => {
vi.useRealTimers();
});
describe.each([
['memory', () => configureRateLimit()],
[
'redis',
() =>
configureRateLimit({
default: 'redis',
redis: new RedisMock(),
}),
],
])('%s backend', (_name, configure) => {
beforeEach(() => {
configure();
vi.useFakeTimers({ toFake: ['Date'] });
windowStart(1_000);
});
it('admits up to the limit and spends only what fits', async () => {
const key = uniqueKey();
expect(await claimCountedRateLimit(key, 5, WINDOW, 3)).toBe(3);
expect(await claimCountedRateLimit(key, 5, WINDOW, 3)).toBe(2);
expect(await claimCountedRateLimit(key, 5, WINDOW, 1)).toBe(0);
});
it('spends thousands of units in one claim', async () => {
const key = uniqueKey();
expect(
await claimCountedRateLimit(key, 15_000, WINDOW, 6_000),
).toBe(6_000);
expect(
await claimCountedRateLimit(key, 15_000, WINDOW, 10_000),
).toBe(9_000);
});
it('carries the previous window by how much of it is still inside', async () => {
const key = uniqueKey();
expect(await claimCountedRateLimit(key, 100, WINDOW, 100)).toBe(
100,
);
// A quarter into the next window, 75 of those still count.
vi.setSystemTime(1_001 * WINDOW + WINDOW / 4);
expect(await claimCountedRateLimit(key, 100, WINDOW, 50)).toBe(25);
// Two windows on, nothing carries.
windowStart(1_003);
expect(await claimCountedRateLimit(key, 100, WINDOW, 100)).toBe(
100,
);
});
it('keeps its buckets apart from claimRateLimit', async () => {
const key = uniqueKey();
expect(await claimCountedRateLimit(key, 2, WINDOW, 2)).toBe(2);
expect(await claimRateLimit(key, 2, WINDOW, 2)).toBe(2);
});
});
it('claims nothing for zero units', async () => {
const key = uniqueKey();
expect(await claimCountedRateLimit(key, 1, WINDOW, 0)).toBe(0);
expect(await claimCountedRateLimit(key, 1, WINDOW, 1)).toBe(1);
});
it('fails open, admitting every unit, when the backend throws', async () => {
const redis = new RedisMock();
redis.multi = () => {
throw new Error('redis down');
};
configureRateLimit({ default: 'redis', redis });
const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
expect(await claimCountedRateLimit(uniqueKey(), 1, WINDOW, 4)).toBe(4);
spy.mockRestore();
});
it('drops a memory counter once its windows have passed', async () => {
configureRateLimit();
vi.useFakeTimers({ toFake: ['Date'] });
windowStart(2_000);
const key = uniqueKey();
expect(await claimCountedRateLimit(key, 1, WINDOW, 1)).toBe(1);
windowStart(2_002);
sweepMemoryWindows();
windowStart(2_000);
expect(await claimCountedRateLimit(key, 1, WINDOW, 1)).toBe(1);
});
});
describe('claimRateLimit', () => {
const uniqueKey = () => `claim-${Math.random().toString(36).slice(2, 10)}`;
@@ -104,6 +104,8 @@ const default_user_permissions = {
// is there; which app may is the per-app grant, which is what the user is
// actually asked about.
'events:background': {},
// Likewise, the per-app grant decides whose notifications go out as pushes.
'notifications:push': {},
};
module.exports = {
@@ -209,6 +209,7 @@ afterAll(async () => {
const attemptsFor = async (
model: string,
thrown: unknown = new Error('upstream down'),
extra: { provider?: string; fallback?: boolean } = {},
) => {
createMock.mockRejectedValue(thrown);
let caught: HttpError | undefined;
@@ -217,6 +218,7 @@ const attemptsFor = async (
driver.complete({
model,
messages: [{ role: 'user', content: 'hi' }],
...extra,
}),
);
} catch (e) {
@@ -886,6 +888,59 @@ describe('ChatCompletionDriver duplicate-model fallback', () => {
});
});
describe('ChatCompletionDriver fallback opt-out', () => {
const SHARED = 'deepseek-v4-pro';
it('fails on the first route when the caller sets fallback: false', async () => {
const attempts = await attemptsFor(SHARED, undefined, {
fallback: false,
});
expect(attempts).toHaveLength(1);
expect(attempts[0]).toMatchObject({ provider: 'deepseek' });
});
it('tries only the pinned provider', async () => {
const attempts = await attemptsFor(SHARED, undefined, {
provider: 'openrouter',
fallback: false,
});
expect(attempts).toHaveLength(1);
expect(attempts[0]).toMatchObject({ provider: 'openrouter' });
});
it('rejects a pinned provider that has no route for the model', async () => {
createMock.mockClear();
await expect(
withTestActor(() =>
driver.complete({
model: SHARED,
provider: 'claude',
fallback: false,
messages: [{ role: 'user', content: 'hi' }],
}),
),
).rejects.toMatchObject({ statusCode: 400 });
expect(createMock).not.toHaveBeenCalled();
});
it('still marks the failed route for the next caller', async () => {
const [only] = await attemptsFor(SHARED, undefined, {
fallback: false,
});
expect(isRouteUnhealthy(only.provider, only.model)).toBe(true);
});
it('keeps the chain when fallback is true or unset', async () => {
expect(
await attemptsFor(SHARED, undefined, { fallback: true }),
).toHaveLength(3);
expect(await attemptsFor(SHARED)).toHaveLength(3);
});
});
// Each attempt in a fallback chain is a whole completion at that model's
// prices, so each one has to clear the credit gate on its own. The chain used
// to re-check with a nominal 1-microcent amount, which any account with a
@@ -709,6 +709,20 @@ export class ChatCompletionDriver extends PuterDriver {
legacyCode: 'bad_request',
});
}
// A pinned provider without a route for the model normally resolves
// to another vendor; with `fallback: false` that is the one thing the
// caller asked us not to do.
if (
args.fallback === false &&
intendedProvider &&
model.provider !== intendedProvider
) {
throw new HttpError(
400,
`Model ${args.model} is not served by provider ${intendedProvider}`,
{ legacyCode: 'bad_request' },
);
}
args.messages = normalizeMediaParts(normalize_messages(args.messages));
if (args.tools) {
@@ -946,8 +960,15 @@ export class ChatCompletionDriver extends PuterDriver {
const bucketKey = model.id;
const tried = new Set([routeId(model.provider!, model.id)]);
let lastError: Error | null = e as Error;
// With `fallback: false` the caller wants this route's answer or
// its error, not a bill for another provider's attempt.
const fallbackAllowed = args.fallback !== false;
while (lastError && attempts.length < MAX_ATTEMPTS) {
while (
fallbackAllowed &&
lastError &&
attempts.length < MAX_ATTEMPTS
) {
const fallback = this.#findFallback(bucketKey, tried);
if (!fallback) break;
@@ -809,6 +809,11 @@ describe('Alibaba official catalog', () => {
expect(models.some((model) => model.id === 'qwen-image-fixture')).toBe(
false,
);
const fixture = models.find(
(model) => model.id === 'qwen-catalog-fixture',
)!;
expect(fixture.puterId).toBe('alibaba:qwen/qwen-catalog-fixture');
expect(modelLookupNames(models)).toContain('qwen-catalog-fixture');
const plus = models.find((model) => model.id === 'qwen-plus')!;
const original = ALIBABA_MODELS.find(
(model) => model.id === 'qwen-plus',
@@ -909,6 +914,115 @@ describe('Alibaba official catalog', () => {
expect(model.costs).toEqual({ tokens: 1_000_000 });
});
it.each([
['qwen3.9-plus-2027-01-01', 'alibaba:qwen/qwen3.9-plus-2027-01-01'],
['kimi/kimi-k3', 'alibaba:kimi/kimi-k3'],
[
'deepseek-v4.1-flash-1017',
'alibaba:deepseek/deepseek-v4.1-flash-1017',
],
['glm-5.3-prime', 'alibaba:zhipu/glm-5.3-prime'],
['decision-model-preview', 'alibaba:qwen/decision-model-preview'],
])(
'gives uncurated model %s a puterId in the alibaba:<vendor>/<id> form',
(id, puterId) => {
const model = mapAlibabaCatalogModel({
model: id,
capabilities: ['TG'],
})!;
expect(model.puterId).toBe(puterId);
},
);
it('skips a vendor-prefixed spelling of a curated model', () => {
const model = mapAlibabaCatalogModel({
model: 'zhipu/glm-5.2',
capabilities: ['TG', 'Reasoning'],
inference_metadata: {
request_modality: ['Text'],
response_modality: ['Text'],
},
});
expect(model).toBeNull();
});
it.each([
[
'ASR',
{
capabilities: ['ASR'],
inference_metadata: {
request_modality: ['Audio'],
response_modality: ['Text'],
},
},
],
[
'rerank',
{
capabilities: ['TR'],
inference_metadata: {
request_modality: ['Text'],
response_modality: ['Text'],
},
},
],
[
'image generation',
{
capabilities: ['IG'],
inference_metadata: {
request_modality: ['Text'],
response_modality: ['Text', 'Image'],
},
},
],
[
'realtime omni',
{
capabilities: ['Realtime-Omni'],
inference_metadata: {
request_modality: ['Text', 'Image', 'Audio', 'Video'],
response_modality: ['Text', 'Audio'],
},
},
],
[
'TTS',
{
capabilities: ['TTS'],
inference_metadata: {
request_modality: ['Audio'],
response_modality: ['Text'],
},
},
],
])('rejects %s models that cannot serve chat completions', (_kind, raw) => {
expect(
mapAlibabaCatalogModel({
model: 'non-chat-fixture',
...(raw as AlibabaApiModel),
}),
).toBeNull();
});
it('admits a vision model', () => {
const model = mapAlibabaCatalogModel({
model: 'vision-fixture',
capabilities: ['VU'],
inference_metadata: {
request_modality: ['Text', 'Image'],
response_modality: ['Text'],
},
})!;
expect(model.modalities?.input).toEqual(['text', 'image']);
});
it('does not admit an uncurated entry without capability or modality data', () => {
expect(mapAlibabaCatalogModel({ model: 'mystery-model' })).toBeNull();
expect(mapAlibabaCatalogModel({ model: 'qwen-plus' })).not.toBeNull();
});
it('refuses inference with unverified catalog prices', async () => {
seedPages();
const { provider } = makeProvider();
@@ -82,6 +82,23 @@ const positiveLimit = (value: unknown): number | undefined =>
? value
: undefined;
const chatCapabilities = ['TG', 'Reasoning', 'VU'];
const curatedPuterIds = new Set(ALIBABA_MODELS.map((model) => model.puterId));
const inferPuterId = (id: string): string => {
if (id.includes('/')) return `alibaba:${id}`;
const family = id.split('-')[0];
const sibling = ALIBABA_MODELS.find(
(model) => model.puterId && model.id.split('-')[0] === family,
);
// Unprefixed feed ids are Model Studio's own family unless a curated
// sibling points at a third-party org.
const org =
sibling?.puterId?.slice('alibaba:'.length).split('/')[0] ?? 'qwen';
return `alibaba:${org}/${id}`;
};
export const mapAlibabaCatalogModel = (
raw: AlibabaApiModel,
previous?: IChatModel,
@@ -90,16 +107,25 @@ export const mapAlibabaCatalogModel = (
const id = raw.model.trim();
const existing =
previous ?? ALIBABA_MODELS.find((model) => model.id === id);
const input = raw.inference_metadata?.request_modality;
const output = raw.inference_metadata?.response_modality;
if (output && !output.includes('Text')) return null;
if (
!output &&
!existing &&
!raw.capabilities?.some((capability) =>
['TG', 'Reasoning', 'VU'].includes(capability),
raw.capabilities &&
!raw.capabilities.some((capability) =>
chatCapabilities.includes(capability),
)
)
return null;
if (input && !input.includes('Text')) return null;
if (output && !output.includes('Text')) return null;
if (!raw.capabilities && !input && !output && !existing) return null;
let puterId = existing?.puterId;
if (!puterId) {
puterId = inferPuterId(id);
// A feed entry spelling a curated model differently would duplicate
// the curated entry's puterId and list the model twice.
if (curatedPuterIds.has(puterId)) return null;
}
const catalogPricing: AlibabaCatalogTier[] = (raw.prices ?? []).map(
(tier) => {
// Abbreviated ranges do not specify whether k means 1000 or 1024 tokens.
@@ -149,6 +175,7 @@ export const mapAlibabaCatalogModel = (
return {
...existing,
id,
puterId,
name: raw.name ?? existing?.name ?? id,
description: raw.description ?? existing?.description,
aliases: existing?.aliases,
@@ -177,6 +177,7 @@ describe('OpenAiChatProvider model catalog', () => {
expect(ids).toContain('gpt-5.2-2025-12-11');
expect(ids).toContain('gpt-6-astra');
expect(ids).toContain('gpt-6.1-sol');
expect(ids).toContain('gpt-6.1-terra');
expect(ids).toContain('gpt-6-sol');
expect(ids).toContain('gpt-6-luna');
});
@@ -348,30 +349,33 @@ describe('OpenAiChatProvider.complete request shape', () => {
expect(args.safety_identifier).toBe('puter-u42');
});
it.each(['gpt-6-astra', 'gpt-6.1-sol', 'gpt-6-sol', 'gpt-6-luna'])(
'resolves the namespaced %s alias',
async (model) => {
const { provider } = makeProvider();
createMock.mockResolvedValueOnce(baseCompletion);
it.each([
'gpt-6-astra',
'gpt-6.1-sol',
'gpt-6.1-terra',
'gpt-6-sol',
'gpt-6-luna',
])('resolves the namespaced %s alias', async (model) => {
const { provider } = makeProvider();
createMock.mockResolvedValueOnce(baseCompletion);
await withTestActor(() =>
provider.complete({
model: `openai/${model}`,
messages: [{ role: 'user', content: 'hello' }],
reasoning_effort: 'low',
}),
);
await withTestActor(() =>
provider.complete({
model: `openai/${model}`,
messages: [{ role: 'user', content: 'hello' }],
reasoning_effort: 'low',
}),
);
expect(createMock.mock.calls[0]![0].model).toBe(model);
expect(createMock.mock.calls[0]![0].reasoning_effort).toBe('low');
expect(recordSpy).toHaveBeenCalledWith(
expect.any(Object),
expect.anything(),
`openai:${model}`,
expect.any(Object),
);
},
);
expect(createMock.mock.calls[0]![0].model).toBe(model);
expect(createMock.mock.calls[0]![0].reasoning_effort).toBe('low');
expect(recordSpy).toHaveBeenCalledWith(
expect.any(Object),
expect.anything(),
`openai:${model}`,
expect.any(Object),
);
});
it('omits parallel_tool_calls/tool_choice when no tool is sent (Chat Completions rejects them alone)', async () => {
const { provider } = makeProvider();
@@ -224,6 +224,7 @@ describe('OpenAiResponsesChatProvider model catalog', () => {
});
it.each([
['gpt-6.1-terra', '2026-06-01', '2026-10-26', 100, 5, 500],
['gpt-6.1-sol', '2026-04-30', '2026-09-29', 200, 10, 1000],
['gpt-6-sol', '2026-04-20', '2026-09-22', 200, 20, 1000],
['gpt-6-luna', '2026-05-18', '2026-09-22', 10, 1, 50],
@@ -244,6 +245,7 @@ describe('OpenAiResponsesChatProvider model catalog', () => {
tokens: 1_000_000,
prompt_tokens: input,
cached_tokens: cached,
cache_write_tokens: input * 1.25,
completion_tokens: output,
},
context: 1_050_000,
@@ -332,6 +334,7 @@ describe('OpenAiResponsesChatProvider.complete request shape', () => {
'gpt-6-luna',
'gpt-6-astra',
'gpt-6.1-sol',
'gpt-6.1-terra',
'gpt-5.6',
'openai/gpt-5.6',
'openai/gpt-5.6-luna',
@@ -367,7 +370,7 @@ describe('OpenAiResponsesChatProvider.complete request shape', () => {
},
);
it.each(['gpt-5.6-luna', 'gpt-6-luna'])(
it.each(['gpt-5.6-luna', 'gpt-6-luna', 'gpt-6.1-terra'])(
'preserves sampling with reasoning disabled for %s',
async (model) => {
const { provider } = makeProvider();
@@ -620,7 +623,13 @@ describe('OpenAiResponsesChatProvider.complete request shape', () => {
expect(gpt41Args).not.toHaveProperty('verbosity');
});
it.each(['gpt-6-astra', 'gpt-6.1-sol', 'gpt-6-sol', 'gpt-6-luna'])(
it.each([
'gpt-6-astra',
'gpt-6.1-sol',
'gpt-6.1-terra',
'gpt-6-sol',
'gpt-6-luna',
])(
'maps flat controls to Responses options for %s aliases',
async (model) => {
const { provider } = makeProvider();
@@ -860,47 +869,51 @@ describe('OpenAiResponsesChatProvider.complete non-stream output', () => {
});
});
it('bills the whole request at long-context rates past 272K input tokens', async () => {
const sol = OPEN_AI_MODELS.find((m) => m.id === 'gpt-6-sol')!;
const { provider } = makeProvider();
responsesCreateMock.mockResolvedValueOnce({
output: [{ role: 'assistant' }],
output_text: 'hi',
usage: {
input_tokens: 300_000,
output_tokens: 10_000,
input_tokens_details: {
cached_tokens: 50_000,
cache_write_tokens: 20_000,
it.each([
['gpt-6-sol', 119],
['gpt-6.1-terra', 59],
] as const)(
'bills %s at long-context rates past 272K input tokens',
async (modelId, total) => {
const model = OPEN_AI_MODELS.find((m) => m.id === modelId)!;
const { provider } = makeProvider();
responsesCreateMock.mockResolvedValueOnce({
output: [{ role: 'assistant' }],
output_text: 'hi',
usage: {
input_tokens: 300_000,
output_tokens: 10_000,
input_tokens_details: {
cached_tokens: 50_000,
cache_write_tokens: 20_000,
},
},
},
});
});
await withTestActor(() =>
provider.complete({
model: 'gpt-6-sol',
messages: [{ role: 'user', content: 'hi' }],
}),
);
await withTestActor(() =>
provider.complete({
model: modelId,
messages: [{ role: 'user', content: 'hi' }],
}),
);
const [, , , overrides] = recordSpy.mock.calls[0]!;
// $0.92 + $0.02 + $0.10 + $0.15 = $1.19, against $0.62 at the
// standard rates.
expect(overrides).toEqual({
prompt_tokens: 230_000 * Number(sol.costs.prompt_tokens) * 2,
cached_tokens: 50_000 * Number(sol.costs.cached_tokens) * 2,
cache_write_tokens:
20_000 * Number(sol.costs.cache_write_tokens) * 2,
completion_tokens:
10_000 * Number(sol.costs.completion_tokens) * 1.5,
});
const totalCents =
Object.values(overrides as Record<string, number>).reduce(
(a, b) => a + b,
0,
) / 1_000_000;
expect(totalCents).toBeCloseTo(119);
});
const [, , , overrides] = recordSpy.mock.calls[0]!;
expect(overrides).toEqual({
prompt_tokens: 230_000 * Number(model.costs.prompt_tokens) * 2,
cached_tokens: 50_000 * Number(model.costs.cached_tokens) * 2,
cache_write_tokens:
20_000 * Number(model.costs.cache_write_tokens) * 2,
completion_tokens:
10_000 * Number(model.costs.completion_tokens) * 1.5,
});
const totalCents =
Object.values(overrides as Record<string, number>).reduce(
(a, b) => a + b,
0,
) / 1_000_000;
expect(totalCents).toBeCloseTo(total);
},
);
it('bills cached tokens at the input rate when the model prices no cache read', async () => {
// gpt-5.4-pro is responses-API-only and its catalogue entry has no
@@ -31,6 +31,31 @@ const GPT_LONG_CONTEXT_PRICING = {
// Hardcoded from https://models.dev/api.json
export const OPEN_AI_MODELS: IChatModel[] = [
{
puterId: 'openai:openai/gpt-6.1-terra',
id: 'gpt-6.1-terra',
responsesSampling: 'reasoningDisabled',
modalities: { input: ['text', 'image'], output: ['text'] },
open_weights: false,
tool_call: true,
knowledge: '2026-06-01',
release_date: '2026-10-26',
aliases: ['openai/gpt-6.1-terra'],
costs_currency: 'usd-cents',
input_cost_key: 'prompt_tokens',
output_cost_key: 'completion_tokens',
costs: {
tokens: 1_000_000,
prompt_tokens: 100,
cached_tokens: 5,
cache_write_tokens: 100 * 1.25,
completion_tokens: 500,
},
long_context_pricing: GPT_LONG_CONTEXT_PRICING,
context: 1_050_000,
max_tokens: 128_000,
responses_api: true,
},
{
puterId: 'openai:openai/gpt-6.1-sol',
id: 'gpt-6.1-sol',
+5
View File
@@ -175,6 +175,11 @@ export interface UsageDetails {
export interface ICompleteArguments {
messages: PuterMessage[];
provider?: string;
/**
* `false` makes the first route's failure final instead of retrying the
* request on another provider that serves the same model.
*/
fallback?: boolean;
stream?: boolean;
model: string;
test_mode?: boolean;
@@ -289,6 +289,9 @@ describe('clampReasoningEffort', () => {
['gpt-5.5', 'max', 'xhigh'],
['gpt-5', 'high', 'high'],
['gpt-6-astra-1', 'none', 'low'],
...(['none', 'low', 'medium', 'high', 'xhigh', 'max'] as const).map(
(effort) => ['gpt-6.1-terra', effort, effort] as const,
),
] as const)(
'clamps %s requesting %s to %s',
(modelId, requested, expected) => {
@@ -39,9 +39,49 @@ describe('estimateTextTokens', () => {
const long = estimateTextTokens('hello world '.repeat(100));
expect(long).toBeGreaterThan(short);
});
it('counts words the way splitting on \\s+ does', () => {
const splitEstimate = (text: string) =>
Math.floor(
(text.length / 4 + text.split(/\s+/).length * (4 / 3)) / 2,
);
for (const text of [
'a',
' a',
'a ',
' a b\t\nc ',
'x y z w 

   v',
'é ü 日本語 テキスト',
'\r\n\v\f',
]) {
expect(estimateTextTokens(text), JSON.stringify(text)).toBe(
splitEstimate(text),
);
}
});
it('gives text past the scan budget the word density scanned so far', () => {
const text = 'ab cd '.repeat(1000);
const budget = { chars: 12, scannedChars: 0, scannedRuns: 0 };
// Uniform text extrapolates to the full count.
expect(estimateTextTokens(text, budget)).toBe(estimateTextTokens(text));
expect(budget.chars).toBe(0);
// A later part with no budget left takes the same density.
expect(estimateTextTokens(text, budget)).toBe(estimateTextTokens(text));
});
});
describe('estimatePromptTokens', () => {
it('scans a bounded amount of text per prompt', () => {
// Many parts, each under the budget alone, past it together; the
// unscanned ones are extrapolated, which is exact for uniform text.
const part = 'lorem ipsum dolor '.repeat(30_000);
const parts = Array.from({ length: 8 }, () => ({ text: part }));
expect(estimatePromptTokens([{ role: 'user', content: parts }])).toBe(
8 * estimateTextTokens(part),
);
});
it('counts plain string content', () => {
const tokens = estimatePromptTokens([
{ role: 'user', content: 'a fairly ordinary sentence to price' },
@@ -29,7 +29,11 @@
* passes every gate.
*/
import { estimateTextTokens } from '../../util/tokenEstimate.js';
import {
createWordScanBudget,
estimateTextTokens,
type WordScanBudget,
} from '../../util/tokenEstimate.js';
// Re-exported so existing consumers keep one import site for all estimators.
export { estimateTextTokens } from '../../util/tokenEstimate.js';
@@ -91,23 +95,25 @@ const attachmentTokens = (value: unknown): number => {
* recognises is charged the unmeasured estimate — the alternative is a content
* type nobody has taught the gate about arriving for free.
*/
const partTokens = (part: unknown): number => {
if (typeof part === 'string') return estimateTextTokens(part);
const partTokens = (part: unknown, budget: WordScanBudget): number => {
if (typeof part === 'string') return estimateTextTokens(part, budget);
if (!part || typeof part !== 'object') return 0;
const p = part as ContentPart;
if (typeof p.text === 'string') return estimateTextTokens(p.text);
if (typeof p.text === 'string') return estimateTextTokens(p.text, budget);
// Model-output shapes replayed as history: Anthropic thinking blocks,
// our own streamed `reasoning` chunks, OpenAI refusals. All text — they
// must not fall through to the attachment default below, which would
// price a few sentences of reasoning like a full-frame image.
if (typeof p.thinking === 'string') return estimateTextTokens(p.thinking);
if (typeof p.thinking === 'string')
return estimateTextTokens(p.thinking, budget);
if (typeof p.reasoning === 'string') {
return estimateTextTokens(p.reasoning);
return estimateTextTokens(p.reasoning, budget);
}
if (typeof p.refusal === 'string') return estimateTextTokens(p.refusal);
if (typeof p.refusal === 'string')
return estimateTextTokens(p.refusal, budget);
// OpenAI-style `{ image_url: { url } }` (or the flattened string form).
if (p.image_url) {
@@ -136,6 +142,7 @@ const partTokens = (part: unknown): number => {
if (p.type === 'tool_use' && p.input !== undefined) {
return estimateTextTokens(
typeof p.input === 'string' ? p.input : JSON.stringify(p.input),
budget,
);
}
if (p.type === 'tool_result') {
@@ -143,10 +150,11 @@ const partTokens = (part: unknown): number => {
typeof p.content === 'string'
? p.content
: JSON.stringify(p.content ?? ''),
budget,
);
}
if (p.type === 'compaction') {
return estimateTextTokens(String(p.encrypted_content ?? ''));
return estimateTextTokens(String(p.encrypted_content ?? ''), budget);
}
return UNMEASURED_ATTACHMENT_TOKENS;
@@ -164,21 +172,22 @@ const partTokens = (part: unknown): number => {
export const estimatePromptTokens = (messages: unknown): number => {
if (!Array.isArray(messages)) return 0;
const budget = createWordScanBudget();
let tokens = 0;
for (const message of messages) {
if (typeof message === 'string') {
tokens += estimateTextTokens(message);
tokens += estimateTextTokens(message, budget);
continue;
}
if (!message || typeof message !== 'object') continue;
const content = (message as ContentPart).content;
if (typeof content === 'string') {
tokens += estimateTextTokens(content);
tokens += estimateTextTokens(content, budget);
} else if (Array.isArray(content)) {
for (const part of content) tokens += partTokens(part);
for (const part of content) tokens += partTokens(part, budget);
} else if (content) {
tokens += partTokens(content);
tokens += partTokens(content, budget);
}
}
return tokens;
@@ -339,13 +339,16 @@ export class ImageGenerationDriver extends PuterDriver {
);
if (resolvedOutputPath) {
await saveGeneratedMediaToFS(
// False when write access was revoked while the job ran; the
// result still comes back.
const outputSaved = await saveGeneratedMediaToFS(
this.services.fs,
actor,
result,
resolvedOutputPath,
{ noun: 'image', defaultType: 'application/octet-stream' },
);
Context.set('driverMetadata', { outputSaved });
}
return result;
@@ -37,6 +37,7 @@ import {
type MockInstance,
} from 'vitest';
import { Context } from '../../../../core/context.js';
import type { MeteringService } from '../../../../services/metering/MeteringService.js';
import { PuterServer } from '../../../../server.js';
import { setupTestServer } from '../../../../testUtil.js';
@@ -349,3 +350,55 @@ describe('KlingImageProvider billing', () => {
expect(incrementUsageSpy).not.toHaveBeenCalled();
});
});
// -- After the caller leaves -----------------------------------------
describe('KlingImageProvider after the caller leaves', () => {
it('keeps polling after a disconnect and meters the finished image', async () => {
mockFlow(envelope(task('processing')), envelope(succeeded));
const url = await withTestActor(() => {
const abort = new AbortController();
abort.abort();
Context.set('abortSignal', abort.signal);
return makeProvider().generate({ prompt: 'a red apple' });
});
expect(url).toBe('https://kling.example/1.png');
expect(incrementUsageSpy).toHaveBeenCalledTimes(1);
});
it('meters an image that finishes after the window', async () => {
fetchSpy.mockResolvedValueOnce(envelope(task('submitted')));
const realNow = Date.now.bind(Date);
let skew = 0;
vi.spyOn(Date, 'now').mockImplementation(() => realNow() + skew);
let finished = false;
fetchSpy.mockImplementation(async () => {
skew = 6 * 60 * 1000;
return finished
? envelope(succeeded)
: envelope(task('processing'));
});
let failure: unknown;
try {
await generate({ model: 'kling-v3-omni', quality: '4k' });
} catch (e) {
failure = e;
}
expect(failure).toMatchObject({
statusCode: 504,
legacyCode: 'upstream_timeout',
message: 'Timed out waiting for Kling image generation to complete',
});
expect(incrementUsageSpy).not.toHaveBeenCalled();
finished = true;
await vi.waitFor(() =>
expect(incrementUsageSpy).toHaveBeenCalledTimes(1),
);
const [, usageType, amount, cost] = incrementUsageSpy.mock.calls[0]!;
expect(usageType).toBe('kling-image-generation:kling-v3-omni:4k');
expect(amount).toBe(1);
expect(cost).toBe(5.6 * 1_000_000);
});
});
@@ -24,9 +24,12 @@ import type { AiMeteringService } from '../../../util/aiCostFactor.js';
import { klingImageInput, klingRequest } from '../../../util/kling.js';
import {
CONTENT_FILTER_PATTERN,
isTransientUpstreamError,
sanitizeUpstreamMessage,
} from '../../../util/upstreamErrors.js';
import {
JobHold,
pollUntilSettled,
} from '../../../ai-video/providers/polling.js';
import {
closestAspectRatio,
formatAspectRatio,
@@ -145,6 +148,7 @@ export class KlingImageProvider implements IImageProvider {
);
if (!hold) throw insufficientCreditsError();
const jobHold = new JobHold(hold);
try {
const aspectRatio = params.imageSize
? formatAspectRatio(
@@ -184,7 +188,35 @@ export class KlingImageProvider implements IImageProvider {
path,
{ body, baseUrl: this.#baseUrl },
);
const task = await this.#pollUntilSettled(path, created);
// Billed per image; a failed task isn't.
const task = await pollUntilSettled<KlingImageTask>({
provider: 'kling',
providerLabel: 'Kling',
noun: 'image',
intervalMs: this.#pollIntervalMs,
windowMs: POLL_WINDOW_MS,
initial: created,
fetch: () =>
klingRequest<KlingImageTask>(
this.#apiKey,
'GET',
`${path}/${encodeURIComponent(created.task_id)}`,
{ baseUrl: this.#baseUrl },
),
isPending: (task) =>
task.task_status === 'submitted' ||
task.task_status === 'processing',
meter: async (task, settled) => {
if (settled && task?.task_status !== 'succeed') return;
await this.#meteringService.incrementUsage(
actor,
usageType,
1,
Math.round(cents * 1_000_000),
);
},
hold: jobHold,
});
if (task.task_status !== 'succeed') {
throw this.#taskFailure(task.task_status_msg);
}
@@ -192,68 +224,12 @@ export class KlingImageProvider implements IImageProvider {
if (!url) {
throw new Error('Kling response did not include an image URL');
}
await this.#meteringService.incrementUsage(
actor,
usageType,
1,
Math.round(cents * 1_000_000),
);
return url;
} finally {
await hold.release();
await jobHold.release();
}
}
async #pollUntilSettled(
path: string,
created: KlingImageTask,
): Promise<KlingImageTask> {
const signal = Context.get('abortSignal') as AbortSignal | undefined;
const deadline = Date.now() + POLL_WINDOW_MS;
let task = created;
while (
task.task_status === 'submitted' ||
task.task_status === 'processing'
) {
if (signal?.aborted) {
throw new HttpError(
400,
'Client disconnected before image generation finished',
{
legacyCode: 'client_aborted',
fields: { provider: 'kling' },
},
);
}
if (Date.now() > deadline) {
throw new HttpError(
504,
'Timed out waiting for Kling image generation to complete',
{
legacyCode: 'upstream_timeout',
fields: { provider: 'kling' },
},
);
}
await new Promise((resolve) =>
setTimeout(resolve, this.#pollIntervalMs),
);
try {
task = await klingRequest<KlingImageTask>(
this.#apiKey,
'GET',
`${path}/${encodeURIComponent(created.task_id)}`,
{ baseUrl: this.#baseUrl },
);
} catch (err) {
// A missed poll is not a failed task.
if (!isTransientUpstreamError(err)) throw err;
}
}
return task;
}
/**
* A content-policy refusal is the caller's to act on; anything else
* Kling's.
@@ -47,7 +47,13 @@ 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 { VoiceChangerDriver } from './VoiceChangerDriver.js';
import type { DriverStreamResult } from '../meta.js';
import {
OggOpusClock,
outputBytesPerSecond,
type VoiceChangerDriver,
wavDataOffset,
} from './VoiceChangerDriver.js';
import {
callerError,
readStreamResult,
@@ -330,11 +336,11 @@ describe('VoiceChangerDriver.convert success path', () => {
it('meters one usage line at the per-second rate from costs.ts', async () => {
const { actor } = await makeUser();
// 32 KB of mp3_44100_128 back = 2 seconds.
fetchSpy.mockResolvedValueOnce(
okResponse(new ArrayBuffer(8), 'audio/mpeg'),
okResponse(new ArrayBuffer(32_000), 'audio/mpeg'),
);
// 32 KB audio at 16 kbit/s = 2 seconds rounded up.
const buf = Buffer.alloc(32_000);
await readStreamResult(
await withActor(actor, () =>
@@ -344,7 +350,7 @@ describe('VoiceChangerDriver.convert success path', () => {
const usageType = 'elevenlabs:eleven_multilingual_sts_v2:second';
const perSecond = VOICE_CHANGER_COSTS[usageType];
const expectedSeconds = Math.max(1, Math.ceil(32_000 / 16000));
const expectedSeconds = 2;
const calls = incrementUsageSpy.mock.calls.filter(
([, type]) => type === usageType,
@@ -365,7 +371,7 @@ describe('VoiceChangerDriver.convert success path', () => {
.map(([, , , cost]) => cost);
const convert = async (charges: number) => {
fetchSpy.mockResolvedValueOnce(
okResponse(new ArrayBuffer(8), 'audio/mpeg'),
okResponse(new ArrayBuffer(32_000), 'audio/mpeg'),
);
await readStreamResult(
await withActor(actor, () =>
@@ -385,6 +391,230 @@ describe('VoiceChangerDriver.convert success path', () => {
expect(held).toEqual([base, base! * 2]);
});
it.each<[string, number]>([
['mp3_44100_128', 16_000],
['mp3_22050_32', 4_000],
['opus_48000_64', 8_000],
['pcm_44100', 88_200],
['ulaw_8000', 8_000],
['wav_44100', 88_200],
['mp3_44100_999999', 16_000],
['flac_44100', 16_000],
])('sizes %s at %s bytes per second', (format, bytes) => {
expect(outputBytesPerSecond(format)).toBe(bytes);
});
/** A 16-bit mono WAV header, with an optional LIST chunk before data. */
const wavHeader = (sampleRate: number, dataBytes: number, list = 0) => {
const listChunk = list
? Buffer.concat([
Buffer.from('LIST'),
Buffer.from(new Uint32Array([list]).buffer),
Buffer.alloc(list),
])
: Buffer.alloc(0);
const fmt = Buffer.alloc(24);
fmt.write('fmt ', 0, 'ascii');
fmt.writeUInt32LE(16, 4);
fmt.writeUInt16LE(1, 8);
fmt.writeUInt16LE(1, 10);
fmt.writeUInt32LE(sampleRate, 12);
fmt.writeUInt32LE(sampleRate * 2, 16);
fmt.writeUInt16LE(2, 20);
fmt.writeUInt16LE(16, 22);
const data = Buffer.alloc(8);
data.write('data', 0, 'ascii');
data.writeUInt32LE(dataBytes, 4);
const body = Buffer.concat([fmt, listChunk, data]);
const riff = Buffer.alloc(12);
riff.write('RIFF', 0, 'ascii');
riff.writeUInt32LE(4 + body.length + dataBytes, 4);
riff.write('WAVE', 8, 'ascii');
return Buffer.concat([riff, body]);
};
it("finds where a WAV stream's samples start", () => {
expect(wavDataOffset(wavHeader(16_000, 0))).toBe(44);
expect(wavDataOffset(wavHeader(16_000, 0, 26))).toBe(78);
expect(wavDataOffset(wavHeader(16_000, 0).subarray(0, 40))).toBe(
undefined,
);
expect(wavDataOffset(Buffer.alloc(64))).toBe(undefined);
});
/** One Ogg page carrying `body` as a single packet. */
const oggPage = (granule: bigint, sequence: number, body: Buffer) => {
const lacing: number[] = [];
let left = body.length;
while (left >= 255) {
lacing.push(255);
left -= 255;
}
lacing.push(left);
const header = Buffer.alloc(27);
header.write('OggS', 0, 'ascii');
header.writeBigUInt64LE(granule, 6);
header.writeUInt32LE(sequence, 18);
header[26] = lacing.length;
return Buffer.concat([header, Buffer.from(lacing), body]);
};
/** A sparse Ogg Opus stream: `seconds` of audio in very few bytes. */
const oggOpus = (seconds: number, preSkip = 312) => {
const opusHead = Buffer.alloc(19);
opusHead.write('OpusHead', 0, 'ascii');
opusHead[8] = 1;
opusHead[9] = 1;
opusHead.writeUInt16LE(preSkip, 10);
opusHead.writeUInt32LE(48_000, 12);
const pages = [
oggPage(0n, 0, opusHead),
oggPage(0n, 1, Buffer.from('OpusTags')),
];
for (let s = 1; s <= seconds; s++) {
pages.push(
oggPage(BigInt(preSkip + s * 48_000), s + 1, Buffer.alloc(300)),
);
}
return Buffer.concat(pages);
};
it('times an Ogg Opus stream by its last granule, however it is chunked', () => {
const stream = oggOpus(7);
const whole = new OggOpusClock();
whole.push(stream);
expect(whole.seconds).toBe(7);
const trickle = new OggOpusClock();
for (const byte of stream) trickle.push(Buffer.from([byte]));
expect(trickle.seconds).toBe(7);
});
it('does not time a stream that is not Ogg Opus', () => {
const clock = new OggOpusClock();
clock.push(Buffer.alloc(4096, 1));
expect(clock.seconds).toBe(undefined);
});
describe('meters the length of the audio that comes back', () => {
const usageType = 'elevenlabs:eleven_multilingual_sts_v2:second';
const billed = async () => {
await vi.waitFor(() =>
expect(
incrementUsageSpy.mock.calls.filter(
([, type]) => type === usageType,
),
).toHaveLength(1),
);
const [, , count, cost] = incrementUsageSpy.mock.calls.find(
([, type]) => type === usageType,
)!;
expect(cost).toBe(VOICE_CHANGER_COSTS[usageType]! * count);
return count;
};
const convert = async (
actor: Actor,
inputBytes: number,
args: Record<string, unknown> = {},
) =>
readStreamResult(
await withActor(actor, () =>
driver.convert({
audio: dataUrl(Buffer.alloc(inputBytes), 'audio/ogg'),
...args,
}),
),
);
it('bills 600 s for a compact 600 s clip, not its byte size', async () => {
const { actor } = await makeUser();
// 600 s of Opus at 8 kbps in; 600 s of mp3_44100_128 out.
fetchSpy.mockResolvedValueOnce(
okResponse(new ArrayBuffer(600 * 16_000)),
);
await convert(actor, 441_078);
expect(await billed()).toBe(600);
});
it('sizes the output by the requested output_format', async () => {
const { actor } = await makeUser();
// pcm_16000 is 16-bit mono: 32,000 bytes per second.
fetchSpy.mockResolvedValueOnce(
okResponse(new ArrayBuffer(3 * 32_000), 'audio/pcm'),
);
await convert(actor, 1_000_000, { output_format: 'pcm_16000' });
expect(await billed()).toBe(3);
});
it('does not bill a WAV header as audio', async () => {
const { actor } = await makeUser();
// 3 s of wav_16000 is 96,000 bytes of samples plus its header.
fetchSpy.mockResolvedValueOnce(
okResponse(
new Uint8Array(
Buffer.concat([
wavHeader(16_000, 3 * 32_000, 26),
Buffer.alloc(3 * 32_000),
]),
).buffer,
'audio/wav',
),
);
await convert(actor, 1_000_000, { output_format: 'wav_16000' });
expect(await billed()).toBe(3);
});
it('bills sparse Opus output by its duration, not its bytes', async () => {
const { actor } = await makeUser();
// 20 s of mostly silent opus_48000_64 is far under 20 s of bytes.
const body = oggOpus(20);
expect(body.length).toBeLessThan(8_000 * 2);
fetchSpy.mockResolvedValueOnce(
okResponse(new Uint8Array(body).buffer, 'audio/ogg'),
);
await convert(actor, 1_000, { output_format: 'opus_48000_64' });
expect(await billed()).toBe(20);
});
it('sizes an undocumented output_format as the default', async () => {
const { actor } = await makeUser();
// 3 s at the default mp3_44100_128.
fetchSpy.mockResolvedValueOnce(
okResponse(new ArrayBuffer(3 * 16_000)),
);
await convert(actor, 1_000_000, {
output_format: 'mp3_44100_999999',
});
expect(await billed()).toBe(3);
});
it('charges at least the input estimate when the caller hangs up', async () => {
const { actor } = await makeUser();
fetchSpy.mockResolvedValueOnce(
new Response(
new ReadableStream({
start(controller) {
controller.enqueue(new Uint8Array(16_000));
},
}),
{ status: 200, headers: { 'content-type': 'audio/mpeg' } },
),
);
const result = (await withActor(actor, () =>
driver.convert({
audio: dataUrl(Buffer.alloc(80_000), 'audio/mpeg'),
}),
)) as DriverStreamResult;
for await (const _chunk of result.stream) break;
expect(await billed()).toBe(5);
await vi.waitFor(async () =>
expect(
await server.stores.creditHold.outstanding(actor.user.uuid),
).toBe(0),
);
});
});
it('meters nothing when ElevenLabs breaks off before any audio', async () => {
const { actor } = await makeUser();
fetchSpy.mockResolvedValueOnce(
@@ -17,9 +17,11 @@
* along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
import { pipeline, Readable, Transform } from 'node:stream';
import { Context } from '../../core/context.js';
import { HttpError } from '../../core/http/HttpError.js';
import type { DriverStreamResult } from '../meta.js';
import { OUTPUT_FORMATS } from '../ai-tts/providers/elevenlabs/ElevenLabsTTSProvider.js';
import { PuterDriver } from '../types.js';
import {
type AiMeteringService,
@@ -198,10 +200,9 @@ export class VoiceChangerDriver extends PuterDriver {
legacyCode: 'bad_request',
});
// Metering: estimate duration from file size if we don't parse metadata.
// 16 kbit/s is a safe lower bound for speech audio; pre-check credits
// before we hit the ElevenLabs API. Post-usage we increment by the same
// estimate — duration parsing is deferred to v2.1 if needed.
// The hold estimates seconds from the input at 128 kbps: compact audio
// is under-held rather than refused. Usage is metered from the length
// of the audio that comes back.
const estimatedSeconds = Math.max(
1,
Math.ceil(loaded.buffer.byteLength / 16000),
@@ -263,6 +264,10 @@ export class VoiceChangerDriver extends PuterDriver {
if (search) url.search = search;
const metering = this.#aiMetering;
const output = measuredOutput(
outputBytesPerSecond(outputFormat),
outputFormat,
);
return withStreamedAiCharge(
metering,
actor,
@@ -287,16 +292,206 @@ export class VoiceChangerDriver extends PuterDriver {
dataType: 'stream',
content_type:
response.headers.get('content-type') ?? 'audio/mpeg',
stream: upstreamBodyStream(response),
stream: output.measure(upstreamBodyStream(response)),
};
},
() =>
metering.incrementUsage(
async () => {
const measured = await output.seconds;
// A stream cut short was still converted in full upstream.
const seconds =
measured.complete && measured.seconds
? measured.seconds
: Math.max(measured.seconds ?? 0, estimatedSeconds);
return metering.incrementUsage(
actor,
usageKey,
estimatedSeconds,
estimatedCost,
),
seconds,
ucentsPerSecond * seconds,
);
},
);
}
}
/**
* Bytes per second of an ElevenLabs `output_format`. A format outside the
* documented set is sized as the default, so a made-up bitrate can't shrink the
* bill.
*/
export function outputBytesPerSecond(format: string): number {
const known = OUTPUT_FORMATS.has(format) ? format : DEFAULT_OUTPUT_FORMAT;
const [codec, rate, kbps] = known.split('_');
if (codec === 'mp3' || codec === 'opus') return Number(kbps) * 125;
if (codec === 'ulaw' || codec === 'alaw') return Number(rate);
// pcm and wav: 16-bit mono.
return Number(rate) * 2;
}
// Past this, a WAV whose data chunk hasn't started is billed by raw length.
const WAV_HEADER_MAX_BYTES = 64 * 1024;
/**
* Where the samples of a RIFF/WAVE stream start, once `head` reaches its `data`
* chunk; undefined until then, or if `head` isn't a WAV.
*/
export function wavDataOffset(head: Buffer): number | undefined {
if (head.length < 12) return undefined;
if (
head.toString('ascii', 0, 4) !== 'RIFF' ||
head.toString('ascii', 8, 12) !== 'WAVE'
)
return undefined;
let offset = 12;
while (offset + 8 <= head.length) {
if (head.toString('ascii', offset, offset + 4) === 'data')
return offset + 8;
const size = head.readUInt32LE(offset + 4);
offset += 8 + size + (size & 1);
}
return undefined;
}
const OGG_HEADER_BYTES = 27;
const NO_GRANULE = 2n ** 64n - 1n;
/**
* Walks an Ogg Opus stream page by page as it arrives. Opus is variable
* bitrate, so its length comes from the last page's granule position (48 kHz
* samples, less the stream's pre-skip) rather than its byte count.
*/
export class OggOpusClock {
#header = Buffer.alloc(0);
#skip = 0;
#firstBody: Buffer | undefined = Buffer.alloc(0);
#firstBodyBytes = 0;
#granule: bigint | undefined;
#preSkip = 0;
#broken = false;
push(chunk: Buffer): void {
let offset = 0;
while (!this.#broken && offset < chunk.length) {
if (this.#skip > 0) {
const n = Math.min(this.#skip, chunk.length - offset);
if (this.#firstBody && this.#firstBody.length < 19) {
this.#firstBody = Buffer.concat([
this.#firstBody,
chunk.subarray(offset, offset + n),
]);
}
this.#skip -= n;
offset += n;
if (this.#skip === 0 && this.#firstBody) this.#readOpusHead();
continue;
}
const want =
this.#header.length < OGG_HEADER_BYTES
? OGG_HEADER_BYTES
: OGG_HEADER_BYTES + this.#header[26]!;
const n = Math.min(
want - this.#header.length,
chunk.length - offset,
);
this.#header = Buffer.concat([
this.#header,
chunk.subarray(offset, offset + n),
]);
offset += n;
if (this.#header.length < want) continue;
if (this.#header.toString('ascii', 0, 4) !== 'OggS') {
this.#broken = true;
return;
}
if (want === OGG_HEADER_BYTES && this.#header[26]! > 0) continue;
const granule = this.#header.readBigUInt64LE(6);
if (granule !== NO_GRANULE) this.#granule = granule;
let body = 0;
for (const lacing of this.#header.subarray(OGG_HEADER_BYTES)) {
body += lacing;
}
this.#header = Buffer.alloc(0);
this.#skip = body;
if (this.#firstBody) this.#firstBodyBytes = body;
}
}
/** Whole seconds so far, once the stream has proven to be Ogg Opus. */
get seconds(): number | undefined {
if (this.#broken || this.#granule === undefined) return undefined;
const samples = Number(this.#granule) - this.#preSkip;
return samples > 0
? Math.max(1, Math.ceil(samples / 48_000))
: undefined;
}
#readOpusHead(): void {
const head = this.#firstBody!;
this.#firstBody = undefined;
if (
this.#firstBodyBytes < 19 ||
head.toString('ascii', 0, 8) !== 'OpusHead'
) {
this.#broken = true;
return;
}
this.#preSkip = head.readUInt16LE(10);
}
}
/**
* Counts the bytes of a converted stream. `seconds` settles when the stream
* ends or closes: its length in whole seconds (undefined when nothing came
* back), and whether the upstream sent all of it. A WAV's header isn't audio,
* so it isn't counted; Opus is timed by its pages.
*/
function measuredOutput(bytesPerSecond: number, format: string) {
let bytes = 0;
let head: Buffer | undefined = format.startsWith('wav_')
? Buffer.alloc(0)
: undefined;
const opus = format.startsWith('opus_') ? new OggOpusClock() : undefined;
let headerBytes = 0;
let settle!: (measured: { seconds?: number; complete: boolean }) => void;
const seconds = new Promise<{ seconds?: number; complete: boolean }>(
(resolve) => {
settle = resolve;
},
);
const lengthOf = () => {
const timed = opus?.seconds;
if (timed !== undefined) return timed;
const audio = bytes - headerBytes;
return audio > 0
? Math.max(1, Math.ceil(audio / bytesPerSecond))
: undefined;
};
const measure = (source: Readable): Readable => {
const counter = new Transform({
transform(chunk: Buffer, _encoding, callback) {
bytes += chunk.length;
opus?.push(chunk);
if (head) {
head = Buffer.concat([head, chunk]);
const offset = wavDataOffset(head);
if (offset !== undefined) headerBytes = offset;
if (
offset !== undefined ||
head.length >= WAV_HEADER_MAX_BYTES
)
head = undefined;
}
callback(null, chunk);
},
flush(callback) {
settle({ seconds: lengthOf(), complete: true });
callback();
},
});
counter.once('close', () =>
settle({ seconds: lengthOf(), complete: false }),
);
pipeline(source, counter, () => {});
return counter;
};
return { measure, seconds };
}
@@ -508,6 +508,26 @@ describe('SpeechToTextDriver model selection and payload shape', () => {
expect(sent.temperature).toBe(0.2);
});
it('keeps extra_body from overriding the response shape billing reads', async () => {
const { actor } = await makeUser();
transcriptionsCreateMock.mockResolvedValueOnce({ text: 'x' });
await withActor(actor, () =>
driver.transcribe({
file: dataUrl(Buffer.from('a'), 'audio/mp3'),
model: 'gpt-transcribe',
extra_body: {
response_format: 'text',
stream: true,
chunking_strategy: 'auto',
},
}),
);
const sent = transcriptionsCreateMock.mock.calls[0]![0];
expect(sent.extra_body).toEqual({ chunking_strategy: 'auto' });
});
it('does not forward timestamp_granularities, which gpt-transcribe ignores', async () => {
const { actor } = await makeUser();
transcriptionsCreateMock.mockResolvedValueOnce({ text: 'x' });
@@ -684,6 +704,72 @@ describe('SpeechToTextDriver metering', () => {
expect(creditCall[1]).toBe(expected);
});
it('meters the duration OpenAI reports, not the byte estimate', async () => {
const { actor } = await makeUser();
const usageType = 'openai:gpt-transcribe:second';
const billed = async (bytes: number, seconds: number) => {
incrementUsageSpy.mockClear();
transcriptionsCreateMock.mockResolvedValueOnce({
text: 'x',
usage: { type: 'duration', seconds },
});
await withActor(actor, () =>
driver.transcribe({
file: dataUrl(Buffer.alloc(bytes, 1), 'audio/ogg'),
}),
);
const [, , count, cost] = incrementUsageSpy.mock.calls.find(
([, type]) => type === usageType,
)!;
expect(cost).toBe(SPEECH_TO_TEXT_COSTS[usageType]! * count);
return count;
};
// 600 s of Opus at 8 kbps, and 60 s of 44.1 kHz stereo WAV.
expect(await billed(441_078, 600)).toBe(600);
expect(await billed(10_584_078, 59.2)).toBe(60);
});
it('asks for json behind response_format=text so usage comes back', async () => {
const { actor } = await makeUser();
transcriptionsCreateMock.mockResolvedValueOnce({
text: 'plain transcript',
usage: { type: 'duration', seconds: 42 },
});
const result = await withActor(actor, () =>
driver.transcribe({
file: dataUrl(Buffer.from('a'), 'audio/mp3'),
response_format: 'text',
}),
);
expect(result).toBe('plain transcript');
expect(transcriptionsCreateMock.mock.calls[0]![0].response_format).toBe(
'json',
);
const usageType = 'openai:gpt-transcribe:second';
const [, , count] = incrementUsageSpy.mock.calls.find(
([, type]) => type === usageType,
)!;
expect(count).toBe(42);
});
it('falls back to the byte estimate when OpenAI reports token usage', async () => {
const { actor } = await makeUser();
transcriptionsCreateMock.mockResolvedValueOnce({
text: 'x',
usage: { type: 'tokens', input_tokens: 10, output_tokens: 2 },
});
await withActor(actor, () =>
driver.transcribe({
file: dataUrl(Buffer.alloc(32000, 0), 'audio/mp3'),
}),
);
const usageType = 'openai:gpt-transcribe:second';
const [, , count] = incrementUsageSpy.mock.calls.find(
([, type]) => type === usageType,
)!;
expect(count).toBe(2);
});
it('holds and meters at the AI cost factor', async () => {
const { actor } = await makeUser();
const usageType = 'openai:gpt-transcribe:second';
@@ -170,9 +170,9 @@ export class OpenAISpeechToTextProvider extends SpeechToTextProvider {
);
}
// 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.
// The hold estimates seconds from bytes at 128 kbps: compact audio is
// under-held rather than refused, and usage is metered from the
// duration OpenAI reports.
const estimatedSeconds = Math.max(
1,
Math.ceil(loaded.buffer.byteLength / 16000),
@@ -198,8 +198,13 @@ export class OpenAISpeechToTextProvider extends SpeechToTextProvider {
file: openaiFile,
model: selectedModel,
};
// A `text` response carries no usage, so json is asked for and
// its text returned.
if (args.response_format)
payload.response_format = args.response_format;
payload.response_format =
args.response_format === 'text'
? 'json'
: args.response_format;
if (args.language) payload.language = args.language;
if (typeof args.temperature === 'number')
payload.temperature = args.temperature;
@@ -207,7 +212,15 @@ export class OpenAISpeechToTextProvider extends SpeechToTextProvider {
// The API ignores a bare `logprobs` flag; it only honors `include`.
if (args.logprobs && caps.canLogprobs)
payload.include = ['logprobs'];
if (args.extra_body) payload.extra_body = args.extra_body;
if (args.extra_body && typeof args.extra_body === 'object') {
// Billing reads usage off the response; these can strip it.
const extra = {
...(args.extra_body as Record<string, unknown>),
};
delete extra.response_format;
delete extra.stream;
payload.extra_body = extra;
}
const result = await this.#openai.audio.transcriptions.create(
payload as unknown as Parameters<
@@ -215,11 +228,12 @@ export class OpenAISpeechToTextProvider extends SpeechToTextProvider {
>[0],
);
const billedSeconds = reportedSeconds(result) ?? estimatedSeconds;
this.deps.metering.incrementUsage(
actor,
usageType,
estimatedSeconds,
ucentsPerSecond * estimatedSeconds,
billedSeconds,
ucentsPerSecond * billedSeconds,
);
// Text response_format: return raw string; otherwise forward the OpenAI object.
@@ -234,3 +248,18 @@ export class OpenAISpeechToTextProvider extends SpeechToTextProvider {
}
}
}
/** Whole seconds of audio OpenAI reports billing for, if it reports them. */
function reportedSeconds(result: unknown): number | undefined {
if (!result || typeof result !== 'object') return undefined;
const { usage, duration } = result as {
usage?: { type?: string; seconds?: unknown };
duration?: unknown;
};
const seconds = usage?.type === 'duration' ? usage.seconds : duration;
return typeof seconds === 'number' &&
Number.isFinite(seconds) &&
seconds > 0
? Math.max(1, Math.ceil(seconds))
: undefined;
}
@@ -48,7 +48,7 @@ const VOICE_ID_PATTERN = /^[A-Za-z0-9]{20}$/;
const VOICE_SEARCH_MAX_PAGES = 5;
// https://elevenlabs.io/docs/api-reference/text-to-speech/convert
// mp3_44100_192 needs the Creator tier; pcm/wav at 44.1 kHz need Pro.
const OUTPUT_FORMATS = new Set([
export const OUTPUT_FORMATS = new Set([
'alaw_8000',
'mp3_22050_32',
'mp3_24000_48',
@@ -41,7 +41,8 @@ import {
} from 'vitest';
import { v4 as uuidv4 } from 'uuid';
import { runWithContext } from '../../core/context.js';
import { Context, runWithContext } from '../../core/context.js';
import { HttpError } from '../../core/http/HttpError.js';
import { type Actor, SYSTEM_ACTOR } from '../../core/actor.js';
import { PuterServer } from '../../server.js';
import type { MeteringService } from '../../services/metering/MeteringService.js';
@@ -926,6 +927,29 @@ describe('VideoGenerationDriver.generate puter_output_path', () => {
expect(result).toBe(`data:video/mp4;base64,${videoBytes}`);
});
it('returns the result and flags it unsaved when write access is revoked mid-job', async () => {
vi.spyOn(server.services.acl, 'check').mockResolvedValueOnce(true);
vi.spyOn(server.services.fs, 'checkFSAccess').mockRejectedValueOnce(
new HttpError(403, 'Access denied', { legacyCode: 'forbidden' }),
);
const fsWriteSpy = vi.spyOn(server.services.fs, 'write');
const videoBytes = Buffer.from('video-bytes').toString('base64');
completeTogetherJob(`data:video/mp4;base64,${videoBytes}`);
const [result, metadata] = await withTestUser(async () => [
await driver.generate({
prompt: 'hi',
model: DEFAULT_MODEL,
puter_output_path: '/testuser/videos/clip.mp4',
} as never),
Context.get('driverMetadata'),
]);
expect(result).toBe(`data:video/mp4;base64,${videoBytes}`);
expect(fsWriteSpy).not.toHaveBeenCalled();
expect(metadata).toEqual({ outputSaved: false });
});
it('does not forward puter_output_path to the upstream provider call', async () => {
const aclCheckSpy = vi.spyOn(server.services.acl, 'check');
aclCheckSpy.mockResolvedValueOnce(true);
@@ -336,13 +336,16 @@ export class VideoGenerationDriver extends PuterDriver {
});
if (resolvedOutputPath) {
await saveGeneratedMediaToFS(
// False when write access was revoked while the job ran; the
// result still comes back.
const outputSaved = await saveGeneratedMediaToFS(
this.services.fs,
actor,
result,
resolvedOutputPath,
{ noun: 'video', defaultType: 'video/mp4' },
);
Context.set('driverMetadata', { outputSaved });
}
return result;
@@ -395,25 +395,33 @@ describe('BytePlusVideoProvider.generate polling and outcomes', () => {
expect(fetchSpy).toHaveBeenCalledTimes(4); // 1 create + 3 polls
});
it('gives up after the wait window as HttpError 504 upstream_timeout, without metering', async () => {
it('gives up after the wait window as HttpError 504 upstream_timeout, metering the task once it settles', async () => {
vi.useFakeTimers();
try {
let late: Record<string, unknown> = {
id: 'cgt-slow',
status: 'running',
};
fetchSpy
.mockResolvedValueOnce(
jsonResponse({ id: 'cgt-slow', status: 'queued' }),
)
.mockImplementation(async () =>
jsonResponse({ id: 'cgt-slow', status: 'running' }),
);
.mockImplementation(async () => jsonResponse(late));
// The default poll interval keeps the window to ~60 polls.
const provider = new BytePlusVideoProvider(
{ apiKey: 'test-key' },
aiMetering(),
);
const rejection = withTestActor(() =>
provider.generate({ prompt: 'hi' }),
).catch((e: unknown) => e);
const rejection = (async () => {
try {
await withTestActor(() =>
provider.generate({ prompt: 'hi' }),
);
} catch (e) {
return e;
}
})();
// Ten-minute wait window, polled every 5s.
await vi.advanceTimersByTimeAsync(VIDEO_POLL_WINDOW_MS + 5_000);
@@ -425,7 +433,21 @@ describe('BytePlusVideoProvider.generate polling and outcomes', () => {
'Timed out waiting for BytePlus video generation to complete',
fields: { provider: 'byteplus' },
});
// BytePlus still renders and bills the task past the window.
expect(incrementUsageSpy).not.toHaveBeenCalled();
late = {
id: 'cgt-slow',
status: 'succeeded',
content: { video_url: 'https://byteplus.example/v.mp4' },
usage: { completion_tokens: 1234 },
};
await vi.advanceTimersByTimeAsync(10_000);
expect(incrementUsageSpy).toHaveBeenCalledTimes(1);
const [, usageType, tokens, cost] =
incrementUsageSpy.mock.calls[0]!;
expect(usageType).toMatch(/^byteplus-video-generation:/);
expect(tokens).toBe(1234);
expect(cost).toBeGreaterThan(0);
} finally {
vi.useRealTimers();
}
Loaded 100 of 587 files, more files were not shown because too many files have changed in this diff. Show more