mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-14 18:01:21 +00:00
b27e84d1810e6401a934fd564756ea83f4bf6421
35
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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.
|
||
|
|
0a8b37ae56 |
fix(notifications): website apps are named by their hostname (#4468)
An app created for an origin keeps its uid as name and title, so its notifications showed that uid as the sender. appMessageSender now names such an app by the hostname of its index_url, and keeps the stored name when index_url doesn't parse. The check AppDriver used for these rows moves to util/appView so both share it. The Settings app list shows a generic label for an app whose title is still its uid. |
||
|
|
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 |
||
|
|
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.
|
||
|
|
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).
|
||
|
|
8d3a1ccc2b |
feat(notifications): remove a notification from the dashboard history (#4412)
Each notification in the dashboard list gets a remove (✕) control next to mark-as-read. It calls the new POST /notif/delete, which deletes the user's own row and sends `notif.removed` to their other tabs so the entry and any toast for it disappear everywhere. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Daniel Salazar <daniel.salazar@puter.com> |
||
|
|
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 |
||
|
|
7401fe0b09 |
fix(notifications): receipts report what the user saw, sticky push opt-in, safer app pushes (#4413)
* fix(socket): count sockets that never acknowledge instead of asking them
askWithAck sent an acked broadcast to sockets outside the ack room only to
learn whether any were there. Those sockets never answer, so every node held
the ack callback until they disconnected. Each node now answers a server-side
count of them instead; a node that does not answer in time still reads as
holding one.
* feat(notifications): read focus and own display from receipt answers
A receipt answer may carry focused and showsOwn beside visibilityState; both
pass through to notif.delivery.settled when they are booleans. An answer with
received: false is not a responder, and an ask answered only that way settles
as unreceived.
* fix(notifications): name an app message's bad field before the size cap
An app message over the 16 KB value cap answered notification_value_too_large
even when one field was what broke a rule of its own. The fields are checked
first now, so the caller gets bad_request naming the field; the total cap still
applies after. Character counts skip the code point walk on strings that can't
fit.
* fix(push-sw): match the server's tags, keep taps, and show pages' notifications
- A push is tagged with the server's group, `<app uid>:<group>` or the uid, and
alerts again when that replaces one showing. Handlers get the app's own group
and the send time in ms from `ts`.
- The app's icon stands in when the notification has none.
- Default and relative tap URLs resolve against the worker's scope, still only
on its origin.
- A tap posted to an open window waits briefly for the page to take it; without
a click handler there, the window is taken to the URL, or one is opened.
- A page that got a notification live while away can ask the worker to show it;
the worker holds it back while another window of the origin has focus.
* fix(notifications): answer every receipt ask
A page now answers each socket that asks about a notification, not only the
first, and answers { received: false } when the notification doesn't arrive
within the wait or the page holds too many asks, so the server's nodes can
let go of them. A received answer carries focused and showsOwn beside
visibilityState.
* fix(notifications): keep web push to users who turned it on, and show what a push would
Subscription:
- onPush() renews the subscription on load only for a user who turned push on
on this origin with enable(); enable() records that, and disable(), sign-out
and push_revoked forget it. One renewal runs per page and worker, however many
onPush() calls there are, and push_disabled, push_revoked, push_unavailable
and too_many_requests stay quiet there.
- puter.auth.signOut() on a website removes this browser's subscription with
the leaving user's token and forgets the opt-in and kept taps, without
holding up the sign-out.
- A subscription another service on the site made is left alone and rejected
as push_subscription_conflict; one made with Puter's earlier key is replaced.
- A subscription refused with push_endpoint_revoked is replaced once, like
push_subscription_invalid.
- Another worker holding the scope rejects push_worker_conflict, naming
importScripts and the serviceWorker option, instead of waiting out the
confirmation push.
- enable() rejects user_gesture_required outside a click instead of prompting,
and a refused browser subscribe rejects push_subscribe_failed.
Live side:
- A page that is hidden or unfocused shows every app notification it receives,
with or without a handler for its channel, through push-sw.js so a focused
window of the app holds it back. It uses the server's tag, the app's icon as
a fallback, the generic app line for sensitive ones, and the registration of
its own worker.
- Page handlers get 5 seconds, as in the worker (push_handler_timeout).
- A feed stopped by a sign-out starts again on the next sign-in.
- onPush() still delivers live when IndexedDB is unavailable.
- onPush() rejects invalid_handler for a handler the worker could not rebuild
from its source (methods, bound and built-in functions).
- Saving and removing a handler's source for the worker is ordered per key, so
a quick unmount and remount keeps it.
- A tap posted by push-sw.js is acknowledged only when a click handler takes it.
* fix(gui): show app pushes under the app's name and Puter's icon
- On Puter's own origin, an app's push takes the app's name as its title, with
the app's title and text in the body, and Puter's copy of the app's icon; an
icon the app chose is never shown. A sensitive one keeps its generic line.
- A push's ts is its timestamp, and a newer push in a group alerts again.
- Re-registering this browser counts only apps that push to Puter's devices
(puterDevices), so a website's Web Push confirmation alone never registers
it.
* fix(gui): free the push dialog's button when registering fails late
A registration that failed after the dialog's deadline left the button
spinning and disabled. It now clears the attempt, settles on a state the
browser moved to meanwhile, or offers a retry.
* fix(permission): a user's own app grant clears a narrower via on the row
An app grant that already existed was skipped, so a row another path recorded
with extra.via kept it after the user approved the same permission in Puter.
A grant without via now rewrites such a row without it, keeping its other
extras; a grant with via still never touches an existing row.
* refactor(notifications): export the notification text cleaner
Extensions that word notifications can use the same cleaning as app messages.
* docs(notifications): describe when pushes go out, taps, worker rules and enable() errors
Pushes go out when no open page answers that it's visible and focused or
shows the notification itself. Documents the page-shown notification, the
sticky opt-in, tap routing, group and sensitive behavior, service worker
rules, requirements, every enable() and onPush() error, push caps for
unlisted apps and websites, and sending from workers and events handlers.
The push playground example is removed (the playground can't serve the
site's service worker) and redirects to the guide. The minimal page catches
enable() errors and sends after 5 seconds. The recipe no longer sends on
every load, and app.message is listed in events fetch and the catch-up
recipe.
* docs(notifications): per-app browser caps and when push_endpoint_revoked appears
* docs(notifications): the page's handler timeout
* fix(notifications): leave a site's own service worker alone when enabling pushes
Registering /puter-sw.js at a scope another worker holds queued it behind that worker, so enable() rejected with push_worker_conflict but swapped the site's worker out once its tabs closed. Now enable() checks the scope first and rejects without registering.
* docs(notifications): a background page shows score alerts itself
|
||
|
|
c00f693ec6 |
feat(notifications): report how each live notification was received (#4403)
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
* feat(notifications): report how each live notif.message was received SocketService.sendWithAck sends an event to rooms or sockets and waits, up to a timeout, for an acknowledgement from each socket that listed the event in its handshake (`auth.acks`); every other socket gets a plain emit. It lists sockets across the cluster with fetchSockets and asks the listed ones through the adapter's broadcast acks, and counts this node alone when the adapter cannot list the others. The emitting region now sends notif.message through it, with the window from `notificationAckTimeoutMs` (default 5000), and emits the node-local `notif.delivery.settled` with the outcome: received, unreceived, no-socket or remote-only (sockets only in a peer region), whether any socket there acknowledges, and each responder's visibilityState. Peer regions keep sending their own copy as before; the GUI event carries `region_delivered` so this region does not send it twice. The desktop and dashboard list notif.message in their handshake and answer its callback once with document.visibilityState. Nothing in core acts on the settled event. * fix(notifications): send live notif.message at once and report what the count covers sendWithAck now emits to the whole room straight away as a broadcast with ack, and lists the room's sockets alongside it only to fill in the counts, so nobody waits on the listing. Whether the adapter can count the cluster's servers is checked once; where it cannot, sends go out per socket on this node with a one-time warning. The result carries `complete`: false when only this node's sockets were counted, either for that reason or because the listing failed or took over 1 s, which is warned about at most once a minute. notif.delivery.settled gains `sendId`, `type`, `complete` and `peerRegions`. An incomplete count with no socket found settles as unreceived rather than no-socket, and the peer-region read runs alongside the send instead of after it. notif.written carries the same `sendId`, minted per send (an update gets a new one) and passed through notif.created to the socket adapter on the fold-in path. The comment on revoking sockets no longer says fetchSockets is unavailable with our Redis client; it works with ioredis cluster. * fix(notifications): keep a failed server count from ending the process socket.io awaits the adapter's serverCount without a catch on every broadcast with an ack, and the redis-streams adapter's count rejects as soon as one cluster node fails to answer. The count on the root namespace's adapter now never rejects: a failure reports the last good count (1 before any), and is warned about at most once a minute. A stale count only lets an ack wait run to its timeout. Whether sendWithAck takes the cluster path is no longer a one-shot check awaited before the emit. Until a count has succeeded, sends go out per socket on this node at once while a count is retried in the background, at most every 30 s (SocketService.COUNT_RETRY_MS); the first success switches to the cluster path for good. * fix(notifications): ask only acknowledging sockets, through an ack room A socket whose handshake lists an event in `acks` now joins `<room>:acks:<event>` for each of its rooms and its own id. sendWithAck sends the ordinary broadcast to the target rooms except those ack rooms, which keeps it on the adapter's stream, and a broadcast with an ack only to the ack rooms. Sockets that never acknowledge are no longer asked, so other nodes hold no pending ack for them and a send completes as soon as every acknowledging socket answered. The per-socket listing that matched socket.data.acks is gone; the ack-capable count comes from the room membership the listing already returns. The per-socket path stays only for a node whose adapter has not counted servers yet. A failed server count during a send now marks its counts incomplete, so a stale count can't make a listing that missed a node read as complete. With no count ever succeeded (an in-memory Redis), the failure is warned once per process instead of once a minute. * fix(notifications): say whether every socket on a live notification acknowledges it A tab on an older bundle beside one that acknowledges made the settle read as ack-capable, so a listener could not tell that the older tab may have shown the notification. notif.delivery.settled now carries everySocketAcks. * feat(notifications): app pages acknowledge the live copy of an app message NotificationService.appReceipt() asks an app's own sockets whether a notification reached a page, and settles it as notif.delivery.settled with appUid, the same outcomes as the desktop's. Callers ask only when the answer decides something, since it is a round trip to every node. - SocketService.askWithAck asks without listing sockets: sockets that don't acknowledge get an acked broadcast too, which can only time out, and that says whether an older client shares the target. - puter.js declares notif.receipt on its filesystem and events sockets. A page answers once one of its notification feeds received that uid, before any handler runs, and never when no feed on the page got it. - Docs describe the push route as "no page of the app received it live within 5 seconds". * fix(notifications): count a socket that acknowledges other events as a current client Every desktop page also holds puter.js's own socket, which never shows notif.message, so "every socket acknowledges notif.message" never held there. sendWithAck now reports `declaring`, the sockets that listed any event in auth.acks, and everySocketAcks on the desktop's settle means no socket here declares nothing. * fix(puter-js): fall back to the Continue dialog when a popup can't be opened Some browsers report user activation but still return null from window.open (iOS home-screen web apps, Safari after awaited work), so permission prompts, signIn, enablePush, and GUI action popups failed without showing anything. PuterDialog.launch() now owns the open-now-or-show-Continue decision for every popup flow and shows the consent dialog when the direct open returns null. On cross-origin-isolated pages signIn leaves settling to the /login/wait relay on both launch paths. * fix(notifications): show a live push when the page's window isn't focused onPush showed the handler's result as a system notification only when the tab was hidden. A visible tab in a window the user left showed nothing. It now also shows when the document doesn't have focus. Inside the Puter desktop the desktop's banner shows the notification, so an app's frame still never does. * build(puter-js): publish push-sw.js exactly as written The build emits dist/push-sw.js next to puter.js, but terser minified it along with the bundle. It is now marked as already minimized, so the file apps import into their service worker is the source unchanged. * fix(notifications): send live notif.message plainly and ask for receipts on request Every live notif.message used to be sent with an ack, list the user's sockets across the cluster (fetchSockets ships each socket's handshake between nodes) and wait out the receipt window, whether or not anything would act on it. notif.message is a plain send again. NotificationService.receipt() asks the desktop's sockets, or with appUid an app's own, the way app receipts already did: an ask with no socket listing, settled as notif.delivery.settled. A caller asks only when the answer changes what it does. - The desktop and dashboard sockets answer notif.receipt from the page's puter.js receipt log and note each live notif.message in it, so every socket on the page answers alike. A page whose puter.js has no log lists no acks. - SocketService.sendWithAck, the socket listing and region_delivered are gone. askWithAck counts this node's sockets when the adapter cannot count servers. - The send id stays on notif.written and the settle, and leaves the wire. * fix(socket): join only the ack room receipts are asked in A socket listing events in auth.acks joined an ack room beside each of its rooms and its own id. Receipts are only asked in the user's room, or the app's for an app socket, so that is the one ack room it joins. * docs(notifications): publish the push limits of 12 a minute and 1,000 a day Pushes are capped per user per app at 12 a minute and 1,000 a day, and at 6 a minute and 200 a day while an app is new. The rate limits table and the notification pages said one a minute and 100 a day. --------- Co-authored-by: Daniel Salazar <daniel.salazar@puter.com> |
||
|
|
eb1ad240f2 | docs(notifications): describe the confirmation step as what happens; one-line comments and neutral fixtures | ||
|
|
1aa7caf44c |
fix(notifications): visible sender for app messages; local banners follow push consent
An app title of only invisible or blank-filler characters no longer leaves an app message without a sender; it falls back to the app's name, then its uid. Hidden-tab banners now show only while push is on in this browser, and for an app's message only when the app holds notifications:push. |
||
|
|
fe8acf007c | fix(notifications): read the app off the notification on an update | ||
|
|
8010c9fc94 | Merge juancastro/put-2168-let-users-decide-which-apps-can-send-them-pushes into juancastro/put-2167-notification-written-hook | ||
|
|
bbf375db14 |
fix(notifications): notif.written hands out a copy with the audience
Listeners get their own copy of the value, so what the socket carries is what was written. Both emits name the audience, and an update names the row's app when the row or the value says which. |
||
|
|
f0d7a69dd4 |
fix(notifications): cap the sender name on app messages
The app's title or name is cut to the title length, counting code points, before it is stored on the message. |
||
|
|
fceaee6372 |
feat(notifications): node-local notif.written hook
NotificationService emits notif.written after each non-silent write and after an in-place rewrite, on the node that wrote the row, so an extension can act on new notifications without a second bus round trip. It stays off the published event stream; notif.created carries the notification itself. |
||
|
|
b57fef8c9d | Merge origin/main into juancastro/put-2251-let-apps-send-notifications-to-their-own-user | ||
|
|
589a98b12c |
feat(kv): share KV prefixes with other users at watch/read/write, and bound what apps can be granted (#4331)
* feat(kv): share key-value prefixes with other users, and bound what apps can be granted - KV share handles move out of Events into KvShareService, served at /kv/shares (the /events/kv-handles routes stay as an alias). - Shares have watch, read and write levels, encoded in the permission root (kv-share, kv-share-read, kv-share-write) with write implying read and watch. - Every puter.kv method takes optConfig.handle; keys are relative to the shared prefix, cursors are sealed, and private entries stay hidden. - New puter.kv.share, unshare, listShared and listSharedByMe; optional label; kv.share.received notice in the app's mailbox only; recipient blocks apply. - puter.kv.set gains onlyIfAbsent. - fs and kv events gain viaWorker, and kv events report self from the writer. - /auth/grant-user-app only accepts canonical permissions from a fixed set of families; the consent dialog, puter.perms and docs follow. - Migrations: kv_share_handles gains revoked_by, label, and grantee and app indexes (mysql_mig_49, postgres_mig_38, sqlite 0095). * fix(kv): never name a private key in a cross-app or handle cursor, and bound flat-permission scans - Listings of another namespace (cross-app or through a handle) resume after the last entry they returned, and their cursors are sealed, bound to the namespace or handle, and padded to one length. Old plain cross-app cursors are still read when they name the target namespace; other namespaces get 400. - PermissionStore scans flat permissions a few roots at a time and skips roots nested under another root in the same call. |
||
|
|
c701b8d7c0 |
fix(notifications): show the sending app on app messages
App messages carry the sending app's uid and name (and its icon when it is an https URL), set by the server; the desktop toast and the dashboard panel show the name above the title. Title and text drop invisible formatting marks and control characters, with newlines kept in text and whitespace-only text left out. The icon is stored as its normalized URL, and a target may not hold dot segments or non-ASCII characters. Both rate-limit windows are read before either is spent, so a refused call counts against neither. The docs say what ttl means for push delivery. |
||
|
|
4b41b9cd54 |
feat(notifications): apps can notify the user they act for
Adds the app.message type (audience app-user). An app, its workers and the tokens it issues can create it through puter-notifications create, only for their own user and under their own app. The value is validated against a fixed format, the write is not silent, and each app has a per-user budget of 30 per minute and 300 per day, configurable with notificationAppMessageLimits. Holders keep creating the other types silently and cannot create app.message. |
||
|
|
b67acdbf6f |
fix(stores): return written rows without reading them from a lagging replica (#4166)
* fix(stores): read rows back from the primary after writing them ShareStore (create, upsertPending, upsertActive, upsertActiveGroup, applyPending, upsertAnyone), AppStore (create, createFromOrigin, update, claimOwnership), NotificationStore.create, SubdomainStore.update and NotificationDriver.create returned the row they had just written by reading it back through `db.read`, which goes to the replica. Under replica lag that came back null or stale. Those read-backs now go to the primary, through a `primary` option on the getters. AppStore caches the primary copy on create, and claimOwnership no longer reads through getById, which cached the replica's pre-claim row on a miss. * fix(stores): skip the notify read-back; read OIDC link conflicts from the primary NotificationStore gains `insert`, which writes and returns the uid; `create` is now `insert` plus the primary read-back. NotificationService .notify ignores the row, so it uses `insert` and no longer pays a primary round trip per recipient. NotificationDriver.create still reads the row back from the primary. OIDCStore.link reads the conflicting row from the primary after a unique violation, so a replica that has not seen another account's link can no longer make the call report success. * perf(stores): return written rows instead of reading them back ShareStore.create, NotificationStore.create, AppStore.create and AppStore.createFromOrigin build the row they return from the values they inserted and the insert id, instead of reading it back from the primary. The DB-defaulted creation timestamp is now written explicitly from unix seconds (`timestampFromUnixSql`), so the returned value matches what a read returns (`timestampValue`); postgres INSERTs add RETURNING id. The AppStore rows fill every column the INSERT leaves to the schema and are cached as before. ShareStore.upsertPending's re-invite branch selects the full pending row in its dedupe read and returns it with the new mode and data; it falls back to the primary only when the UPDATE matched nothing. The remaining primary read-backs stay and say why: the share upserts and the new-invite insert (an upsert's surviving uid/created_at and the entry-owner subquery are only known to the primary), applyPending and claimOwnership (only an id or uid in hand), AppStore.update and SubdomainStore.update (the base row is a replica copy that would be cached), NotificationDriver.create (notify returns only the uid) and the OIDC link conflict. |
||
|
|
4448103ef0 |
fix: misc events and kv issues found during recipes (#3997)
* fix: misc events and kv issues found during recipes * test: cover handler depth on narrowed actors and created notifications * test: fix stale legacy batch directory upload test and seat user-agent flake * test: wait for the refusal to be recorded before claiming its gap marker |
||
|
|
b784b51cf3 |
fix: harden the events stack for flag-on (#3752)
* fix: harden events dispatch, single delivery and KV share handles Dispatch: a filtered subscription used the anchor path stored at subscribe time, so renaming or moving the anchor folder silently ended its deliveries; dispatch now resolves the anchor's live path from the event's own ancestor chain. A move out of a watched folder now reaches that folder's subscribers, with `from` only for rows that watched the source side. Gap markers are authorized like deliveries and coalesced per subscription and subject instead of fanning per lost event. Session subscriptions: the per-socket cap decides on the write, not before it; an orphaned watched-set token heals on refresh; durable rows keep their watch window when a session subscribe touches the same keys. `self` is false when the acting user is unknown. Single delivery: a subscription in backoff or suspended with a backlog pinned the sweeper's head and starved everyone behind it — the sweep now defers it. Only a settled handler run bills a delivery. A socket-only account row no longer wedges after two attempts nobody received. The lease is twice the handler timeout; remote candidates have their own attempt counter; the region depth reconcile runs once a minute region-wide with a bounded scan. KV share handles: a grantee no longer sees the owner's namespace and absolute prefix on the subscribe answer or listing, nor in the delivery token; revoking a wider handle retires the handles it covers; minting the same handle twice returns the existing one, after the delegation check; a row whose event cannot be re-based onto its handle is dropped rather than delivered raw. * fix: presence survives replication, long sessions and region churn One presence item per (user, app) with per-region map fields lost a region whenever two regions joined inside the replication window, and nothing ever put it back. Presence is now one item per (user, app, region): each region writes only its own, a leave or repair retires it conditionally on its own write stamp, and a read is a prefix query. Items carry a 48 h ttl refreshed by a claim-gated write off the existing socket renew path, at most once per 12 h, so a tab that stays connected keeps its region in the row. A region that answered "no socket" or completed a leave releases a shared pin, so a reconnect on another node rejoins and a flapping client cannot force a replicated write per cycle. Cached rows expire after a minute; unaddressable region names are filtered and pruned; relayed acks settle under a bounded concurrency; the forward queue is bounded in bytes as well as items. * feat: indexes for the event_subscriptions hot queries Handler publish, remove and listing, and the hourly expiry and suspension sweeps, all scanned `event_subscriptions`. Adds (app_uid, handler_name), (expires_at) and (suspended_at, id), guarded on every engine. Existing migrations: the postgres widens are now guarded so a boot does not take an exclusive lock for a no-op, the kv_share_handles grantee FK gets an index, the sqlite notification rebuild is transactional and idempotent. * fix: notification writes go through the registry The driver's `create` bypassed the type registry, producing uncatalogued rows with no size bound; it now requires a registered type, caps the payload, and answers 400 rather than 500 for a bad one. `mark_acknowledged` emits the ack other tabs listen for, and only when a row was actually changed. * fix: the handler scanner, unsubscribe, and the in-tab handler environment The free-variable scanner skipped arrows inside a declaration's initializer, so `const ids = event.items.map(x => x.id)` was refused, and treated a name after a comma in a nested initializer as bound, so a real free variable slipped through to fail on first delivery. `unsubscribe()` now drops the durable routing entry so the events socket can close. A broadcast handler running in the tab gets `user` and `fetch` like the worker gives it. `single` without a handler name is refused before the round trip. * docs: events limits, error codes and the background-workers section Retention is deployment-configured rather than a fixed 14 days, and the template no longer ships it armed. Documents `events_terminal`, the two per-event gap reasons, the subject length and listing caps, the `from` field on moves, and the handle-relative anchor. The sessions manager hides the background-workers section when the server has none to show. * feat: a background handler acts as the app does for its user A handler's `user` was a five-minute access token scoped to the subscription's `list` grant, which could stat the changed file but not read it, and could not reach the app's KV or AppData — so an app told that a file was written could do nothing with it. It now runs with the same authority the app has for that user in a tab: an app-under-user worker session, one row per (user, app) named `events:handlers`, visible and revocable in the sessions list. The `events:background` consent is what authorizes running it unattended, and is re-checked before every mint. The wider token exposed two things: puter.js opens a filesystem socket the moment it has a token, which would have parked the isolate in the app's own delivery room and steered deliveries at it; the events client now opts out of sockets (and the per-open bookkeeping) before construction, and is memoized per token in the isolate. And four filesystem operations assumed a socket exists; they no longer do. |
||
|
|
adde6c3a29 |
feat: notification GUI rides the events surface (PUT-1685) (#3689)
Widen the notif: match filter and fetch scope for a session's own generic developer/app-user subscribe: today it pins ref to the session's own uuid, so a row naming an app (handler-suspension notices, app-bound worker deploys) never matches live and never replays on reconnect, even though the audience predicate already grants the holder every such row it owns. The predicate is the authority and already reruns per row/page after the match, so widening the filter to it (account is unaffected — it never names an app) adds no exposure. |
||
|
|
fe5f81e52e |
feat: scoped app access to notifications (PUT-1684) (#3688)
An actor holding an app reads the `app-user` rows naming that app, plus its `developer` rows when the holder owns it. `account` rows reach no app, and a slice an actor may not see comes back empty rather than refused. The audience predicate becomes the enforced read path in the same change that lifts the blanket app-actor 403, layered behind an audience/app_uid SQL scope; two-segment `notif:` subjects expand server-side from the actor's own app, so an app can never name another app's uid. No feature flag: `audience` defaults to 'account', so every pre-registry row is default-denied to app actors and the backfill can only narrow. |
||
|
|
796683a133 | feat: missed-event fetch and notification fold-in (PUT-1681) (#3687) | ||
|
|
d8ebfc8023 |
feat: delivery re-check cache, revocation and anchor settle (PUT-1677) (#3681)
* feat: delivery re-check cache, revocation and anchor settle (PUT-1677) * fix: authorize re-anchors, settle each row once, purge revoked backlog (PUT-1677) - A path-form row whose anchor is deleted only climbs to an ancestor its holder may still watch under the mode it subscribed with; otherwise it ends with `anchor_deleted`. It used to land on any surviving ancestor (a guest's row on the owner's home), where the re-check denied every delivery but the row still held an anchor slot and a filter evaluation there. - After a climb the new anchor is re-verified and the climb repeated if a recursive delete took that level too, instead of leaving the row on a dead uid. - suspend() is one conditional write per row and reports which rows it was the one to suspend; concurrent settles of the same grant (an unshare revokes several strings) no longer each purge, forget and notify the same rows. - One "subscriptions ended" notification per holder and app, carrying the count and subjects, instead of one per row. - A revoke that removed nothing no longer announces; the sweeper purges (not defers) the backlog of a permission_revoked row; the reap purges pending entries with the row. - The delivery auth cache indexes entries by subscription so forget() is not a scan of the whole cache. |
||
|
|
82076ea767 | feat: notification retention sweep (PUT-1668) (#3672) | ||
|
|
5f37817a5c | feat: notification type registry and audience rules (PUT-1665) (#3671) | ||
|
|
7332993ff3 |
Show share notifications in the dashboard (#3653)
* Show share notifications in the dashboard The dashboard opens its own socket but never listened for notif.* events, so a file shared with a user on the dashboard was announced to nobody. Add a notification center to the dashboard: a bell in the sidebar with the unread count (a dot when collapsed, and on the mobile hamburger), a panel anchored to it — a bottom sheet on phones — listing what the server still holds as unacknowledged, and toasts for what arrives live. Clicking a share lands on Files › Shared with the item selected; dismissing, and "Mark all as read", acknowledge on the server so other tabs clear too. A toast timing out is not a dismissal: the entry stays unread in the panel. Arrivals fold in by uid (a regrouped share rewrites its row and toast in place), the panel refreshes on open, reconnect and tab focus so shares the backend folds in silently past the sender's budget still surface, and a burst on connect is capped at three toasts plus a summary. Backend: notif.unreads carries created_at so delivered-on-connect items can be dated, and listings break same-second ties by id so order is stable. The desktop's mark-ack calls move to a shared helper; UINotification gains an optional auto-hide timeout that pauses on hover/focus. * fix: look up notification glyphs and icons by own key only A notification whose `source` or `icon` was a prototype key such as `constructor` made the glyph lookup return a function, and the toast icon builder then threw inside the socket handler — which dropped the whole burst of toasts it was part of. The list rendered the same entry as "[object Object]". * fix: show dashboard toasts above open app windows App windows opened from the dashboard are stay-on-top and stack in the 99999999+ band, so the toast container at 10000000 sat underneath them: any notification arriving while an app was open — the usual state of a dashboard session — was drawn behind the app and never seen. * fix: reveal the dashboard when a toast is clicked over an open app Clicking a share toast switches the dashboard to Shared, and the "N new notifications" toast opens the panel — both inside the dashboard window. With an app maximized over it, all of that happened out of sight and the click appeared to do nothing. Minimize the covering app windows first, the way the minimize controls do, landing the URL on the dashboard's own route, and act once the history pop has settled so the tab switch is not traversed over. * fix: keep keyboard focus in the notifications panel across re-renders Every change to the list rebuilds its rows, which dropped focus to the document body: dismissing an entry with Enter threw the keyboard out of the dialog (the row it had just been moved to was replaced), and so did any arrival while an entry was focused. Put focus back on the same entry after a render, or on the one that took a dismissed entry's place, and let Shift+Tab from the panel itself wrap to the last control instead of leaving the dialog. * fix: keep the notifications panel inside short viewports The anchored panel is bottom-aligned with the bell and grows upward, but its height was capped only by the viewport (560px or 100vh - 24px), not by the room above the bell — on a short window the header ran off the top of the page. Cap it to that room, so the list scrolls instead. * fix: make notifications with nowhere to go inert in the panel An entry with no target (a worker deploy result) showed a default cursor yet, when clicked, was dismissed and took the panel with it — nothing opened, the entry was gone, and the list had closed. Render such entries as text with the ✕ as their only action; only share entries remain buttons. The ✕ now names its notification for screen readers, since on an inert entry it is the only focusable control. * fix: stop a listing in flight from resurrecting dismissed notifications The list is refreshed on open, on reconnect, and when the tab comes back into view; a dismissal landing while that listing is in flight was undone when it resolved, since the server's snapshot predates the ack. When the listing also resolved after the server's own ack event, nothing removed the entry again and it stayed until the next refresh. Leave out of the reconciliation whatever was acknowledged — here or in another tab — since the listing was requested. |
||
|
|
b795b219a8 |
✨ PUT-1497: Share file link sharing and notifications (#3595)
* refactor(share): move share notifications into their own service
* feat(share): invite an address with no account, and email it
* feat(share): surface pending invites in the SDK and share dialog
* fix(share): unreachable revoke confirmation, and double-encoded labels
* feat(share): budget share announcements, group them, and let people block senders
Sharing had one defence against noise: a 15-minute quiet window per (sender,
recipient) pair, which dropped the second share rather than folding it in.
Twenty senders each under their own window could still bury someone, and there
was no way to make one of them stop.
Announcements are now budgeted on two axes through the existing sliding-window
limiter: 1 per 15 minutes and 20 per day from one sender, and 10 per hour /
50 per day to one recipient from anyone. Over budget the share still succeeds
and the recipient's notification is still brought up to date — only the
interruption is dropped. Invite email to an address with no account is budgeted
the same way, keyed on a hash of the canonical address.
Notifications now fold across senders: a new share rewrites the notification
the recipient hasn't dismissed, so "alice and bob shared 5 items with you"
replaces a stack of five. The record is written even when suppressed, so the
count is right whenever they next look.
Blocking is a new `user_block` table with enforcement in ShareService: a blocked
sender's share is refused with `recipient_not_accepting_shares`, spends no
quota, and writes no row, and their unclaimed invite is dropped when the address
is confirmed. Existing access is untouched — that is what revoke is for.
Managed from a Blocked people card in the dashboard's Security tab.
Also publishes the sharing limits, including the ones already on this branch
that were never documented.
* fix(share): name the item in share email, instead of 'an item'
* fix(share): make the invite lifecycle canonical, authorized, and race-safe
* refactor(email): drop EmailClient.isConfigured; callers read config.email
* feat(share): batch share email into a per-recipient digest, durably
* docs(share): document the share error codes; steady the disk migration tests
* fix(share): log why a digest wasn't sent, and recover orphaned ones
* feat(share): email recipients about shares by default, with a way to decline
Share email was off unless a deployment opted in, which meant an account
holder was told about a share in the app only. It is now on unless
`share_email_notifications` is set to false.
The reason it defaulted off was that nobody could decline. So this also
honors `user.unsubscribed` — the account-wide opt-out the /unsubscribe page
already writes and app feedback already respects, which share email ignored —
and the digest carries that link. Sharing and the in-app notification are
unaffected by it; only the mail stops.
The link is composed in the template around an interpolated uuid rather than
passed pre-built: Handlebars escapes interpolated values, so a whole URL came
out as `user_uuid=…`, which browsers decode but link scanners and older
mail clients need not.
* fix(share): count every shared file in the digest, not just the first
* feat(share): let a recipient refuse shares from everyone
Blocking answered "not from this person" but had no answer to "not from
anyone", so the only way to stop a stream of unwanted shares was to name
each sender after they had already reached you.
Stored as a key in the user row's existing `metadata` blob rather than a
column: the share path already holds the recipient's row by the time it
asks, so reading it costs nothing, and a one-bit preference doesn't earn
a migration per dialect. `updateMetadata` merges and refreshes the cached
row, so the switch bites on the very next share.
Refusing everyone reports the same code as refusing one person — which of
the two it is is the recipient's business, not the sender's. Enforced at
both moments the per-sender block is: when the share is issued, and when
a pending invite is claimed. The per-sender list is untouched while the
blanket switch is on, so turning it off restores what it hid.
`GET /share/blocks` now carries `all`; `POST`/`DELETE` take `{ all: true }`
beside the existing `{ username }`. Managed from the same Blocked people
card in the dashboard's Security tab.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(share): keep the digest sweep off a window that still has a timer
The sweep treated an entry as orphaned the moment its window closed, which
is also the moment the node that armed it fires. Claiming an entry is only
exclusive among flushers that can see each other's deletes, so the two
could each claim a share of the same digest and both send. It now waits
out a grace period first, which costs a genuinely stranded digest that
much delay and nothing else.
Both digest listings were capped at 200 with no word when they hit it — a
truncated flush sends a digest that undercounts and reads as complete.
The cap is named and logged.
Also: `#emailHolder` still described share email as off by default, which
it stopped being; the config doc said the batch window defaults to 60s
when it is 90; and the two tests that need several calls inside one window
were racing a 50ms window across four sequential round trips, so they
failed under full-suite load rather than on the behaviour they cover.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(share): stop double-encoding the recipient in two dialog messages
`i18n()` encodes what it returns, replacements included, so encoding the
recipient first showed the entities to anyone whose address or username
contains one. Same pattern already fixed two lines above.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* fix(notification): widen the mysql shown/acknowledged columns
Both arrived from the v1 schema as `tinyint(1)`, where they were flags.
The backend rework changed the writes to a unix second; sqlite (`INTEGER`)
and postgres (`bigint`) took it and mysql did not, so on mysql every
`markShown` and `markAcknowledged` has failed with
ER_WARN_DATA_OUT_OF_RANGE and left the column NULL. Dismissing a
notification never stuck — the unacknowledged count never moved and one
already delivered came back on every reconnect.
No backfill: every reader tests `IS NULL` / `IS NOT NULL` only, so a
legacy `1` keeps meaning "yes" once widened. Guarded on the current type,
because changing a column type copies the table and this directory
replays on every boot.
Not reachable from the test suite — it runs against sqlite and postgres,
both of which already have the right type. Verified by hand against mysql:
`/notif/mark-read` and `/notif/mark-ack` now persist, and a dismissed
share notification is no longer the one a later share folds into.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Daniel Salazar <daniel.salazar@puter.com>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
||
|
|
116d6e6663 | tests: big test push for better coverage (#3490) | ||
|
|
b188942436 |
feat (PUT-1016 & PUT-1020) (#3164)
* feat (PUT-1016 & PUT-1020) temp account preservation on forced relogin hosted asset cookies to v2 token too * fix: remove llm dashes and ugly comments * update agents |
||
|
|
267f464232 | Add AGPL license headers to source files (#2877) | ||
|
|
d4d78ac7db |
rework: change backend and backend extensions to use simpler code structure and patterns (#2815)
* fix: dynamodb health checks and client recreation (#2789) * wip: no nanoServices groundwork * feat: data clients in new shape * wip: auth and perms in new system * more wip * middlewaters mainly done * wip: fsv2 in new layout * old fs v2 migration * driver system * driver and old fs fixes * ai drivers wip * stream support * metering in ai chat driver * wip: new auth * rate limit and auth routes * captcha and anti csrf * fix: types * auth store * app logic * wip most other dricvers * fs * mostly kill all legacy stuff * fs finish * fix: redis usage * ai controller * driver cleanup * socket io in v2 * broadcast and crudq stuff * subdomains * notifcations and shares * fix bad syntaxes * auth wip Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * extensions * extension setup * more routes * sql migrations and default services * home router * tier 7 * everything else * everything else * remaining missing bits * server health * logs * cleanup * deps * cleanup 2 * more cleanup 2 * boot * fix launch * config fix * move file * fix: tsconfig things * fix: extension loading * launching * fix: drivers * fix: others * fix: icons * fix: file uploads * fs fixes * fix: fs api * fix: dev-center * config * add back telemetry * lint stuff * husky hooks * fix: fs oss * fix: config migration * config migration * migrate scripts + replicate * runner * fix: merge defafult config * fix: default region * fix: api domain * fix paths in readfile * fix fs entry default s3 * NS: Remove Referral && Entri Service * dep cleanups * fix: static assets * fix: kv and perms * fix: driver registrations * fix: home mapping * fix: rao * adding back 500 alarm * fix: build paths * fix: fs and kv shapes * fix: kv shape * more kv coercing and ai chat matching format as prior * fix: private app gates * private app caches * fix: whole bunch of legacy shape issues * update template jsonc * fix caching partial oidc and fs signed paths * more oidc fixes * fix: wip * fix: private apps * admin route fixes * fix: last few things hopefully * claude uploads * fix security for app only routes * fix kv system namespace * stuff * fix: app and kv and suggested apps * fix:open item * fix: FS operations * fix: default app icons * add back token-read and WSL support * metering fixes * fix: fsEntry * perm scanners and implicators * proper download endpoint * fix: download * fix anti csrft on v2 * fix file extensions, app icons * fold in v1 fixes from origin/main into v2 equivalents Re-applies the v1 fixes that landed on origin/main into their v2 counterparts since the v1 files were deleted on DS/wip during the v2 migration. v1 commits referenced below. - SQLBatcher: flush immediately when queue hits maxBatchSize instead of racing the timer (v1 12f48238). - RedisClient: drop maxRetriesPerRequest from 2 to 1 to shrink failure window (v1 |