mirror of
https://github.com/HeyPuter/puter.git
synced 2026-11-06 05:55:42 +00:00
bbf375db14cbe45e771dc7e0aeba72eae82bc3c7
454
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
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. |
||
|
|
20c63b6282 | Merge juancastro/put-2168-let-users-decide-which-apps-can-send-them-pushes into juancastro/put-2167-notification-written-hook | ||
|
|
9fb66137a3 | Merge juancastro/put-2251-let-apps-send-notifications-to-their-own-user into juancastro/put-2168-let-users-decide-which-apps-can-send-them-pushes | ||
|
|
057aa116a4 | Merge origin/main into juancastro/put-2251-let-apps-send-notifications-to-their-own-user | ||
|
|
5cfd4d1f53 | fix(hosting): honor user-granted subdomain permissions (#4372) | ||
|
|
e1a36215f6 | fix(sharing): preserve team grants and scope link invalidation (#4371) | ||
|
|
996d85148e | fix(fs): remove app data requested during uninstall (#4369) | ||
|
|
7bccde40a8 | fix(hosting): use the current authenticated visitor for asset billing (#4364) | ||
|
|
cf5d2c60a5 | perf(permission): resolve default grants through full-access issuers (#4361) | ||
|
|
b7a0276337 | fix(fs): preserve paging and file read-copy contracts (#4367) | ||
|
|
91554a137e | fix(apps): keep app provenance and permission caches consistent (#4357) | ||
|
|
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. |
||
|
|
47a6122c37 |
refactor(permission): keep the notifications:push constant with the permission code
The consent check that reads it lives with the delivery code, outside core. The grant flow test checks the grant through the permission service directly. |
||
|
|
c79b3edf4f | Merge juancastro/put-2251-let-apps-send-notifications-to-their-own-user into juancastro/put-2168-let-users-decide-which-apps-can-send-them-pushes | ||
|
|
d366861321 | Merge origin/main into juancastro/put-2251-let-apps-send-notifications-to-their-own-user | ||
|
|
f850155411 |
KV share handles: hardening, higher limits, batch share, uninstall data deletion, account-deletion cleanup (#4344)
Maintain Release Merge PR / update-release-pr (push) Canceled after 0s
release-please / release-please (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
* fix(kv): sign share handle ids and drop retired handles from every node's cache
New handle ids carry a short HMAC (kvh-<uuid>.<mac>); an id whose mac
does not verify is refused before any lookup. Unsigned ids keep working
but no longer fall back to the primary on a replica miss.
Retiring or changing a handle now broadcasts outer.pubsub.kv.share.forget
so every node drops its cached row. Event subscribes and delivery
rechecks refuse a handle whose owner is suspended, as the driver does.
* fix(kv): an app its user removed stops reaching shares
An app acting for a user exercises a share grant only while the user
still has the app authorized: at least one user-to-app permission row,
which every app token path leaves and revoke-user-app '*' removes. The
share itself stays; authorizing the app again restores access. The
answer rides the permission cache, keyed by the app-under-user
generation the revoke bumps, so live subscriptions go quiet too.
* feat(kv): list keys through a watch-level share handle
list with as: 'keys' through a handle needs only watch, which already
learns every key written in the region. Values and entries still need
read.
* feat(kv): filter share listings by app and by state
GET /kv/shares and GET /kv/shares/with-me take an optional appUid: an
app uid, or null for the app-less store. An account session may name
any app; an app is refused any namespace but its own. GET /kv/shares
also takes state=live|revoked|all, all by default.
* feat(puter.js): appUid and state options on the KV share listings
listShared() and listSharedByMe() pass appUid (null for the app-less
store), and listSharedByMe() passes state. Docs cover the new options
and that a watch share can list keys without their values.
* feat(kv): indexes and store counts for share-handle quotas and retention
Adds covering indexes for the owner's live count, the owner-holder pair,
app-filtered listings and retired rows, plus store methods to count a
pair's live handles and delete rows retired before a cutoff. The live
handle cache holds up to 50,000 entries; the miss cache stays at 10,000.
* feat(kv): higher share-handle caps, a per-holder cap, and sharing with several recipients
Live share handles per account go to 25,000 paid / 5,000 free, and per
app to 10,000 / 2,000. One owner may hold out 256 live handles to one
holder on every plan, which bounds the rows a revoke or unshare walks.
Re-sharing an existing region takes no slot and is never refused.
POST /kv/shares takes recipients (1 to 50) as an alternative to
recipient: one region, level and label for all. Caps and a new
per-recipient grant budget (300/min, 2,000/hour) are checked for the
whole batch up front; failures about one recipient are reported on its
item. The single-recipient request and response are unchanged and run
through the same code path.
* feat(kv): delete share handles 30 days after they stop
A daily job deletes retired share-handle rows older than 30 days, in
batches of 500. One node runs it per day; it stops on shutdown.
* feat(puter.js): share a KV prefix with several users in one call
puter.kv.share() takes an array of recipients, positionally or as
recipients in the options form, and resolves to one item per recipient:
the share, or an error with its message and code. The single-recipient
forms are unchanged.
* feat(kv): delete an app's data on uninstall, and close account-deletion gaps
Uninstall:
- POST /auth/revoke-user-app takes an optional `deleteData` boolean, only
with `permission: '*'` (400 otherwise). AuthService.uninstallApp revokes
every grant, deletes the app's worker sessions, then awaits
`app.uninstalled` { userId, userUuid, appUid, deleteData }.
- With deleteData, KvShareService retires the handles the user shares out
of that app's namespace (as the platform, in batches) and then empties
the namespace in the background, 5,000 items per step, stopping on
shutdown. Handles shared with the user are not touched.
- SystemKVStore.purgeNamespace(namespace, { maxItems }) deletes up to
maxItems items of one `v1:<uuid>:<appUid>` namespace, reading keys only,
with no mutation events or usage, and reports `done` once it is empty.
It refuses the system namespace and anything else. flush shares its
page-delete path.
- puter.perms.revokeApp(appUid, '*', { deleteData: true }); the desktop's
uninstall dialog has an unchecked "Also delete this app's data" option.
Account deletion:
- EventsService settles each of a deleted account's apps on `user.delete`
(they cascade without `app.changed`) and drops the owner's subscription
keys.
- The flat permission entries the account held are purged in pages.
Suspension:
- `user.suspended` / `user.unsuspended` events; the share-handle store
drops cached rows owned by that user here and on other nodes.
* fix(permission): await a peer's flat-permission deletes so emitAndWait sees them applied
* fix(kv): strongly consistent purge pages, and an uninstall purge stops once the app is authorized again
A stale empty page could end a purge as done while items remained. The
background purge after an uninstall now checks before each step that the
user hasn't authorized the app again, so data written after that is kept.
* fix(kv): flush keeps eventually consistent page reads; only a purge reads strongly consistent
* fix(user): emit user.suspended and user.unsuspended when the user store changes an account's suspension
|
||
|
|
c8998075ea |
fix(push): let users grant apps notifications:push
The grant route now only accepts permissions from a fixed set of app-grantable families, which refused notifications:push, so an app could never be given push consent. Register it next to events:background. |
||
|
|
759f1a798d | Merge origin/juancastro/put-2251-let-apps-send-notifications-to-their-own-user into juancastro/put-2168-let-users-decide-which-apps-can-send-them-pushes | ||
|
|
b57fef8c9d | Merge origin/main into juancastro/put-2251-let-apps-send-notifications-to-their-own-user | ||
|
|
b9f0c8601d |
fix(dashboard): eighth adversarial review of Files — bugs, UX, a11y, perf (#4330)
* fix(dashboard): don't preview a file named after an image type, or a failed image
Space on a file named "png" (no extension) opened the image preview over a
broken image: the type was read from the last dot-separated part of the
name, which for an extensionless name is the whole name. It's now read from
the extension.
A preview whose image URL couldn't be fetched left the previous image up
under the newly selected row and raised an unhandled rejection. It now
closes instead.
* fix(dashboard): sort Files names by number, and break size/date ties by name
Names were compared as plain text, so "file10" sorted before "file2" and
"dir10" before "dir2". They now compare as a file manager does, with runs
of digits by value.
Sorting by size or date left rows with the same value (every folder, when
sorting by size) in whatever order the server listed them, and a row added
afterwards (a new folder, a rename) landed after its equals instead of where
a fresh listing would put it. Ties now fall back to the name, in the full
sort and in the single-row insert alike.
The sort also parsed each trashed item's metadata on every comparison; it
now reads each entry's sort key once.
* fix(dashboard): ask about "this item" when permanently deleting one Trash item
The Delete key and the phone selection bar's Delete Permanently asked
"Are you sure you want to permanently delete these items?" for a single
item too. One item now gets the single-item question, as from its own
menu. The three copies of the confirm-and-delete step (Delete key,
selection bar, multi-select menu) are now one.
* fix(dashboard): don't let saved column widths squeeze out an auto-width name
Saved widths too wide for the listing fall back to the defaults, but only
when the Name column had a saved width too. Beside an auto-width name, a
Size or Modified column widened on a large screen kept its width on a
smaller one and squeezed the names to nothing, with the row scrolling
sideways. The check now counts an auto-width name at its 100px minimum.
* fix(dashboard): give Files names a phone's full row, not a desktop column width
A narrow listing drops the columns for two-row rows, where each name has
the row's width. Names were still cut to the Name column's saved width
whenever the saved columns happened to fit, so a 120px column set on a
desktop cut names to about 100px on a phone with 280px to spare.
* fix(fs): refuse to replace a folder that contains the item being moved or copied
Moving or copying an item onto a name taken by one of its own parent
folders, with overwrite, removed that folder recursively first, and the
item went with it. Cutting the inner "photos" out of photos/photos, pasting
it next to the outer one and choosing Replace deleted both folders and
everything in them. Such an overwrite is now refused before anything is
removed.
* fix(dashboard): keep the Files selection when an upload lands in another folder
An upload into a folder other than the one on screen (a drop on a folder
row, a sidebar folder or a crumb, or Upload from a folder's menu) still
listed the folder on screen again when it finished. That cleared whatever
was selected there and left an open menu acting on rows no longer on the
page. Only an upload into the folder on screen refreshes it now.
The Upload picker ran its own copy of the upload flow; it now goes through
the same one as drops.
* fix(dashboard): keep Files rows up while a refresh reads web link and app icons
A refresh of the folder on screen keeps its rows until the new listing is
in, so it doesn't flash blank. But it cleared them before reading the
icons, and a web link's or .app's icon is read from the file over the
network, so in such a folder every re-sort, upload or return to the tab
blanked the pane for those reads. The icons are now read before the rows
are swapped.
* fix(dashboard): Ctrl/Cmd-drag a Files selection as a whole
Pressing Ctrl (Cmd on a Mac) on a row that was already selected took it out
of the selection at once, so a drag starting there collapsed the selection
to that one row: a Ctrl-drag of three files to copy them copied one, under
a ghost that still read "3". The row now leaves the selection when the
button is released without a drag, as in a desktop file manager, and a
drag carries the whole selection.
* fix(dashboard): let Enter open the row the arrow keys moved to in Files
Clicking a sidebar folder, crumb or toolbar button leaves it focused, and
the arrow keys and type-to-select still move the row selection from there.
Enter, Space and the menu key then went to that focused control: after
clicking Documents and pressing Down twice, Enter opened Documents again
and dropped the selection instead of opening the highlighted row. The
listing now takes the focus when these keys move the selection, so the
keys after them act on the rows, and Shift+Tab goes back to the toolbar.
* fix(dashboard): let Files' image preview be cancelled while it loads, and close it with the tab
The preview only counted as open once its image URL had come back (two
round-trips), so nothing could cancel it before then: Escape cleared the
selection and the image popped up anyway, a second Space opened it instead
of closing it, and opening a folder left the old image to appear over the
new one. It now counts as open from the start, so each of those cancels
it.
Browser Back/Forward to another dashboard tab left the preview floating
over that tab, where Escape and Space no longer reach it. A route change
away from Files now closes it, as it does Share and Properties.
* fix(dashboard): keep Files keys off a new folder until the server has made it
A new folder's row goes up with the name editor open before mkdir answers.
Clicks and menus already wait on it, but the keyboard didn't: pressing
Escape and then Delete on a slow connection sent a move for the row's
placeholder and showed "Moving New Folder: Subject does not exist", and
Enter opened the predicted path before it existed. Enter, Delete, Ctrl/Cmd+C
and Ctrl/Cmd+X now skip such a row, as do Cut and Delete in the
multi-select menu and the phone's selection bar.
* fix(dashboard): explain a Files action that fails on the network
A request that fails at the network level is rejected with no message.
New Folder then took its row back with no alert at all, and Restore and
the New menu's files showed "[object XMLHttpRequest]". A failed move from
the clipboard named the item and nothing else. Each now falls back to
"An unknown error occurred."
* fix(dashboard): don't offer Trash folders, or view-only shares' crumbs, as Files drop targets
Inside the Trash a folder row lit up as a drop target and then refused the
drop with an untranslated "Cannot move items into a deleted folder.", and a
crumb inside the Trash took the move, though only Restore may take an item
out of the Trash. A crumb of a folder shared with the user to view only
lit up too, and the drop ended in one refusal per item from the server.
Such folders no longer light up, and a drop on one goes back, as a drop on
a view-only folder row already did. A view-only crumb still opens on a
dwell.
* fix(dashboard): count a new folder, not the old selection, in the Files footer
New Folder selects the new row, but the footer was updated before that and
kept describing what was selected before ("3 items selected"), as did the
phone's selection bar, whose buttons by then acted on the new folder. A
Shift+click also still extended from the old anchor. The footer now
follows the new selection, and the new row is the anchor.
* fix(dashboard): update the Files footer when Enter ends a rename
Ending a rename with Enter selects the row, and then called the desktop
Explorer's footer update, which finds no footer in the dashboard. Renaming
a row from its menu while another was selected left the footer at
"1 item selected" with two rows selected.
* fix(dashboard): stop Files' image preview from running out of read URLs
The preview fetched a read URL for every image it showed, and read URLs
share the access-token allowance of 20 an hour. Stepping through a folder
of images with the arrow keys spent it in seconds, after which the preview
(and any app asking for a read URL) failed for the rest of the hour. The
preview now reads the image directly and shows it from memory, freed when
it closes or moves on, and a held arrow key loads only the image it stops
on.
* fix(dashboard): don't offer to publish a folder the user may not share from Files' folder menus
The background, crumb and sidebar menus offered Publish as Website for any
folder, so in a folder shared with the user to view or edit it opened the
publish dialog only for the server to refuse. Publishing takes the right
sharing does; the item now stays disabled without it, as on a folder row's
own menu.
* fix(dashboard): open a Files folder under its new name when it's opened mid-rename
Double-clicking a folder right after typing its new name (the first click
sends the rename) opened it under the old path, which the rename then
took away: the view sat in a folder that no longer existed, with crumbs
and Back/Forward disagreeing about its name, and New Folder or Upload
there failed. Opening a row (double-click, tap, Enter, or Open from its
menu) now waits for a rename still on its way, and goes by one path.
* fix(dashboard): keep Files' Back/Forward in step with folders moved or trashed
Back and Forward kept a folder's old path after it was moved or deleted,
here or from elsewhere: going into a folder, back out, and dragging it
somewhere else (or deleting it) left Forward leading to "This folder
couldn't be opened", with New Folder and Upload gone. History entries now
follow a moved folder and what is inside it, and a trashed or deleted
one's entries are dropped.
* fix(dashboard): keep screen readers from reading Files' icon file names
The crumbs' separators, the folder sidebar's icons and every row's icon
were images with no alt text, so a screen reader announced each one, by
file name in some ("triangle-right.svg"), between every crumb and before
every name. They are decorative beside the text they sit next to and are
now marked so. The crumb of the folder on screen is marked as the current
page.
* fix(dashboard): make double-click on a Files column divider fit the column
Double-clicking a column divider measured the text in system-ui, but the
listing renders in Inter, and took the cells' padding as 8px a side where
Size and Modified have 10. Right after "fit to content", the longest name
was still cut in the middle and every Modified cell ended in an ellipsis
("40 minutes a…"). Each column is now measured in its cells' own font and
padding.
* fix(dashboard): keep a renamed item among its sharer's in Files → Shared
With Shared sorted by "Shared by", renaming an item shared with the user
to edit placed its row by name alone, so it could land among another
person's items until the listing was loaded again. A row put back in
place now goes by its sharer first there, as the listing does.
* fix(dashboard): keep every item, and empty folders' places, in a downloaded zip
Downloading several items as one zip keyed each by its name, so two items
with the same name (in Files → Shared, two people can each share a
"Notes.txt") came out as one file, the other silently gone. Such names
now get " (1)" and so on in the zip, as a folder does for a duplicate.
Empty folders were put at the top of the zip by their own name, not where
they sat: zipping a folder holding x/y gave "y/" next to "x/". They now
keep their place.
dedupedName moves to helpers/, now that the zip uses it as well.
* fix(dashboard): give Files grid tiles both lines of their name
A tile has room for two lines of name, but names were cut to fit one:
"Quarterly financial report final version.pdf" showed as
"Quarterl…l version.pdf" over an empty second line. A name now shows whole
when it wraps into the two lines, and is cut in the middle, keeping its
extension, only as far as it has to be to fit them.
Where the browser wraps a name can't be told from text widths alone, so
the tiles are written and read back together, one layout a round rather
than one a tile.
* fix(dashboard): keep the Files listing when a refresh can't reach the server
A refresh of the folder on screen that failed for any reason replaced the
listing with "This folder couldn't be opened." and took New Folder and
Upload away. Returning to the tab triggers one, so opening a laptop before
its Wi-Fi reconnected, or a moment's lost connection during a re-sort, left
a folder that was fine looking broken until it was opened again. A refresh
that doesn't reach the server now leaves the rows as they are; one the
server answers with an error (the folder is gone) still says so.
* fix(dashboard): don't drop files added to a Files folder while a new folder is made
While New Folder (or New › file) waited for the server, every item added
to the folder on screen was dropped, not only the one being made, so a
file uploaded from another tab or device in that moment didn't show up
until the folder was listed again. Such items are now held until the
create is done and added then; the create's own comes through as an
update of the row already drawn for it.
* perf(dashboard): refresh Files on return to the browser tab only when Files is showing
Coming back to the browser tab listed the Files folder again even with
another dashboard tab in front, and in Files → Shared that meant every
share all over again. The refresh now waits until Files is next shown.
* fix(dashboard): move the keyboard focus off a Files arrow that disables itself
Pressing Enter on Back to reach the first folder disables Back while it has
the focus, and a disabled arrow is drawn at 10% opacity, focus ring
included, so the keyboard user lost track of where they were. The focus
now moves on to an arrow that still works, or to the listing.
* fix(dashboard): show "can't drop here" when dragging files from the computer where Files takes none
Files dragged in from the computer showed the copy pointer everywhere in
Files, then dropping on the Trash, in Shared, on a folder shared to view
only, or while a folder was loading did nothing. The pointer now shows the
drop isn't possible there.
* fix(dashboard): let Files' type-to-select find names with "i" in a Turkish browser
Typed letters were lower-cased for the browser's language and the names
they're matched against without one. In Turkish, "I" lower-cases to "ı",
so typing it never reached "Invoices". Both are now lower-cased alike.
* fix(dashboard): bring Files' Back/Forward along when a folder rename is undone
Renaming a folder points Back and Forward at its new name, but undoing the
rename left them there: Forward then led to "This folder couldn't be
opened" for a name the folder no longer had. Undo now takes them back to
the old name too.
* fix(dashboard): don't shrink Files → Shared's name column when it's dragged wider
In a narrow Shared listing, "Shared by" is laid out narrower beside a name
column with no set width. Dragging the name's divider gave it a width, at
which "Shared by" sprang back to its own and pushed the name narrower than
it started: dragging right made it 30px smaller, and there it stuck.
Sizing the name now keeps "Shared by" at the width it had on screen.
* docs(fs): note that move and copy never overwrite a folder holding the source
* fix(dashboard): keep the Files drop zone after files dragged in cross a folder row
Leaving a folder row while dragging files in from the computer also took
the highlight off the listing around it, as the row's notice bubbled up to
the listing's own. With the "can't drop here" pointer going by that
highlight, a drop on the listing after crossing a folder row was refused.
The row's notices stay with the row, and the listing goes by whether the
folder takes uploads.
* fix(dashboard): don't let a Files folder wait for ever on a rename to open
Opening a row waits for a rename on its way, which settles in the last
step of the rename's handling. A failure after the rename itself (such as
refreshing the folder's websites) never got there, and the folder then
wouldn't open until it was listed again. Opening waits five seconds at
most.
* docs(fs): drop the note on overwriting a folder that holds the source
The refusal is an edge case, and its error message already explains it.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
|
||
|
|
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. |
||
|
|
ad5f9b6196 | fix(push): canonical app uid in the push consent check | ||
|
|
65b966c264 |
feat(push): notifications:push app permission
Apps need a per-app notifications:push grant for their notifications to go out as pushes. hasPushConsent checks it as the app acting for the user, so only that user's grant to that app answers yes, and a revoke takes effect on the next check. The permission dialog describes it and the perms docs list it. |
||
|
|
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. |
||
|
|
8b8dd19eae |
fix(teams): refuse to boot when teams_enabled has nothing registering Teams (#4300)
* fix(teams): refuse to boot when teams_enabled has nothing registering Teams Core's seat checks (team 2FA, seat password reset, email and username changes, self-delete, the signup placeholder claim) read the team store and skip when it is absent. With `teams_enabled` on and no extension registering the store and service, the server now fails at boot instead of serving without those checks. - whoami no longer string-matches the extension proxy's "missing property" error: with the flag on, the store is always there. - Tests that turned the flag on without Teams drop it; the puter.js harness sets it only under the `teams` capability. - Drop a stale services/index.ts comment. * refactor(gui): drop the Teams console styles, which ship with the console now |
||
|
|
1fdde02f6a |
feat(metering): free and no-allowance plan traits, provisional resolver answers (#4284)
* feat(metering): let registered plans declare free and no-allowance traits
A policy passed to registerPolicy can now set `free: true`, so
isFreeSubscription and everything that reads it count the plan as free.
It can also set `noAllowance: true`: the plan grants no monthly
allowance, so only purchased credit can be spent, and the cached
credit check refuses an actor that has none. A non-positive allowance
without the trait still means the plan is not metered.
getActorSubscriptionStatus returns the plan along with whether it was
paid for and whether the answer is provisional (a lookup behind it
failed, or the actor lacks a field resolvers key on), so callers can
tell a fallback from a resolved plan. getActorSubscription is unchanged.
* feat(metering): provisional resolver answers, and tighter no-allowance accounting
A subscription resolver can answer { id, provisional: true }. The answer
wins like any id but is cached only for the fallback window.
On a no-allowance plan, the month record now records allowanceUsed = 0,
so spend that purchased credit paid for is not counted again against an
allowance the actor gets later in the month. An actor that never had
budget raises no credit-state event. setMonthAllowance refuses plans
whose allowance an override never applies to.
* fix(metering): keep resolved plans clean when a default fails, and announce moves onto no budget
A failed or provisional default resolver now marks the answer provisional
only when the default is what applied; an explicit plan that won is
cached for the full window. A provisional answer with no id still marks
the result provisional.
A plan or credit change now re-arms an actor's credit alert instead of
forgetting it, so an actor moving onto a plan with no budget hears
'exhausted' once. An actor never seen with budget still hears nothing.
* chore(metering): let a provisional answer carry no id, and fix two resolver docs
* chore: one-line comments
|
||
|
|
1627650abc |
refactor: make teams an optional feature (#4248)
release-please / release-please (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
Maintain Release Merge PR / update-release-pr (push) Canceled after 0s
* refactor: let core run without a teams registration
Teams becomes optional: `services.team` and `stores.team` may be absent, and
core stops assuming either is there.
Seventeen call sites across share, signup, account deletion and the homepage
now degrade instead of throwing: a team recipient that cannot be resolved is
"no such recipient" rather than a fault, a group share folds in nothing, and
account deletion stops asking what a seat was charged for. This generalises
the pattern `gates.ts` and `SocketService` already used.
Teams' own files take a private handle to their store and service, so the
feature's code stays free of optionality that only core needs.
No behaviour changes where Teams is registered.
* refactor: put a contract between core and teams
`core/teams.ts` holds the shapes core reads and the calls it makes, and the
registries declare the pair as provided rather than built in. The service,
store and controller are registered by whatever runs the feature.
Typing them properly turned up eight AuthController call sites the previous
commit could not see: the controller reads its stores from the built-in
registry, so an absent `team` fell to an index signature and typed as
`unknown` instead of erroring. They now go through the same optional path as
the rest.
The feature's own tests move with it. The cases in core suites that build a
team to exercise a core rule still need relocating, so those suites are red
here.
* test: stub core's seat rules, rehome the rest with the feature
A core suite can no longer build a team, so the rules that refuse a seat get
`testFixtures/seatStub.ts` instead: it answers the contract, which is all
those rules read, and puts the registry back when the test ends.
Seventeen cases move that way. Four could not: group shares and a seat's 2FA
enrolment over HTTP need the real feature and the `/teams` route, so they go
with it, as does the whole of `GroupGrants.test.ts`.
Two whoami cases asserted a seat's team through `extension.import`, which
resolves the registry rather than the stores object. They now assert the
absent case, and the behaviour they covered is asserted against
`buildUserDetails`, which takes its stores as an argument.
Suite: 12252 passed, 38 skipped.
* refactor: take the teams console out of the built-in tabs
`TabTeams`, `teamsConsole` and `teamActionIcons` are no longer built in. The
dashboard already invites a tab through its `dashboard-will-open` event, so
whatever provides Teams contributes the console the same way.
Its 113 strings go with it and register into the shipped English dictionary
at load, so `i18n('teams_*')` resolves as it did. Only `en.js` ever carried
them, so no translation is lost.
Three modules stay, because things other than the console use them:
`teamBadge` (the dashboard shell), `orgSeat` (Home, Account, Security) and
`shareTeams` (the share picker). They already render nothing when the
account has no team. `teams_account_of` stays with `teamBadge` for the same
reason, and the `share_*` and `plan_team_*` strings stay with their callers.
The GUI builds clean with no console at all, and builds it in when the tab
is contributed.
* docs: seats past the owner's current limit get no allowance
|
||
|
|
75385501a7 |
fix(fs): support offset and byte_count in read operations (#4266)
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(fs): support offset and byte_count in read operations Allow partial content reads via offset and byte_count parameters in puter.fs.read and backend FS controllers. - In puter.js read operation, check options.offset and options.byte_count against null/undefined so offset: 0 is preserved in query params. - In handle_resp, accept any 2xx status code so 206 Partial Content responses resolve successfully. - In backend FS controllers (LegacyFSController and FSController), derive the HTTP byte range from query offset/byte_count parameters when an explicit Range header is not present. - Add unit and integration tests across controllers and SDK test suites. * fix(fs): strict range params, 416 past EOF, drop format churn - resolveReadRange accepts only decimal digit strings and safe integers - readContent maps S3 InvalidRange to 416 instead of a 500 - revert unrelated union-type reformatting, restore truncated test comment --------- Co-authored-by: Daniel Salazar <daniel.salazar@puter.com> |
||
|
|
18b96d529c | fix: misc scan fixes (#4270) | ||
|
|
517efda38a | automerge emergency fix (#4264) | ||
|
|
ea3801fb74 | PUT-2058 and PUT-2096 (#4256) | ||
|
|
9ff224e22f |
perf(backend): cut sequential round trips on auth, fs, driver, event and site paths (#4259)
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
Pipeline - CORS preflights send Access-Control-Max-Age: browsers stop re-preflighting authed calls every 5 s. - `req.ua` is parsed on first read instead of on every request. - JSON `rawBody` reuses the parsed buffer instead of copying it. - `emitAndWait` returns early when nothing listens: no span or closure per unlistened driver call. - SQLBatcher sends a lone query on the next tick while fewer than 8 flushes are in flight, and waits `maxTimeInQueue` as before otherwise. Lone reads skip the 10/30 ms wait; batching under load is unchanged. Auth and permissions - Token-to-actor auth starts the user, session and app lookups together and checks them in the same order: fewer sequential Redis round trips per authed request. - get-user-app-token and check-app reuse the resolved app row. getOrCreateApp skips the re-select after its own insert and doesn't wait on the cache write for replica rows. - getOrCreateApp reads the race winner from the primary when its insert loses, so it no longer hands out a session that was never written. - whoami runs the email check and the OIDC/taskbar/team lookups in parallel; key order unchanged. - OIDC providers are memoized once every discovery succeeds, with one discovery fetch in flight per URL, and the Apple key is parsed once. - gui-token and sync-cookie use `req.actor.user` instead of re-reading the user. - listSessions batches app lookups. App icon probes run together, and the original-icon probe skips the lineage fallback. - Default-granted permissions are answered from config before the scan cache, and exploded options are deduped: one less Redis read per /drivers/call. - ACL resolves ancestors lazily (owners and full-access tokens skip them) and scans the node first, then the ancestors concurrently on a miss. Filesystem - Missing-path lineage lookups run 10 at a time: batch-upload path resolution drops from about 1.3 s to about 40 ms per 100 new files. - MySQL single-file inserts return the row built from the written values, falling back to the primary read: one less primary round trip per new file. - Batch thumbnails are stored with one UPDATE per chunk (200 rows or 4M chars), with a per-row fallback. - storage.quota.bonus listeners run concurrently next to the usage SUM, and the payload carries `userUuid`: quota checks and /df no longer serialize the listener reads. - readdir cache backfill is one pipeline instead of 3xN SETEX. - Profile reads skip the lineage fallback; the own profile is read alongside the user lookup. - Legacy /stat runs is_empty (new `hasChildrenByParentUid`), owner and thumbnail concurrently. - Downloads and the video proxy pipe through `pipeToResponse`: aborts release the storage stream at once with no extra 'close' listeners, and the video proxy no longer crashes on a mid-stream upstream error. - mkdir looks up target and parent together. - Upload completion reuses the session from the access check (status is still re-read before completing), and multipart signing reads it once. - Moving a shared file to Trash skips two lookups that always returned the entry itself. Drivers and events - Subscription resolvers run concurrently and are folded in registration order. - SubdomainDriver and WorkerDriver overlap independent lookups. - The events "anyone subscribed?" check is one Redis pipeline when the region is warm. - Handler publish overlaps the count with the size check. - Broadcast flushes peers concurrently over a keep-alive agent, so a slow peer no longer delays cross-region invalidations to the others. Sites, share and AI - Hosted sites resolve the owner first, then the app and root together. Site config loads only on 404s. The hosted-app query selects only the columns it uses and moves into AppStore. - shareFlags asks about the plan only when an item is shared by link only. - Share listings run the count, teams and plan reads alongside their page query. - Claude attachment cleanup no longer blocks the response. - New util `awaitLater` for promises started early and awaited later. |
||
|
|
28f162ee92 |
fix(metering): fence settles, idempotent monthly claim, split addon consumption (PUT-2001) (#4236)
* fix(metering): pin metering writes to one replica, fence settles, idempotent monthly claim (PUT-2001) store-kv-v1 is a last-writer-wins global table, and every region settled its metering buffer into its local replica, so concurrent increments to the same per-actor item from two regions could drop one side. Writes and consistent reads of metering:* and policy:actor:* items now go to the dynamo.home replica. Buffered settles record each write on the claim with a fence token before sending it, and the KV store refuses a token the item already holds, so a re-driven claim applies its write once. Claims are serialized per counter within a cache so one writer's tokens land in order. The monthly charge claim is a set-if-absent of a random token, and a failed claim write keeps its token for the retry, so a write that landed anyway still charges exactly once. * fix(metering): drop home-replica pinning; keep addon consumption off the purchase record (PUT-2001) Pinning every metering write and exact read to one replica put a cross-region round trip on credit checks near the allowance and on every settle, and made one region's outage stall metering everywhere. The per-item clash it guarded against is rare and undercharges at most a flush window. The one clash that loses money is a credit purchase and an overage settle landing on the addons item in different regions at once. Consumption now goes to its own item and getActorAddons sums it with what the purchase record already holds. * perf(metering): read addon consumption only for accounts with purchased credit (PUT-2001) * perf(metering): settle counters through a sliding pool instead of waves (PUT-2001) * feat(metering): consistent-read option on getActorAddons (PUT-2001) Admin credit checks read the purchase record directly to get a consistent read, which misses consumption now that it lives on its own item. They can call getActorAddons with consistentRead instead. Also formats the lines this PR added. |
||
|
|
2b254b8892 |
fix: misc bugs (#4251)
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
Notify HeyPuter / notify (push) Canceled after 0s
release-please / release-please (push) Canceled after 0s
Maintain Release Merge PR / update-release-pr (push) Canceled after 0s
|
||
|
|
2a15afc570 | Resolve PUT-2059 (#4250) | ||
|
|
e5ca303c50 | PUT-1903 (#4226) | ||
|
|
c6c0dd5e29 |
fix: bound how many accounts a team owner can provision (#4223)
Notify HeyPuter / notify (push) Canceled after 0s
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
* feat: bound provisioning by what an owner may mint, not only what is live Seat and team caps express how much may exist at one time. This adds the other half of the rule: how much an owner may create over a window, and the checks a provisioned account answers to. - Provisioning puts each seat through `SignupService.runSignupGate`, the same extension-owned gate a signup answers, and refuses what it blocks. It fails closed, since a check that could not run is not a check that passed. - A per-owner budget over a rolling window, sized to every seat the owner may hold live across every team they may own, times the churn allowed on top. It is spent on creation and is not returned. Keyed on the owner's uuid so a recycled id cannot inherit someone else's spend, and charged after validation so a taken username costs nothing. * refactor: tighten the provisioning gate and budget after review - The gate runs before the seat-cap lock. A listener has no deadline and the lease is ten seconds with nothing to renew it, so the lock must not be held across one. - `failOpen: false` rethrows rather than returning false, so a backend outage is answered 503 instead of escaping the handler. - An address goes through `validateEmail` first, as it does on every other path that creates an account. - The gate always runs; a request only enriches what it is told, so one being absent is no reason to skip it. - The gate is told it is looking at a seat, whose address is optional. - A refusal carries the gate's own code and request code, as a blocked signup does, so support can correlate it. - Restored `provisionAccount`'s docblock, which an insert had displaced. Tests: the spy restores in `finally`, and the cap overrides are taken after the fixtures, so one failure cannot cascade through the file. * fix: show a provisioning refusal as a rule, not a failure The console special-cases the seat cap and sends everything else to "Something went wrong", which reads as a fault rather than an answer. The provisioning budget refuses the same way the cap does, so it is presented the same way. |
||
|
|
ff815f730d |
fix: harden token delivery, session revokes, app identity and fs scoping (#4229)
release-please / release-please (push) Canceled after 0s
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
- relay sign-in popups take the opener origin from the referrer only - session revokes read the primary, batch per user, and tombstone caches in every region - wisp relay tokens bound to the minting session; verify rechecks the account - hosted origins keep app identity with the site owner; deleted app uids recorded in deleted_apps - subdomain cache tombstoned on delete, update, reclaim and app/account cascades - email lookups require an exact address match after the SQL lookup - workers deploy only from files the deployer owns - share_app_data:false enforced at ACL time, plus copy/move/rename/share/recursive listing - kv remove returns no value without read; kv:expire op grant usable - fs errors name the entry instead of the owner's path - shared-by-me drops rows a delegate no longer has authority over - private apps can't move index_url onto another user's origin - GUI: default apps saved and launched by uid; save picker accepts only copy/move; permission dialog waits for focus; Publish as Worker hidden on others' files |
||
|
|
62270ff9aa | Delete worker sessions when workers are deleted, don't treat sessions from deleted workers as still valid. (#4163) | ||
|
|
b52fc01712 |
perf: cut godmode app launch latency (cached token renewal, launch reuse) (#4221)
* perf(auth): renew godmode app tokens from cache instead of the primary Every godmode launch read its token row from the primary and rewrote its expiry, a few cross-region round trips per launch far from the primary. - A cache pointer names the row per (user, web session, app). The row is read through the uuid cache token auth uses and re-checked (owner, session, app, revoked, expired, max age); anything else falls back to the primary read. Revoking the row drops the pointer. - The expiry write is skipped when it would move by under an hour. A renewal that hits a revoked or lapsed row mints a new one instead. - The response carries the row's actual expires_at. * perf(gui): reuse a just-minted godmode token for repeat launches A launch of a godmode app reuses the token this page minted for it within the last ten minutes under the same desktop session, instead of asking again. The cache is dropped when an app reports its token dead, after a session revoke from the session manager or an uninstall, and across tabs via a BroadcastChannel that carries token row ids, never tokens. A mint in flight during a drop is not remembered. |
||
|
|
f9cde23937 |
Merge branch 'filipkujundzic/put-1954-elevenlabs-tts-passes-voice-names-through-as-voice-ids-so' into filipkujundzic/put-1907-ai-streaming-buffering-and-error-shape-cleanup
# Conflicts: # src/backend/drivers/ai-chat/utils/inlineImages.test.ts # src/backend/drivers/ai-chat/utils/inlineImages.ts # src/backend/drivers/util/fileInput.ts |
||
|
|
d8f9aff3dd |
fix: follow-ups to #4161–#4203 (rate limits, broadcast, AI routing and billing, fs subtree walks) (#4205)
* fix(rate-limit): count the newest KV rate rows first The KV backend's rate check, peek and concurrent acquire list the key's rows with a limit + 1 cap that the store applies before dropping expired rows, so unswept rows from past windows (oldest keys) could fill the fetch and the live count read as zero. They now list newest first. * fix(broadcast): split webhook flushes under a byte budget, per-peer backlogs Flushes go out in bodies of at most `broadcast.outboundMaxBodyBytes` (default 4 MiB) instead of one POST, and each peer keeps its own backlog of what it missed, capped by count and four bodies' worth of bytes, oldest dropped first, so one failing peer can no longer grow every peer's body past their 50 MB limit. * fix(ai-chat): classify dropped upstream connections as provider failures A connection reset or refusal (SDK APIConnectionError, Mistral's ConnectionError, an undici reset on the cause) carries no status, so a chain of them fell through to 500 internal_error. Attempts now record connectionFailed; a chain of only resets and 5xx answers 400 upstream_provider_unavailable, and a reset counts as an upstream signal in a mixed chain. * fix(ai-chat): keep SDK retries on Anthropic count_tokens The Claude client is built with SDK retries off so the driver can fall back, but count_tokens has no fallback route. It now passes sdkRetryOptions, like the OpenAI moderation call, so a transient 429 or reset is retried. * fix(ai-chat): mark a route unhealthy for 60 s after a transient failure With SDK retries off, one 5xx, 429, timeout or dropped connection kept a route marked for 10 minutes, sending that model's traffic to resellers. Those failures now mark the route for 60 s; bad credentials, an exhausted account and other route-level failures keep 10 minutes. * fix(metering): don't memoise spent addons inside the force-exact window The spent-addons memo could cache a pre-grant record read from a lagging replica right after a credits-changed announcement, so overage skipped the new credit and requireCredits kept answering 402 for up to 15 s. A spent record is now only cached once the actor's force-exact window has ended. * fix(share): send the digest entries a flush claimed when another take fails Digest entries are taken concurrently, and a single rejected take threw away every entry the other takes had already removed from KV. The flush now logs the failed takes and sends what it claimed; an entry whose take failed stays queued for a later flush. * fix(fs): announce an overwriting move's replaced entry only when it took its name /move and v2 /fs/move looked up the entry at the destination name before moving and always announced its removal. A move into someone else's Trash renames instead of replacing, so the owner's desktop hid a live entry and legacy /move reported it as `overwritten`. Both routes now pass the target on only when the moved entry kept the name it was looked up by. * fix(ai): name the vendor in upstream error fields from media providers upstreamFetch now takes the provider id plus a display label, and UpstreamResponseError carries the id. translateProviderError copies a string `provider` off the error into `fields`, and the BytePlus video and Cloudflare image errors set one, so upstream_auth_failed and upstream_credits_exhausted alarms say which vendor failed again. * fix(teams): delete a provisioned seat whose home folders could not be created provisionUser logs a failed default-folder creation and still returns the account, so provisionAccount went on to add the membership, issue a password, mail the seat and emit team.account.created for an account with no home. It now cascade-deletes the new user and returns 500 internal_error before any of that. * fix(ai-chat): load model catalogs without holding up server start onServerStart awaited every gateway catalog (up to its 10s fetch timeout) while HTTP was already listening, so drivers started after it had no providers yet and chat requests found an empty model map. The first build now runs in the background; complete(), models() and token counting wait on it instead of failing. * fix(ai-chat): serve direct models while gateway catalogs load at boot The first model-map build now publishes a fresh map as each provider's catalog lands, so static catalogs are routable at once. complete() and token counting wait for the full first build only when the model, or the provider asked for, isn't in the map yet. Refreshes still swap once. * fix(ai-chat): split gateway-billed cost across input and output A completion billed at the cost OpenRouter, Infron or Neuralwatt reports was quoted to `ai.prompt.cost-calculated` as all input and no output. The ledger still records one `billedUsage` line; the reported per-key costs now spread that bill over the usage keys at the catalog's relative rates, in whole µ¢ that sum to it. Neuralwatt energy units take no share. * fix(ai-image): hold Replicate credits for the whole prediction The output and input-surcharge holds took the default 10-minute TTL, the same as the prediction window alone, so a slow prediction could outlive them once creation and input measurement were added. They now last 15 minutes, like the video job holds. * feat(ai-chat): report what a completion was charged as usage.cost Chat usage (the non-stream result and the stream's final usage line) now carries `cost`: the µ¢ the ledger records for the request after the cost factor, from the request's recorded charges and the gate's factor lookup. `usd_cents` is unchanged. A hooked recording now copies the usage object first, so keys the driver adds later never land in the ledger. * fix(ai): charge streamed speech once its first byte arrives Per-character TTS and the voice changer recorded their charge when the upstream's response headers arrived, so a body that failed before any audio was billed in full. A shared relay now charges on the first byte, or when the caller hangs up, and charges nothing for an upstream that fails or ends before sending audio. * fix(ai): release credit holds only after the request's usage lands A hold was released as soon as its operation returned, while the usage it stood for was still being written (behind the cost-factor lookup), so a concurrent request briefly saw neither. AI metering now tracks the request's usage writes and its holds (chat included) release once those settle, without delaying the response; a streamed charge chains its own. A failed write still releases the hold, and the hold's TTL backstops it. * test(ai-chat): wait for a stream's credit hold to come back after its usage lands * perf(fs): page subtree delete and copy by parent link, not by depth over the path range Each page of a recursive delete or copy sorted the whole remaining subtree by a computed depth, so a walk cost grew with the square of its size. It now pages the children of up to 100 sibling directories at a time by parent_uid, depth first, keyset on (parent_uid, id), so a page reads only its own rows; rows with no stored path are reached too. SQLite gets the parent_uid index the others have. * fix(fs): find children by parent_uid in the empty check and supplementary-name lookup Rows the old backend wrote carry parent_uid and no parent_id, so a non-recursive delete of a directory holding only such rows passed the empty check and orphaned them, and on MySQL the supplementary-character child lookup missed them, leaving their subtrees out of listings, counts and renames. Both now key on parent_uid; the empty check still probes parent_id too, which the delete cascade follows. * docs(fs): drop the single-POST note from the entry broadcast cap |
||
|
|
b88aa55b24 |
fix: drop moved descendants from the entry cache; read cursor=null as the first page (#4204)
Renaming or moving a directory rewrote its descendants' paths but left their
cached copies (by id, uuid and old path) live for up to 60 s. A lookup of an
old path kept resolving, and a write beneath one landed at a path its parent
no longer had. updatePathPrefixForUser and renameUserHome now drop those keys,
here and, within the broadcast budget, in peer regions.
puter.js sent `cursor=null` for `listShared`/`listSharedByMe({ cursor: null })`,
which decoded to null and failed every keyset listing with a 500. decodeCursor
reads it as no cursor, and the SDK no longer sends it.
|
||
|
|
f07a786b41 |
fix: reserve storage before creating directories on signed write starts (#4161)
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
startBatchWrite and startWrite created directory items and missing parents before (or alongside) the storage reservation, so an over-quota start paid for every directory write, left the directories behind, and waited on them before returning 413. File targets are now resolved and storage reserved first; directories are created only after, and the reservation is released if that fails. |
||
|
|
21131ad3d6 |
fix(kv): spread upload sessions across system KV partitions (#4162)
Upload sessions shared the system namespace's single partition key. They now go to one of 16 system partitions hashed from the session id, via a new system-actor-only `systemPartition` KV option; partitions are never cached and emit no mutation events. Reads fall back to the unpartitioned key for sessions written before this (max session lifetime is one hour). |
||
|
|
590c87c00a |
refactor(stores): one row cache and keyset page helper, batched cache round trips, dead store methods removed (#4191)
* refactor(stores): one read-through RowCache for UserStore and AppStore UserStore and AppStore each carried the same multi-key row cache: key builder, cache read, tombstone-guarded backfill, broadcast refresh, invalidate and tombstone-on-delete. They now share RowCache on PuterStore, which also single-flights concurrent misses for one key and batches getByIds/getByUids: one pipelined tombstone check for the loaded rows and one pipelined backfill instead of per-row awaits. Cache keys and TTLs are unchanged. * perf(apps): invalidate cached app lists by generation, not by tracker scan Every app write SMEMBERS'd a global set of every cached list key, parsed each key back into its filters and deleted the matches. List pages are now keyed by their owner's generation token (or a shared one for unscoped lists), and a write replaces the tokens it affects: O(1) per write, and a page loaded before a write can no longer be cached after it. * refactor(stores): one keyset page helper for store listings Thirteen store listings each cut a LIMIT n+1 read to a page and sealed the last row's position, and each opened an id cursor its own way. They now use keysetPage() and openIdCursor() from util/pagination. Cursor payloads and sealing are unchanged; a cursor without a usable id still fails with 400 in ShareStore and still restarts the listing elsewhere (strict option). * refactor(stores): delete store methods nothing calls AppStore.existsByIndexUrl, OIDCStore.unlinkByUserId, TeamStore.listByOwner/listActiveSeatUuids, SubdomainStore.getByDomain/ listByDomain, FSEntryStore.ensureDirectoriesForUser, ShareStore.listByIssuer/listByRecipientEmail/deleteByRecipientEmail/ getDailyShareCount/listByHolderAndFsentries and PermissionStore.invalidateScanCache/deleteUserUserPermsByPermissionPrefix had no production callers. ShareStore.listByFsentries was a copy of listReaching; its callers use the latter. SubdomainStore SMEMBERS'd a per-user prefix-list tracker on every write and broadcast its deletion, but nothing ever wrote that set or cached a prefix listing. The reads are gone. * refactor(stores): shared escapeLike and bumpGeneration helpers escapeLike replaces the LIKE escaping copied into PermissionStore (twice), ShareStore and FSEntryStore, and now also guards SubdomainStore's prefix filters, which passed caller prefixes into LIKE unescaped. bumpGeneration sends INCR and EXPIRE in one pipeline instead of two awaited commands in PermissionStore, EventSubscriptionStore and PresenceStore. * refactor(stores): read affected rows off WriteResult Every database client returns affectedRows and anyRowsAffected, so the `affectedRows ?? changes ?? 0` fallbacks in ShareStore, NotificationStore, UserStore and UserBlockStore read those directly. * perf(stores): batch flat-permission KV deletes and durable cache drops PermissionStore deleted each flat KV entry with its own kv.del; one kv.batchDel now covers the set. DurableSubscriptionStore's suspend and reap dropped each row from the cache separately; EventSubscriptionStore gains dropDurables, which drops them per owner in one pass, and dropDurable calls it. suspend keeps one conditional UPDATE per row, which is how concurrent settles learn which rows each of them suspended. * refactor(fs): fold FSEntryStore's duplicate getters, sort maps and cache keys - getEntryByUuid and getEntryById share one by-column getter. - listChildren and listChildrenPage read one sort table; the offset listing now coalesces NULL modified/size like the cursor listing, so both order NULLs the same way on every engine. - Entry cache keys come from one builder instead of hand-built strings. - getEntriesByIds, getEntriesByPaths and the per-user path reader fetch their cache hits in one pipelined round trip instead of a GET per key, through readCacheValues, which RowCache.readMany now uses too. * fix(apps): cache the row a lost createFromOrigin race reads from the primary Keeps AppStore's private #writeCache as a RowCache write, so code that still calls it (#4166's read-back) loads in either merge order. |
||
|
|
45f6e9d8ee |
refactor(drivers-wiring): AppService views and launch gates, batched app lookups, shared driver policy and guards (#4186)
* perf(workers): skip hot-reload lookups for files no worker is bound to
The fs.write.file / fs.remove.node / fs.move.node listeners listed every
workers.puter.* row of the file's owner on every event, for every user.
The event's FSEntry already carries the worker rows bound to it, so a file
with none returns before any I/O; a bound file reads just its own rows,
batched, from the primary.
* perf(apps): read the uid alias only after a direct lookup misses
Every AppDriver uid lookup fired an uncached system-KV GetItem for the
merge alias in parallel with the direct read, even when the direct read
hit. The alias is now read only on a miss.
* refactor(apps): one app view with the launch gates, built by AppService
AppDriver's serializer and its private-app / hosted-backing gates move to
AppService.views, built on a pure toAppView that is the one place
index_url is withheld. AppDriver, the subdomains driver's embedded apps and
the GUI shell view all use it. Visibility of protected apps
(filterReadable) moves with it.
Subdomain embeds (associated_app, app_owner) used to return index_url with
no gate; they now withhold it from a viewer who may not open a private app
or whose hosted backing is gone, and carry privateAccess, godmode and
feedback_enabled like a direct app read.
* refactor(apps): one toAppSummary; batch the recommended and taskbar lookups
SuggestedApps, RecommendedApps, /get-launch-apps and the taskbar each built
their own launch summary; they now share toAppSummary (the extra
feedback_enabled / external flags appear on the lists that lacked them).
getRecommendedApps awaited getByName once per recommended app (29) and
getTaskbarItems did the same per taskbar entry on every whoami; both now
make one batched call per key through the new AppStore.getByNames, which
keeps getByName's case-insensitive and old-name matching. The taskbar's
raw UPDATE of the user row goes through UserStore.update, and
/get-launch-apps checks hosted backings in one batch and reaches the
stores without casts.
* refactor(apps): app routes read through AppService; /delete-site calls the subdomains driver
AppController used the global driversContainers through a getter whose
boot-order comment no longer held; it uses the injected drivers and
AppService. GET /apps/:name looked each name up, read it again through the
driver and resolved private access a second time, emitting
app.privateAccess.resolveLaunch twice per private app; it now batches the
names and resolves once. POST /query/app did a lookup, a driver read and a
raw app_filetype_association SELECT per selector (up to 200, serially); it
now makes one batched lookup per key, one grant check and one filetype read.
The driver's read shape is a pure toAppReadView both use.
HostingController's /delete-site re-implemented the subdomains driver's
delete (protected check, delete, subdomain.delete event); it keeps its
owner-scoped 404 and calls the driver. Hosting and Desktop controllers drop
constructors that discarded the drivers argument, and trivial getters.
* refactor(drivers): one per-method policy validator and resolver; drop @Driver
Four policy blocks (rateLimit, concurrent, requireSubscription,
requireReputation) share the { default, methods } shape but each had its
own validator and resolver. They become validatePerMethod(value, label,
policy) and resolvePerMethod(cfg, method); error messages are unchanged.
The @Driver class decorator and the prototype keys it wrote had no users
outside its own tests; drivers declare their fields, and resolveDriverMeta
reads only those. DriverController takes the driver name from the meta it
already resolved instead of probing the prototype.
resolveDriverMethodRateLimit/Concurrent stay as deprecated aliases for the
email extension.
* refactor(drivers): shared actor guards and crud-q concurrency policy
The apps, subdomains, workers and notifications drivers each carried a
private #requireActor, two carried the same #requireVerified, and apps and
subdomains the same owner/app_owner write predicate. These become
requireContextActor, requireContextUserActor, assertActorEmailVerified and
actorOwnsRow in core/actor.ts; each driver keeps its own error for the
cases that differed (system actors still read apps; notifications still
answer 403 for an actor without a user). The identical crud-q concurrency
block is one CRUD_CONCURRENT constant.
The metering, installedApps and appTelemetry extensions re-checked an actor
their routes already require; those checks and the tests for them go.
* refactor(extensions): store methods instead of raw SQL in OSS extensions
installedApps and appTelemetry queried user_to_app_permissions directly
and metering read apps by name with its own SELECT. PermissionStore gains
listAppsGrantedByUser, listAppAuthenticatedUsers (with the email-share
check folded in) and countAppUsers; metering uses AppStore.getByName.
* refactor(server): one layer builder and one lifecycle hook runner
PuterServer built each of its five layers with its own copy of the same
loop (built-ins, then extension additions, into the layer and its export
container), and ran each lifecycle hook with five near-identical loops per
hook. Both are now one helper each: #instantiateLayer and
#runHook(order, hook). Construction order, the args each layer receives,
and hook order (start and prepare bottom-up, shutdown top-down) are
unchanged.
* fix(workers): tear down a deleted source's worker after its FK nulls root_dir_id
|
||
|
|
d162549c1d |
refactor(controllers): one signup, login, confirm and window.user path; userProtected/internalAuth route options (#4189)
* refactor(auth): one signup path for form, temp, OIDC and team accounts Signup steps (validate hook, phone/card forcing, bonus verdict, provisioning, signup events) move from AuthController and OIDCService into SignupService. Username rules, availability and generation move to util/username.ts, and user-row + group + default-folder creation to provisionUser, which TeamService and DefaultUserService now share. Behaviour fixes: a provisioned team seat joins default_user_group like every other account, and generated temp usernames skip names whose home path is occupied. * refactor(auth): one web-session start for password and OIDC sign-in startWebSession creates the session with the request's IP, user agent, origin and host and sets the session cookie. OIDC sign-ins used to create their session without that metadata, so they showed up blank in the session list. * refactor(auth): one confirmEmail for the code and link confirmation routes Both routes now re-run the email checks, refuse an address another account confirmed, strip it from other rows, promote the group and push user.email_confirmed to the account's sockets (with the originating socket id on the code route). The link route's full-table UPDATE clearing other accounts' pending change-email requests is dropped: /change_email/confirm already refuses an address a confirmed account owns. * refactor(auth): build window.user once for whoami and sign-in buildUserDetails and scrubSensitive move from the whoami extension into util/userDetails.ts. /whoami calls them; sign-in responses now return the same user-actor payload, scrubbed, so the login copy no longer ships the phone number and a no-reload login gets oidc_only/otp/directories up front. Taskbar resolution is best-effort on both. * refactor(http): userProtected route option replaces AuthController's route walker copy The user-protected gate is materialized from a userProtected RouteOption (implies requireUserActor), so AuthController no longer re-implements the decorator walker to wire it per instance; those routes and the session routes are plain decorators now. AuthRequired also counts requireVerified and userProtected. * refactor(http): internalAuth route option and a shared bodyRecord EventsController and PeerController each checked x-puter-internal-auth by hand with different compares; an internalAuth RouteOption now picks the secret from config and one constant-time gate checks it. ShareController and EventsController share bodyRecord instead of private copies. * fix(fs): cap legacy /delete at 500 paths per request The bulk form resolved, ACL-checked and removed an unbounded paths[] list one by one. It now refuses more than FS_BATCH_WRITE_MAX_ITEMS (500), the cap the batch write routes already use, before touching any entry. * refactor(controllers): user and subdomain writes and reads through their stores AuthController's 2FA setup/enable/disable and recovery-code writes go through UserStore.update instead of raw UPDATEs plus a manual invalidate, and /readdir-subdomains pages SubdomainStore.listByUserId instead of an unbounded SELECT; the response shape is unchanged. * refactor(auth): note SignupService's call-time peers * fix(auth): type userDetails' taskbar deps independently of TaskbarDeps.clients |
||
|
|
576e893a5b |
refactor(services-core): read-only socket reauth, one token-grant read per check, shared grant/revoke and hosted-asset paths (#4187)
* perf(socket): re-check live sockets without touching their sessions
The 5-minute sweep reused the request-path authenticate(), which slid
every connected session's expiry and wrote user activity each tick, and
could fill SessionStore's touch-throttle map. authenticate() now takes
touch: false for re-checks; the sweep uses it, checks credentials
through runWithConcurrencyLimitSettled, and a call while a sweep runs
joins it instead of starting another.
* perf(acl): read a scoped token's grants once per check
ACLService.check did a Redis GET + JSON.parse of the token's grant list
for every ancestor x mode, and the permission scan did the same per
option. Both now read the list once (PermissionStore.listAccessTokenPerms,
which replaces the per-permission hasAccessTokenPerm) and test it in
memory.
* refactor(metering): incrementUsage goes through batchIncrementUsages
incrementUsage was a copy of the batch path that let an infinite amount
through and skipped NaN, where the batch path counts either as one unit.
It now calls batchIncrementUsages with a one-entry batch, so both apply
the batch clamping (documented on batchIncrementUsages). The overuse
alarm loses the single-path fields nothing sets any more.
* perf(metering): memoize an actor's spent addons on the increment path
Every increment and credit refresh read the actor's addons record from
the KV store. A record with no purchased credit left can only change
through a grant, and every grant announces credits-changed, so it is now
kept per actor beside the credit memo and dropped with it. Records with
credit left are still read fresh, since increments on other nodes draw
them down without announcing it. A read that straddles a drop isn't
kept.
* refactor(services): route raw table writes through their stores
- access_token_permissions: AuthService inserted grants one row at a
time through a cast to the store's db client, read the authorizer, and
deleted the manifest with raw SQL, then asked PermissionStore to drop
its cache. PermissionStore now owns insertAccessTokenPerms (chunked
multi-row insert), deleteAccessTokenPerms and
getAccessTokenAuthorizerId, and invalidates inside the writes.
- TeamService's 2FA reset clears the OTP columns through UserStore.update.
- AppIconService's data-URL icon swap is AppStore.replaceDataUrlIcon.
* fix(user): correct cascadeDelete's FK note; seat cleanup is best effort
The header said most user_id foreign keys are ON DELETE SET NULL; the
migrations cascade nearly all of them and keep SET NULL for audit and
analytics history. forgetSeat ran unguarded after the row was already
gone, so a cache failure there failed the whole delete; it is now
logged and stepped over like the other teardown steps.
* refactor(acl): one grant/revoke path per holder kind
ACLService's user and group stat/set pairs were copies differing only in
which PermissionService methods they called. They now share #stat and
#set over a holder adapter ({ lockKey, query, grant, revoke }); lock keys
and errors are unchanged.
PermissionService's eleven grant/revoke methods repeated the same guard
blocks. Each keeps its own order of checks, now through shared helpers
(#requireUserId, #refuseAppActor, #assertCanManage, #requireUser,
#requireApp, #assertFitsColumn, #audit), so every refusal keeps its
status, legacyCode and wording. Audit writes stay fire-and-forget.
* refactor(auth): one hosted-asset token API keyed by kind
The private-asset and public hosted-actor cookies had separate create/
verify methods, four cookie-name getters and two identical options
getters, differing only in the kind claim and what the host field was
called. They are now createHostedAssetToken(kind, claims),
verifyHostedAssetToken(kind, token, expected),
getHostedAssetCookieOptions(), and the exported HOSTED_ASSET_COOKIES
names. Token claims, cookie names and every error message are unchanged;
puterSite and privateAppGate call the new methods.
* refactor(auth): one revoked/expired check for every actor builder
The session, app-under-user and access-token builders (and the access
token's parent-session check) each spelled out the same revoked/expired
tests. They now share #endedSession, which returns the same reauth
results; only a session token passes its auth_id. Adds coverage for an
access token whose parent session expires, is revoked, or is gone.
|