Commit Graph
454 Commits
Author SHA1 Message Date
404oopsandClaude Opus 5.5 e2043a5e58 Merge remote-tracking branch 'origin/main' into fix/openai-cache-write-tiers
Main's docs cleanup (#4504) moved the prompt caching docs under
per-provider sections; the OpenAI paragraphs now live under
OpenAI models > Prompt Caching.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 19:55:55 +01:00
Filip Kujundžić 8b98c7ada8 Merge pull request #4503 from HeyPuter/codex/openai-image-default
feat(ai): add GPT Image 2.6 and default to Flare
2026-11-05 19:53:05 +01:00
Filip Kujundžić b27e84d181 Merge pull request #4457 from HeyPuter/fix/convo-bounded-recovery
Bound conversation recovery and report omitted replay context
2026-11-05 19:46:10 +01:00
404oopsandClaude Opus 5.5 add0e7be7d fix(convo): report routine context trimming as context_trimmed
A reconnect that left older items out of the 40-item, 48 KiB restore window
fired an error event with context_restore_failed, the code a provider
refusal uses, with the count only in the message. Every reconnect of a long
conversation raised an error that apps treating error as fatal would surface.

Trimming now fires a nonfatal context_trimmed event, before ready, with the
count in omittedItems. context_restore_failed is kept for a provider refusal
of a restored item or a failed send of the replay.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 19:28:02 +01:00
404oops b5d221ede6 fix(convo): bound recovery attempts and report omitted context 2026-11-05 19:28:02 +01:00
404oops 3137a631a7 fix(realtime): reuse settled credit for post-record continuation 2026-11-05 19:28:02 +01:00
404oopsandClaude Opus 5.5 57706d8093 fix(realtime): bound unacknowledged audio by duration, not a byte cap
The per-commit cap of 1,280,000 bytes assumed G.711, so a PCM16 session
(48,000 bytes/s, the SDK's format) with transcription on was closed with
1013 after about 27 seconds without a commit: silence under server VAD,
one long utterance, or a long assistant reply.

Unacknowledged audio is now a duration at the provider-confirmed input
format (the lower rate while a requested format is unconfirmed). Without
turn detection it counts all audio since the last commit or clear; with
server VAD only speech from speech_started, plus the buffer after an
explicit commit, since the provider drops silence. Each started 160 s
claims one spare item as its hold instead of closing the session; a
commit, clear or input_audio_buffer_commit_empty returns the holds to the
pool. If the upstream closes first, every claimed item is billed. The
settle no longer commits silence.

The relay test file now expires the in-memory per-IP pre-auth window
after each test; every test connects from loopback and the added tests
pushed the file past 120 upgrades a minute.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 19:27:54 +01:00
Reynaldi ChernandoandClaude Opus 5.5 fec1d1916f Merge remote-tracking branch 'upstream/main' into codex/openai-image-default
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 01:03:08 +07:00
Juan Fernando CastroandReynaldi Chernando da369039e0 docs: complex teams recipe (PUT-1986) (#4095)
* docs: complex teams recipe (PUT-1986)

Showing which teammates have a file open, built from a folder shared with the
team plus `puter.events` watching it. There is no presence API, so the recipe
is about composing the two.

One use case per the recipe guidance. Uses the masked path from
`listShared()` so members address the owner's folder, keys presence files on
`uuid` rather than username, and re-reads the folder on each event so a missed
delivery cannot leave the list wrong.

* refine

---------

Co-authored-by: Reynaldi Chernando <reynaldichernando@gmail.com>
2026-11-06 00:53:25 +07:00
404oopsandClaude Opus 5.5 4f36152850 fix(realtime): hold transcription credit per item at the selected model
Input transcription reserved 64 items up front at the costliest model's
price and largest cost factor, about $7 before factors per session. Ordinary
subscribers couldn't enable it, and concurrent sessions were refused while
balance was left.

Each item now holds its own credit at the selected transcription model's
budget and cost factor. Two spare items are reserved before configuration or
audio is forwarded and refilled as the provider commits items, within the
64-item pending cap. A commit with no spare reserves its own item. A model
change releases the spares and reserves new ones at the new price; committed
items keep their hold. When the account can't cover another item, client
reads pause while the session's own holds may still free credit, and the
session is refused with insufficient_funds once it holds none.

Audio awaiting a commit acknowledgement is now tracked separately from pool
capacity and reset by each acknowledged commit or clear. If the upstream
closes before that acknowledgement, the audio bills as one item. Previously
the fallback required an untouched window, so a lost acknowledgement after an
earlier completed item went unbilled; the cumulative byte count could not
simply be reused without charging earlier work twice. The uncommitted-audio
cap is now 160 seconds of audio instead of a per-window total.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 10:23:49 -07:00
404oops 86db96d587 fix(realtime): reserve and settle pending transcription work 2026-11-05 10:23:49 -07:00
404oops 214ffb6605 fix(realtime): evict revoked credentials and recheck paid eligibility 2026-11-05 10:23:48 -07:00
404oops 871cc2cf87 fix(realtime): bound upgrades before credential admission 2026-11-05 10:23:48 -07:00
404oops ba002be0f7 fix(convo): reject interrupt readiness failures asynchronously 2026-11-05 10:23:47 -07:00
404oops d2cef27085 perf(metering): avoid retries for growing buffers 2026-11-05 10:23:46 -07:00
404oopsandClaude Opus 5.5 305d20ab1c fix(puter-js): accept provider events up to the relay's 4 MiB cap
The conversation transport refused any server event over 64 KiB with a
terminal 4009 close, the limit meant for what the SDK sends. The relay
forwards provider events up to 4 MiB, and a session.updated echo or a
whole audio item easily passes 64 KiB, so valid conversations ended.

Incoming events now have their own 4 MiB limit, matching the relay,
counted in UTF-8 bytes (a string short enough that it can't exceed the
limit isn't encoded to check). An event over it still closes with 4009
and doesn't reconnect. Uploads keep their 64 KiB cap, and malformed
events are still ignored.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 10:23:44 -07:00
404oopsandClaude Opus 5.5 6b0b530ab1 fix(realtime): retry instead of refusing when the plan lookup fails
When the billing lookup failed, metering answered with a provisional
fallback plan, so the relay refused the session with
`subscription_required` or, against the fallback's allowance,
`insufficient_funds`, both terminal 1008 closes, and the SDK showed the
upgrade prompt to a paying account. A plan or funds refusal decided on a
provisional plan now sends `internal_error` and closes with 1011, at
connection and mid-session, so the client retries once the lookup
recovers.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 10:23:43 -07:00
404oopsandClaude Opus 5.5 c3756d3ac3 fix(realtime): tell transient server failures apart from bad credentials
A credential lookup that threw (a database or cache outage) was treated
as no actor, so the session got `invalid_api_key` and a 1008 close. That
is terminal for the SDK, which then ended an established conversation
that only needed to reconnect. A failed lookup now gets `internal_error`
and closes with 1011, which the SDK retries; missing, invalid and
revoked credentials keep `invalid_api_key` and 1008. Nothing is cached
for the failed lookup, and the cause is logged server-side only.

A session whose usage records pile up behind metering now closes with
1013 instead of 1008, still with `rate_limit_exceeded`. Queued usage is
still recorded and its holds released. An unexpected setup failure now
sends `internal_error` before its 1011 close.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-05 10:23:43 -07:00
Neal Shah 64c3b3c0ce subdomain and worker recipes complex, writing full stack applications with both (#4206) 2026-11-05 21:33:21 +07:00
Reynaldi Chernando e9a4c7a461 ai docs cleanup (#4504)
Backend Tests / changes (push) Waiting to run
Maintain Release Merge PR / update-release-pr (push) Waiting to run
Notify HeyPuter / notify (push) Waiting to run
release-please / release-please (push) Waiting to run
* ai docs cleanup

* fixes
2026-11-05 19:15:32 +07:00
Filip Kujundžić be9cd9eb89 feat(ai): add GPT Image 2.6 and default to Flare 2026-11-05 11:13:43 +01:00
Daniel Salazar 8b64cc5076 docs(notifications): recipes and format pass for dismissal, priority, buttons and schedule updates (#4502)
- dismiss.md is the one place that explains closing a notification on other
  devices; on/onNotification/push link to it instead of repeating it.
- Pages follow the house format: section order, error tables, playground
  examples for dismiss() and markShown(), sidebar and index listings.
- updateSchedule.md describes what a developer sees instead of internals.
- New recipes: mark notifications as read, incoming calls (high priority,
  short ttl, buttons), unread counts on the app icon.
- Existing recipes move reminders, snoozes and recurring summaries with
  updateSchedule() instead of cancel-and-recreate; the buttons recipe follows
  the recipe format.
2026-11-05 00:32:53 -07:00
Daniel Salazar d3869b7846 feat(notifications): dismissal across devices, high priority, buttons, schedule updates, docs fixes (#4501)
- Docs: status(), browser limits, events worker fetch/ack, update() across
  pages, stored-notification retries, device removal, push delivery, queue
  coalescing, existing service workers and missing error codes match the code.
- puter.notifications.markShown(uid) / dismiss(uid).
- Dismissals reach the app's live feed (on('dismiss')); pages close the
  notification on screen, and a later page load closes ones already dismissed.
  The desktop closes its own copies on notif.ack.
- Live notification postings are forwarded to sessions in other regions.
- notify({ priority: 'high' }).
- onPush() results accept requireInteraction and actions; clicks carry action.
- replaced reaches service-worker onPush() handlers.
- puter.events.updateSchedule(scheduleId, changes), with the invoker
  re-checking a schedule run's guard right before it is sent.
2026-11-05 00:17:27 -07:00
Reynaldi Chernando 8acd73675c use gpt 6 luna as example in docs (#4497) 2026-11-05 08:20:12 +07:00
Juan Fernando Castro 93798cba93 fix(puter-js): saved push handlers stay while any tab uses them, and the worker can skip them (#4481)
* fix(puter-js): background push syncs never prompt for sign-in

The load-time sync and the resync after the browser replaces a
subscription ask for the key and announce the subscription without
opening Puter's sign-in; only enable() may. The confirmation and the
removal never open it, and looking up the user for the opt-in uses a
non-interactive /whoami. A rejected token during a background sync is
not reported as a sync failure.

* fix(puter-js): turning push off in one tab stops other tabs' syncs

Tabs of a site take a shared Web Lock to subscribe and announce, and
again to confirm, without holding it while the confirmation push is on
its way. A load sync re-reads the opt-in inside the lock and stops when
it is gone. disable() tells the other tabs first, so their syncs and any
enable() in flight stop, then forgets the opt-ins and removes the
subscription inside the same lock. Without Web Locks the re-reads and
the message still apply.

* fix(puter-js): wait for a push confirmation only once the lock is held

A sync queued behind another tab's lock started its 30 second
confirmation timer and listener before it got the lock, so a long wait
failed the sync or left the timeout unhandled. The listener now starts
inside the locked step, right before the subscription is announced.

* test(puter-js): reach the push sync in the boot sign-in e2e

The push page now has the user's opt-in and cached user from an earlier
load, starts its sync before boot's own requests come back, and gets a
rejected token from the push routes, so the spec covers the key request
that must not open a sign-in.

* fix(puter-js): sign-out and disable() remove only Puter's push subscription

Removal now looks at the subscription's application server key and ends
it only when it is Puter's: the key stored at the last subscribe, or the
server's key when none is stored. Any other subscription on the site is
left as it is and the server is not told about it. Without a named
worker, as at sign-out or disable() without serviceWorker, every
registration on the origin is checked, so a subscription under a folder
worker is the one removed.

* fix(puter-js): sign-out and a user change end this browser's pushes

Sign-out stops this tab's syncs, tells the site's other tabs, and inside
the push lock forgets every opt-in and tap, ends Puter's subscription in
the browser, then sends the removal with the leaving user's token as a
keepalive request so it is still sent if the page reloads.
puter.auth.signOut() returns a promise that resolves once that is done;
the token is still discarded at once.

A different user signing in over a session, or after the token was
dropped, gets the same cleanup with the token of the user signed in
before. A new token for the same user, or a dropped token, keeps
everything.

A page load finding a Puter subscription with no opt-in for the
signed-in user, or with nobody signed in, ends it in the browser. Opt-in
records that can't be read leave it as it is.

* fix(puter-js): record a push opt-in before other tabs can check it

enable() now writes the user's opt-in inside the push lock, right after
the server has the subscription and before the lock is released. A tab
loading at the same time waits for the lock, reads the opt-in, and keeps
the new subscription. A failed announce, or push turned off while it
ran, records nothing.

* fix(puter-js): push cleanup runs without the lock where Web Locks refuse

In a frame where navigator.locks.request rejects, such as a sandboxed
frame, sign-out, disable(), the load-time check and syncs now run their
step without the lock instead of rejecting, so signOut() still resolves
and the subscription is still ended.

* fix(puter-js): send the sign-out removal again when keepalive is refused

A keepalive request that needs a CORS preflight is refused by some
browsers. The removal at sign-out is then sent once more as a normal
request with the same token and without a sign-in prompt, after the
browser has already ended the subscription.

* test(puter-js): run the push boot test on full Chromium and skip it without push support

The headless shell keeps Notification.permission at 'denied' after the
permission is granted, so onPush() never synced there and the test saw
no key request. The test now launches full Chromium itself, since a
channel can't be set per test, and is skipped on WebKit and wherever the
page has no PushManager or notification permission.

* fix(puter-js): a user change finishes its push cleanup before enable()

enable() waits for the cleanup a user change started. When the token
was dropped and a different user signs in from enable(), the pushes of
the user signed in before end first, and the new user keeps their new
opt-in and subscription. A token that sign-out already cleaned up is
not cleaned up again when the next user signs in.

* fix(puter-js): a tap without a URL opens the site the push came from

A page that calls onPush() or enable() saves where taps resolve for its
worker: the worker's folder when the page is inside it, else the site's
root. push-sw.js and the notifications a page shows itself read relative
URLs from it, and fall back to the worker's scope when nothing is saved.

* fix(puter-js): push taps go to top-level windows only

push-sw.js routes taps, and holds back a page's notification for a focused
window, among top-level windows only, as the GUI's worker does. Challenges
and subscription changes still reach every window of the origin.

* fix(puter-js): deliver a push tap to the page once

push-sw.js keeps a tap before posting it to an open page. The page runs
its click handler only after taking that copy out of IndexedDB in one
readwrite transaction, and acknowledges only then. When no
acknowledgement comes in time, the worker takes the copy itself and opens
the tap's URL only if the page hadn't. The wait starts at the post and
ends at most 800 ms after the click, so openWindow still runs in time.
Pages and workers without this keep today's behaviour.

* fix(puter-js): clear stored push taps that nobody reads

push-sw.js removes taps kept over five minutes on every push and tap, and
a page sweeps them once when onPush() syncs. A page opened for a tap
ignores a kept tap older than that, and removes it when no click handler
is added within 10 seconds of loading.

* fix(puter-js): a late page already at the tap's URL still gets the tap

When no acknowledgement comes in time and the window is already at the
tap's URL, push-sw.js leaves the copy it kept for that page instead of
taking it back, so the page's click handler still runs when it answers
late. A copy nobody takes is removed with the other old taps.

* fix(puter-js): a push tap runs at most once when IndexedDB fails

A page that can't take a kept tap out of IndexedDB leaves it to the
worker instead of running it, and a worker whose claim fails leaves it
to the page instead of opening the tap's URL.

* fix(puter-js): take a tap back and keep it for the next page in one step

When no acknowledgement comes in time, push-sw.js takes its copy back and
keeps it, without the id, for the page it opens in one readwrite
transaction, leaving more of Firefox's window-opening allowance for
openWindow.

* test(puter-js): shorten the fake IndexedDB comment

* test(puter-js): keep the push boot test comment to one line

* fix(puter-js): a user signing back in with the same token gets the user-change cleanup again

The token that sign-out already cleaned up is skipped only for the next
user change. Once the same token is set again, a later sign-in by a
different user ends its pushes as usual.

* fix(static): push-sw.js refreshes at the CDN within five minutes

* feat(puter-js): the push worker can skip saved handlers

* fix(puter-js): off() keeps a saved push handler another tab still uses

* docs(notifications): self-hosting the push worker

* docs(notifications): name the pinned push worker version as a placeholder

* fix(puter-js): pages with push handlers can enter the back/forward cache

* test(puter-js): a restored page saves its push handler again

* docs(notifications): pin the push worker only with a pinned puter.js
2026-11-04 10:15:36 -05:00
Juan Fernando Castro 848e336455 fix(puter-js): page loads check push status instead of registering again (#4480)
* fix(puter-js): background push syncs never prompt for sign-in

The load-time sync and the resync after the browser replaces a
subscription ask for the key and announce the subscription without
opening Puter's sign-in; only enable() may. The confirmation and the
removal never open it, and looking up the user for the opt-in uses a
non-interactive /whoami. A rejected token during a background sync is
not reported as a sync failure.

* fix(puter-js): turning push off in one tab stops other tabs' syncs

Tabs of a site take a shared Web Lock to subscribe and announce, and
again to confirm, without holding it while the confirmation push is on
its way. A load sync re-reads the opt-in inside the lock and stops when
it is gone. disable() tells the other tabs first, so their syncs and any
enable() in flight stop, then forgets the opt-ins and removes the
subscription inside the same lock. Without Web Locks the re-reads and
the message still apply.

* fix(puter-js): wait for a push confirmation only once the lock is held

A sync queued behind another tab's lock started its 30 second
confirmation timer and listener before it got the lock, so a long wait
failed the sync or left the timeout unhandled. The listener now starts
inside the locked step, right before the subscription is announced.

* test(puter-js): reach the push sync in the boot sign-in e2e

The push page now has the user's opt-in and cached user from an earlier
load, starts its sync before boot's own requests come back, and gets a
rejected token from the push routes, so the spec covers the key request
that must not open a sign-in.

* fix(puter-js): sign-out and disable() remove only Puter's push subscription

Removal now looks at the subscription's application server key and ends
it only when it is Puter's: the key stored at the last subscribe, or the
server's key when none is stored. Any other subscription on the site is
left as it is and the server is not told about it. Without a named
worker, as at sign-out or disable() without serviceWorker, every
registration on the origin is checked, so a subscription under a folder
worker is the one removed.

* fix(puter-js): sign-out and a user change end this browser's pushes

Sign-out stops this tab's syncs, tells the site's other tabs, and inside
the push lock forgets every opt-in and tap, ends Puter's subscription in
the browser, then sends the removal with the leaving user's token as a
keepalive request so it is still sent if the page reloads.
puter.auth.signOut() returns a promise that resolves once that is done;
the token is still discarded at once.

A different user signing in over a session, or after the token was
dropped, gets the same cleanup with the token of the user signed in
before. A new token for the same user, or a dropped token, keeps
everything.

A page load finding a Puter subscription with no opt-in for the
signed-in user, or with nobody signed in, ends it in the browser. Opt-in
records that can't be read leave it as it is.

* fix(puter-js): record a push opt-in before other tabs can check it

enable() now writes the user's opt-in inside the push lock, right after
the server has the subscription and before the lock is released. A tab
loading at the same time waits for the lock, reads the opt-in, and keeps
the new subscription. A failed announce, or push turned off while it
ran, records nothing.

* fix(puter-js): push cleanup runs without the lock where Web Locks refuse

In a frame where navigator.locks.request rejects, such as a sandboxed
frame, sign-out, disable(), the load-time check and syncs now run their
step without the lock instead of rejecting, so signOut() still resolves
and the subscription is still ended.

* fix(puter-js): send the sign-out removal again when keepalive is refused

A keepalive request that needs a CORS preflight is refused by some
browsers. The removal at sign-out is then sent once more as a normal
request with the same token and without a sign-in prompt, after the
browser has already ended the subscription.

* test(puter-js): run the push boot test on full Chromium and skip it without push support

The headless shell keeps Notification.permission at 'denied' after the
permission is granted, so onPush() never synced there and the test saw
no key request. The test now launches full Chromium itself, since a
channel can't be set per test, and is skipped on WebKit and wherever the
page has no PushManager or notification permission.

* fix(puter-js): a user change finishes its push cleanup before enable()

enable() waits for the cleanup a user change started. When the token
was dropped and a different user signs in from enable(), the pushes of
the user signed in before end first, and the new user keeps their new
opt-in and subscription. A token that sign-out already cleaned up is
not cleaned up again when the next user signs in.

* fix(puter-js): a tap without a URL opens the site the push came from

A page that calls onPush() or enable() saves where taps resolve for its
worker: the worker's folder when the page is inside it, else the site's
root. push-sw.js and the notifications a page shows itself read relative
URLs from it, and fall back to the worker's scope when nothing is saved.

* fix(puter-js): push taps go to top-level windows only

push-sw.js routes taps, and holds back a page's notification for a focused
window, among top-level windows only, as the GUI's worker does. Challenges
and subscription changes still reach every window of the origin.

* fix(puter-js): deliver a push tap to the page once

push-sw.js keeps a tap before posting it to an open page. The page runs
its click handler only after taking that copy out of IndexedDB in one
readwrite transaction, and acknowledges only then. When no
acknowledgement comes in time, the worker takes the copy itself and opens
the tap's URL only if the page hadn't. The wait starts at the post and
ends at most 800 ms after the click, so openWindow still runs in time.
Pages and workers without this keep today's behaviour.

* fix(puter-js): clear stored push taps that nobody reads

push-sw.js removes taps kept over five minutes on every push and tap, and
a page sweeps them once when onPush() syncs. A page opened for a tap
ignores a kept tap older than that, and removes it when no click handler
is added within 10 seconds of loading.

* fix(puter-js): a late page already at the tap's URL still gets the tap

When no acknowledgement comes in time and the window is already at the
tap's URL, push-sw.js leaves the copy it kept for that page instead of
taking it back, so the page's click handler still runs when it answers
late. A copy nobody takes is removed with the other old taps.

* fix(puter-js): a push tap runs at most once when IndexedDB fails

A page that can't take a kept tap out of IndexedDB leaves it to the
worker instead of running it, and a worker whose claim fails leaves it
to the page instead of opening the tap's URL.

* fix(puter-js): take a tap back and keep it for the next page in one step

When no acknowledgement comes in time, push-sw.js takes its copy back and
keeps it, without the id, for the page it opens in one readwrite
transaction, leaving more of Firefox's window-opening allowance for
openWindow.

* test(puter-js): shorten the fake IndexedDB comment

* test(puter-js): keep the push boot test comment to one line

* fix(puter-js): a user signing back in with the same token gets the user-change cleanup again

The token that sign-out already cleaned up is skipped only for the next
user change. Once the same token is set again, a later sign-in by a
different user ends its pushes as usual.

* fix(puter-js): a page load checks push status instead of registering again

* fix(puter-js): subscriptions made with an earlier Puter key are replaced

* fix(puter-js): a replaced browser subscription is picked up on the next visit

* fix(puter-js): a page load that only checks push status still looks for a newer worker

* fix(puter-js): the worker subscribes again only where a Puter page used it

* docs(puter-js): soften the note on subscriptions Puter made earlier

* fix(puter-js): a status answer on load leaves a subscription another tab just turned on
2026-11-04 10:15:25 -05:00
Juan Fernando Castro a9a8df6409 feat(events): fetch can start at the newest notification, and the feed uses it (#4479)
* feat(events): fetch can start from the newest event

GET /events/fetch takes from=end: with no cursor it answers an empty page
whose cursor sits at the newest row of the slice, so later reads return
only rows created or replaced after it. puter.events.fetch() passes
`from` through. Adds a (user_id, audience, app_uid, id) index on
notification so the newest row of a slice is one seek.

* fix(puter-js): onPush starts its feed from the newest notification

A notifications listener started without catch-up asks for from: 'end'
and keeps the cursor it gets back instead of reading every stored
notification to mark them as seen. A server that ignores `from` answers
with the oldest page, and the listener reads through it as before.

* fix(events): read the newest notification id from the primary

* fix(events): mark the end cursor at the slice's latest replacement

The end cursor's replacement mark is the newest (replaced_at, id) in the
slice rather than the serving node's clock, since replaced_at is stamped
by whichever node wrote it.

* docs(events): describe the end cursor's replacement mark
2026-11-04 10:15:14 -05:00
Daniel Salazar 23782dec12 feat(events): inline handlers in the user's own scope for onSchedule and onPersistent (#4487)
release-please / release-please (push) Canceled after 0s
Backend Tests / changes (push) Canceled after 0s
Backend Tests / typecheck (push) Canceled after 0s
Backend Tests / test shard 1/4 (push) Canceled after 0s
Backend Tests / test shard 2/4 (push) Canceled after 0s
Backend Tests / test shard 3/4 (push) Canceled after 0s
Backend Tests / test shard 4/4 (push) Canceled after 0s
Backend Tests / test (main) (push) Canceled after 0s
Backend Tests / coverage (push) Canceled after 0s
Maintain Release Merge PR / update-release-pr (push) Canceled after 0s
Notify HeyPuter / notify (push) Canceled after 0s
* feat(events): schema for user-scope handlers

Add event_user_handlers and event_subscriptions.handler_scope in all three
dialects. event_handlers is unchanged.

* feat(events): scope the handler and durable subscription stores

Handler store methods take a scope (app, or a user's scope of an app) and read
the matching table. App-scope behavior, SQL and cache keys are unchanged; user
scopes get their own cache keys, a 20-handler cap, and the inline @<sha256>
name. Adds user-scope listing, counting, deletion and unbound-handler reads.
Durable subscriptions store and return handlerScope, and listByHandler
filters by scope and holder.

* fix(events): hold the user-scope handler cap under concurrency and drop all scope caches on app delete

A create that lands past the cap removes itself and is refused. deleteForApp
drops each holder's cached set in batches, here and on peers. Batch scans over
user scopes and unbound handlers take limit and cursor.

* feat(events): set a CPU limit on events worker deploys

* test(events): cover redeploy and resend of an evicted events script

* test(events): name the deploy-on-miss test for what it checks

* fix(events): keep a deploy-on-miss call inside the delivery lease

* feat(workers): cap rented units per plan and refuse only new ones

* feat(events): publish handlers into a user scope, with inline create

* test(events): cover user-scope publish, inline create and listing

* test(workers): name the credentials gate without a vendor

* feat(events): run user-scope handlers as the holder

A user scope deploys one script per handler under the holder's own
identity, throttled per scope, with an eager deploy after publish that
skips an upload another holder already made. Delivery and schedule runs
address the holder's handler, carry the holder on the invoke headers,
batch calls, the miss handler and the rehydrate route, and stay owed
while the flag is off. Suspensions of a user scope notify the holder,
teardown (app delete, consent revoke, account delete) drops the scope,
and a job deletes unbound handlers after a day.

* test(events): cover user-scope deploy, address, notices, teardown and the sweep

Runs an inline user-scope handler through the local worker runtime as its
holder, and covers the holder on the wire, the flag-off path, revoke and
account-delete teardown, and the unbound-handler sweep.

* test(events): a lagging replica read does not suspend a user-scope schedule run

* feat(puter-js): inline user-scope handlers and scope option for handlers and workers

* docs(events): document inline and user-scope handlers, with playground examples

Cover inline handlers on onSchedule and onPersistent, handlerScope, the
scope option on handlers and workers, the user-scope limits and rented
units, and the new error codes. Add six playground examples and pin the
new limit constants in limits.test.ts.

* feat(schedules): carry the handler scope on the schedule view

* docs(events): describe user-handler cleanup, schedule handlerScope and suspension notices

* fix(events): gate the rented-unit cap on user handlers and let an account session destroy its own user scope

* fix(events): keep user-scope rows off app handlers across mixed versions and bound their deploys and reads

Cached durable rows carry an explicit scope marker; an unmarked entry with a
handler is resolved from the table and rewritten. User-scope rows are parked
before credit checks, billing, client delivery and suspension while the flag
is off. Delivery-time handler checks and addressing share the replica cache
entry. Concurrent eager deploys of one user-scope script upload once.

* fix(events): keep a user scope's age across handler churn for rent

event_user_handlers.scope_since records when the scope first held a handler.
A new row takes the scope's existing minimum, so replacing code does not make
the scope look newer to the monthly sweep. The count uses it instead of the
oldest remaining handler's created_at.

* fix(events): drop cached entries no row backs; document account-session scope destroy

* test(jobs): list the user-handler sweep among the system jobs

* fix(events): let the user-handlers migration apply on its own

* fix(events): fail a cached-row read whose scope lookup fails instead of returning app-scope rows

* fix(events): touch a reused user-scope handler before binding so the sweep cannot take it

* fix(events): bound the deploy-on-miss wait by the call's deadline
2026-11-03 23:26:04 -07:00
Juan Fernando Castro 9bd7ba7079 fix(puter-js): push taps open the right page, once (#4472)
* fix(puter-js): a tap without a URL opens the site the push came from

A page that calls onPush() or enable() saves where taps resolve for its
worker: the worker's folder when the page is inside it, else the site's
root. push-sw.js and the notifications a page shows itself read relative
URLs from it, and fall back to the worker's scope when nothing is saved.

* fix(puter-js): push taps go to top-level windows only

push-sw.js routes taps, and holds back a page's notification for a focused
window, among top-level windows only, as the GUI's worker does. Challenges
and subscription changes still reach every window of the origin.

* fix(puter-js): deliver a push tap to the page once

push-sw.js keeps a tap before posting it to an open page. The page runs
its click handler only after taking that copy out of IndexedDB in one
readwrite transaction, and acknowledges only then. When no
acknowledgement comes in time, the worker takes the copy itself and opens
the tap's URL only if the page hadn't. The wait starts at the post and
ends at most 800 ms after the click, so openWindow still runs in time.
Pages and workers without this keep today's behaviour.

* fix(puter-js): clear stored push taps that nobody reads

push-sw.js removes taps kept over five minutes on every push and tap, and
a page sweeps them once when onPush() syncs. A page opened for a tap
ignores a kept tap older than that, and removes it when no click handler
is added within 10 seconds of loading.

* fix(puter-js): a late page already at the tap's URL still gets the tap

When no acknowledgement comes in time and the window is already at the
tap's URL, push-sw.js leaves the copy it kept for that page instead of
taking it back, so the page's click handler still runs when it answers
late. A copy nobody takes is removed with the other old taps.

* fix(puter-js): a push tap runs at most once when IndexedDB fails

A page that can't take a kept tap out of IndexedDB leaves it to the
worker instead of running it, and a worker whose claim fails leaves it
to the page instead of opening the tap's URL.

* fix(puter-js): take a tap back and keep it for the next page in one step

When no acknowledgement comes in time, push-sw.js takes its copy back and
keeps it, without the id, for the page it opens in one readwrite
transaction, leaving more of Firefox's window-opening allowance for
openWindow.

* test(puter-js): shorten the fake IndexedDB comment
2026-11-03 10:06:16 -05:00
Juan Fernando Castro 8f3d4cc40f fix(puter-js): sign-out ends Puter's push subscription in this browser (#4471)
* fix(puter-js): sign-out and disable() remove only Puter's push subscription

Removal now looks at the subscription's application server key and ends
it only when it is Puter's: the key stored at the last subscribe, or the
server's key when none is stored. Any other subscription on the site is
left as it is and the server is not told about it. Without a named
worker, as at sign-out or disable() without serviceWorker, every
registration on the origin is checked, so a subscription under a folder
worker is the one removed.

* fix(puter-js): sign-out and a user change end this browser's pushes

Sign-out stops this tab's syncs, tells the site's other tabs, and inside
the push lock forgets every opt-in and tap, ends Puter's subscription in
the browser, then sends the removal with the leaving user's token as a
keepalive request so it is still sent if the page reloads.
puter.auth.signOut() returns a promise that resolves once that is done;
the token is still discarded at once.

A different user signing in over a session, or after the token was
dropped, gets the same cleanup with the token of the user signed in
before. A new token for the same user, or a dropped token, keeps
everything.

A page load finding a Puter subscription with no opt-in for the
signed-in user, or with nobody signed in, ends it in the browser. Opt-in
records that can't be read leave it as it is.

* fix(puter-js): record a push opt-in before other tabs can check it

enable() now writes the user's opt-in inside the push lock, right after
the server has the subscription and before the lock is released. A tab
loading at the same time waits for the lock, reads the opt-in, and keeps
the new subscription. A failed announce, or push turned off while it
ran, records nothing.

* fix(puter-js): push cleanup runs without the lock where Web Locks refuse

In a frame where navigator.locks.request rejects, such as a sandboxed
frame, sign-out, disable(), the load-time check and syncs now run their
step without the lock instead of rejecting, so signOut() still resolves
and the subscription is still ended.

* fix(puter-js): send the sign-out removal again when keepalive is refused

A keepalive request that needs a CORS preflight is refused by some
browsers. The removal at sign-out is then sent once more as a normal
request with the same token and without a sign-in prompt, after the
browser has already ended the subscription.

* fix(puter-js): a user change finishes its push cleanup before enable()

enable() waits for the cleanup a user change started. When the token
was dropped and a different user signs in from enable(), the pushes of
the user signed in before end first, and the new user keeps their new
opt-in and subscription. A token that sign-out already cleaned up is
not cleaned up again when the next user signs in.

* fix(puter-js): a user signing back in with the same token gets the user-change cleanup again

The token that sign-out already cleaned up is skipped only for the next
user change. Once the same token is set again, a later sign-in by a
different user ends its pushes as usual.
2026-11-03 10:06:15 -05:00
sabrimete cdb3110857 fix(ui): honor the default font in showFontPicker on the desktop (#4340)
Backend Tests / changes (push) Canceled after 0s
Backend Tests / typecheck (push) Canceled after 0s
Backend Tests / test shard 1/4 (push) Canceled after 0s
Backend Tests / test shard 2/4 (push) Canceled after 0s
Backend Tests / test shard 3/4 (push) Canceled after 0s
Backend Tests / test shard 4/4 (push) Canceled after 0s
Backend Tests / test (main) (push) Canceled after 0s
Backend Tests / coverage (push) Canceled after 0s
release-please / release-please (push) Canceled after 0s
Notify HeyPuter / notify (push) Canceled after 0s
Maintain Release Merge PR / update-release-pr (push) Canceled after 0s
* fix(ui): honor the default font in showFontPicker on the desktop

String arguments are normalized to { defaultFont } in the IPC handler,
matching showColorPicker, instead of throwing on property assignment.
UIWindowFontPicker reads defaultValue/defaultFont/default case-
insensitively, and the SDK fallback and docs accept the same aliases.

* fix(ui): document the showFontPicker cancel result and alias order

The JSDoc gains overloads for the string and options call forms and
states that closing the picker resolves false in an app and null on a
website; the docs page and examples match. e2e coverage adds a
scroll-into-view check for the preselected font and a close-without-
selecting case.
2026-11-03 00:07:05 -07:00
Daniel Salazar d934a47c5e feat: jobs and schedules (#4467)
* feat(db): add nowMsExpr, the database clock in epoch milliseconds, and share the retry backoff

* feat(config): type the jobs config as an augmentable IJobsConfig and resolve its namespace in one place

* feat(jobs): add JobScheduler for fleet- and region-wide system jobs

* refactor(jobs): run core's periodic sweeps as system jobs, and publish the generation bumps of reaped subscriptions

* feat(events): seams for schedules run by an extension, with events_disabled where none is installed

* feat(notifications): add notification.send_key, the deferred mailbox write and notify({ sendAt }) through the schedules contract

* feat(puter.js): schedules, notify({ sendAt }) and notifications.cancel(), and treat a 503 events_disabled as final

* docs(events): document schedules, sendAt and cancel(), with recipes

* fix(events): stop resuming a holder's later pages once the credit sweep's signal aborts, and keep its checkpoint

* fix(events): pre-release fixes for jobs, schedule seams and delayed sends

- The delayed-send write is a field on the notifications driver, not a
  method, so /drivers/call can't reach it.
- A delayed send checks for its own earlier write by uid, so it takes no
  gap lock that every other notification insert waits on.
- The notification send_key index builds in place without locking writes.
- Reaps re-check expiry and suspension on the primary, so a row resumed
  since the replica read is kept.
- Revocation settles skip schedule rows they can't settle.
- An empty handler set read from a replica is re-read on the primary.
- One ended notice per settle, not one per page.
- Sweeps that run once per fleet take more batches per run.
- The global usage rate check keeps its 14-minute minimum window.
- shutdown() waits for prepare hooks a drain already started.

* fix(puter.js): quota refusals are final, and schedule error codes match the server

Schedule, subscription and handler quota 429s no longer go through the
generic retry. ScheduleErrorCode lists what the schedule methods throw,
and the notify({ sendAt }) overload has type tests.

* docs(events): schedule and delayed-send accuracy and style pass

Spread and send-time wording match the runner, the per-user runs-at-once
and rate-limited stored notification limits are published, and the new
pages follow the docs writing rules.

* docs(events): add the Change Data at a Set Time recipe

A one-time schedule that changes data when it runs, cancelling and moving
it, and a repeat-a-few-times variant with maxRuns.

* fix(events): delayed sends recheck the app's access when they fire, and a handler set missing the run's handler is re-read on the primary

Also stops job polling at drain start and marks replayable notification sends.

* feat(events): batch handler invocations to the events dispatcher

Invocations that come due within a few milliseconds go out as one
/invoke-batch request (up to events.invokeBatchSize, default 60). The
dispatcher runs a few at a time and streams a started line and an answer line
per call, so a slow handler delays no other call's answer. A call's timeout
runs from its started line, so time queued behind other calls is never the
handler's; queue time comes out of the connection-wait share instead, and a
call still queued past it is never started and fails as Puter's.

A lone call still uses /invoke, and a dispatcher without the batch route gets
single calls for five minutes before the batch route is tried again.
2026-11-02 23:48:35 -07:00
404oopsandClaude Fable 5.1 0128e8779d fix(ai): route per-breakpoint cache lifetimes through the Responses API
Chat Completions rejects `prompt_cache_breakpoint.ttl` whatever its
value, so a request whose content parts name a breakpoint lifetime is
handed to the Responses provider like a request-level `ttl: '12h'`. The
`/openai/v1/responses` translator keeps `prompt_cache_breakpoint` on
text, image and file parts instead of dropping it with the other unknown
fields.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-03 00:58:29 +01:00
404oopsandClaude Fable 5.1 89f070f7d0 fix(ai): meter OpenAI cache writes by lifetime
GPT-5.6 and later write prompt-cache entries with a 30m or 12h lifetime
and bill them at 1.25x and 2.5x input; every write was metered at the
30m rate. The OpenAI calculators now split `cache_write_tokens_details`
into `cache_write_tokens` (30m) and `cache_write_12h_tokens` (12h), the
GPT-5.6+ catalog carries the 12h rate, and `usageDetails` reports
`cacheWrite30mTokens` / `cacheWrite12hTokens` instead of labelling the
writes as 5m entries.

Callers pick the lifetime with `prompt_cache_options` ({ mode, ttl }) on
`puter.ai.chat`, `/openai/v1/responses` and `/openai/v1/chat/completions`.
The Chat Completions provider hands a 12h request to the Responses
provider, the only API that writes 12h entries. `prompt_cache_retention`
is forwarded on Chat Completions too, and the hyphenated `in-memory`
spelling maps to the `in_memory` value the upstreams accept.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-11-03 00:52:33 +01:00
abb70c14a6 docs: complex fs recipe (PUT-1978) (#4093)
release-please / release-please (push) Canceled after 0s
Backend Tests / changes (push) Canceled after 0s
Backend Tests / typecheck (push) Canceled after 0s
Backend Tests / test shard 1/4 (push) Canceled after 0s
Backend Tests / test shard 2/4 (push) Canceled after 0s
Backend Tests / test shard 3/4 (push) Canceled after 0s
Backend Tests / test shard 4/4 (push) Canceled after 0s
Backend Tests / test (main) (push) Canceled after 0s
Backend Tests / coverage (push) Canceled after 0s
Maintain Release Merge PR / update-release-pr (push) Canceled after 0s
Notify HeyPuter / notify (push) Canceled after 0s
* docs: complex fs recipe (PUT-1978)

A drop folder that reacts to files landing in it, using `puter.events` with an
`fs:` subject instead of polling `readdir`.

One use case per the recipe guidance: watch, ignore your own writes, recover
from a gap, stop watching. The gap section works out what is already done from
the folder itself rather than a counter, so a dropped delivery costs a listing
and never a file.

* docs: rework fs recipe into a CSV importer (PUT-1978)

Renames the recipe to "Import Files From a Folder" and reframes it around
importing CSV files with puter.fs and onLocal(). Watches the app's own
inbox folder, imports files moved in from another folder, and catches up
on open and on gaps. Drops the event.self check, which skipped every file
the user added.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* refine 2

---------

Co-authored-by: Reynaldi Chernando <reynaldichernando@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-03 03:05:34 +07:00
Daniel Salazar efc0ab7126 docs: refresh KV sharing, Events and Notifications docs; fix router CORS and KV size error (#4445)
release-please / release-please (push) Canceled after 0s
Backend Tests / changes (push) Canceled after 0s
Backend Tests / typecheck (push) Canceled after 0s
Backend Tests / test shard 1/4 (push) Canceled after 0s
Backend Tests / test shard 2/4 (push) Canceled after 0s
Backend Tests / test shard 3/4 (push) Canceled after 0s
Backend Tests / test shard 4/4 (push) Canceled after 0s
Backend Tests / test (main) (push) Canceled after 0s
Backend Tests / coverage (push) Canceled after 0s
Maintain Release Merge PR / update-release-pr (push) Canceled after 0s
Notify HeyPuter / notify (push) Canceled after 0s
* 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 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)
Maintain Release Merge PR / update-release-pr (push) Canceled after 0s
Notify HeyPuter / notify (push) Canceled after 0s
Backend Tests / changes (push) Canceled after 0s
Backend Tests / typecheck (push) Canceled after 0s
Backend Tests / test shard 1/4 (push) Canceled after 0s
Backend Tests / test shard 2/4 (push) Canceled after 0s
Backend Tests / test shard 3/4 (push) Canceled after 0s
Backend Tests / test shard 4/4 (push) Canceled after 0s
Backend Tests / test (main) (push) Canceled after 0s
Backend Tests / coverage (push) Canceled after 0s
release-please / release-please (push) Canceled after 0s
* 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)
Backend Tests / changes (push) Canceled after 0s
Backend Tests / typecheck (push) Canceled after 0s
Backend Tests / test shard 1/4 (push) Canceled after 0s
Backend Tests / test shard 2/4 (push) Canceled after 0s
Backend Tests / test shard 3/4 (push) Canceled after 0s
Backend Tests / test shard 4/4 (push) Canceled after 0s
Backend Tests / test (main) (push) Canceled after 0s
Backend Tests / coverage (push) Canceled after 0s
Maintain Release Merge PR / update-release-pr (push) Canceled after 0s
Notify HeyPuter / notify (push) Canceled after 0s
release-please / release-please (push) Canceled after 0s
* 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 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
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 <daniel.salazar@puter.com>
2026-10-30 20:52:00 -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
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
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