Commit Graph
6368 Commits
Author SHA1 Message Date
Juan Castro 07e73efa40 Merge remote-tracking branch 'origin/main' into juancastro/put-1568-rename-srcguisrchelpers-files-to-camelcase 2026-08-25 15:10:29 -04:00
404oops 2a45a10854 Merge pull request #3630 from HeyPuter/FK/dedupe
refactor: dedupe model catalog aliases and stop mutating shared catalogs
2026-08-25 18:07:27 +02:00
Nariman Jelveh e052aec7fb Carry a share link through the OIDC round trip (#3642)
A share email lands on `/?shared=…`, and its recipient is signed out or
in a temporary session more often than not. Password login reloads the
same URL, so the link came back with it — but an OIDC login leaves the
origin and returns to a URL the backend builds from a whitelist of bare
paths. `return_to` was a pathname only, and `/` wasn't on the list, so
the recipient came back signed in on the Dashboard's Apps tab with
nothing to say what had just been shared with them.

`shared` now travels with the path. The GUI sends it along with the
return path; the backend accepts it as the one parameter a `return_to`
may carry, and only values shaped like the masked share path the mail was
built from — everything else rejects the whole value, the path is still
matched as a raw string so a protocol-relative one can't smuggle an
origin through, and the query is rebuilt rather than reflected. The items
ride onto the error page too, so a failed attempt retries on the link
instead of losing it.

The root joins the whitelist for this, but only when it names something:
on its own it is where the flow already lands.
2026-08-25 08:42:47 -07:00
Daniel Salazar cf4d743854 fix: testing for peer (#3643)
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
2026-08-25 08:31:45 -07:00
Daniel Salazar 3c866c645b feat: guest turn for peer (#3640) 2026-08-25 07:51:02 -07:00
Juan Castro 4a5d689af7 refactor: rename GUI helper files to camelCase
src/gui/src/helpers was mostly snake_case, disagreeing with itself and
with the camelCase file convention in AGENTS.md. Rename the 45 offenders
with git mv and repoint every import specifier at the new paths.

Files only — exported symbols are unchanged. applyItemAddedToContainers.js
still exports apply_item_added_to_containers and no call site is edited
beyond its import line; those identifiers cross module boundaries and some
hang off window, so renaming them is a separate, riskier change.
2026-08-25 10:49:59 -04:00
jelveh 6f1548e26a Move Share above Download in file context menus 2026-08-25 07:28:53 -07:00
Daniel Salazar 764ce2f8e2 feat: rep score middleware gateway (#3639) 2026-08-25 07:11:41 -07:00
Daniel Salazar 717c098503 fix: metering buffer improvements for manual editing (#3633)
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
2026-08-24 18:36:43 -07:00
Nariman Jelveh 2c9a5c4f7e Land share-email links in the Dashboard's Shared view (#3634)
* Land share-email links in the Dashboard's Shared view

A share email's links opened the item on the desktop, and "Open Puter"
went to the bare origin — the recipient arrived on Home with nothing to
say what had just been shared. Every link now lands in the Dashboard's
Files tab, on Shared, with the shared items selected.

Each named file still links to itself. "Open Puter" carries every item
in the mail (?shared=a&shared=b), so the whole batch lands highlighted;
the digest records now keep each item's masked path for that. The
button's href is rendered raw like the item links, so `=` and `&` read
the same in the html and text parts, and the unsubscribe link is built
from a separate origin value.

On the GUI side `/?shared=` boots the dashboard rather than the desktop;
`/desktop?shared=` keeps opening the item in place. The Files tab
selects the rows by uid, which survives a rename. A share only reaches a
real account, so a share link never mints a temp user and an existing
temp session is asked to sign in first, as the desktop already did. The
address-bar cleanup both shells need moves into a helper.

* Keep the share email's button link within what mail clients tolerate

The "Open Puter" link names every item in the mail, capped at twenty. But
a single item's parameter is ~150 characters once the owner, uuid and an
encoded name are in it, so twenty of them run to several kilobytes — past
the ~2000 characters where older mail clients cut a URL off or stop making
it clickable — and the count alone couldn't do what its comment promised
for the mail's primary button.

Add a length budget beside the count: items go in, in digest order, only
while the whole link stays under it, so the button always works and the
first items are the ones highlighted. A single item always fits.

* Keep a share link's own item however long its name runs

`shareDeepLink` now builds through `sharedViewLink`, whose length budget
applied to the first item too: a parameter that alone overran it was
dropped, and the link came out as a bare `?shared=`. A name of a few
hundred characters — the GUI allows five hundred, and encoding triples
every non-ASCII one — was enough, so that item's own link in the mail
landed on Shared with nothing picked out, where it used to open the item.

The first item now always goes in; the budget only decides how many more
join it. One long link is still the item the mail is about, and it is no
worse than a bare origin for the clients that truncate it.
2026-08-24 17:01:55 -07:00
jelveh 34af8d18d0 Share a whole selection from the Dashboard's Files tab
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
Sharing was one item at a time: the only way to give someone four files
was to open the dialog four times. A multi-selection now offers Share in
its context menu and in the mobile selection bar, and UIShareModal takes
a list of items.

With several items the access list folds into one row per person rather
than repeating per file, so a grant, a mode change or a revoke is one
decision about a person. A row says what it can't otherwise show: how
much of the selection the person reaches ("On 2 of 4 items", with an
"Add to all" that extends them), a mode select that rests on a
placeholder when their grants disagree rather than presenting one item's
mode as the batch's, and inherited grants left uncontrollable where they
belong. Changing a mode touches only the items the person already holds
— nothing here widens access without saying so.

Requests are chunked to the documented 50-items-per-request cap, and a
selection past it skips the per-item listing instead of firing one
request per file on every refresh.

The eligibility rule the Share entry already used moves into a shared
can_share() helper, since the multi-select menu needs the same answer
for every row.
2026-08-24 14:08:37 -07:00
Juan Fernando Castro 0527bcd98e Merge pull request #3632 from HeyPuter/juancastro/put-1560-file-sharing-deeplink-in-emailnotifications
🔧 PUT-1560: Deep-link the shared item from email and notifications
2026-08-24 12:30:07 -04:00
Juan Castro ea530e604c Merge branch 'main' into juancastro/put-1560-file-sharing-deeplink-in-emailnotifications 2026-08-24 12:21:21 -04:00
Juan Fernando Castro 97d137b94b Merge pull request #3623 from HeyPuter/juancastro/put-1564-perms-docs-drop-the-app-root-dir-raw-permission-string-wire
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
🛠️ PUT-1564: Perms docs — drop the app-root-dir raw string, wire the playground examples
2026-08-24 10:59:56 -04:00
jelveh 1ceffbe291 Add sudoku to recommended apps
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
This change adds Sudoku to the curated recommended apps list so it appears alongside the other bundled games in the app recommendations flow.
2026-08-23 21:59:52 -07:00
Daniel Salazar 684d6752f9 fix: provide fallback for signup verification (#3631) 2026-08-23 21:49:35 -07:00
jelveh 76cea02513 Treat .md like .txt; remove markus suggestion
Merge the .md extension case into the plain-text branch so it suggests ['editor', 'code'] instead of ['markus', 'editor', 'code']. Update the related test to use 'viewer'/'png' as the built-in guard example since 'markus'/'md' no longer applies.
2026-08-23 21:17:28 -07:00
404oopsandClaude Fable 5 308b78e96c refactor: dedupe model catalog aliases and stop mutating shared catalogs
- Add modelLookupNames() and route every provider's list() through it so
  repeated or self-referential aliases can't double-list a model.
- Stop the chat/image/video drivers' #buildModelMap from writing through
  to the module-level catalog constants (ids/aliases accumulated across
  instantiations); bucket copies preserve the models() wire shape.
- Remove self-aliases and exact duplicates from the claude, deepseek,
  groq, mistral, and openai catalogs; fold the duplicated
  gemini-3.7-flash entry — this corrects the live cached-token price
  from 8 to 7.5 and the knowledge date to 2026-03 (the previously
  effective first entry carried stale values).
- ClaudeProvider: derive default max_tokens from the resolved entry
  instead of a second name-based lookup that capped dated ids at 4096.
- Add cross-provider catalog invariants (modelCatalogs.test.ts) with a
  disk-scan registration guard, plus regression tests for catalog
  non-mutation, alias-dedup routing, wire shape, and max_tokens ceilings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-24 03:42:47 +02:00
nevrkla 2d787c3fdf feat(i18n): add Czech GUI localization (#3617)
* feat(i18n): add Czech GUI localization

* feat(i18n): add Czech GUI localization

* fix(i18n): polish Czech GUI translation
2026-08-23 17:22:44 -07:00
Daniel Salazar 5918e3f4e2 fix: kv max number value (#3629)
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
26.08.2
2026-08-23 06:31:08 -07:00
Nariman Jelveh 2c9e818e5f Docs/remove discord reddit links (#3628)
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
* docs: remove Discord and Reddit links

Drops both links from the docs README (header row and Support list) and
from the footer generated on every built docs page.

* docs: remove Mastodon link
2026-08-22 22:14:56 -07:00
Nariman Jelveh 8826a9fc51 Remove Discord and Reddit links from README (#3627)
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
Removed links to Discord and Reddit from the README.
2026-08-22 13:07:33 -07:00
Daniel Salazar 8307e8c9f0 chore: bump up deps (#3626)
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
2026-08-21 21:03:54 -07:00
Daniel Salazar 732786a029 fix: clean up email validation (#3622) 2026-08-21 20:27:05 -07:00
Juan Castro 202c43f46e feat(share): deep-link the shared item from email and notifications
A share email named the file but had nowhere to go: the only link was "Open
Puter", and finding what someone shared meant hunting for it under Shared. Each
named file in the digest now links to itself, and a notification covering a
single item points at that item.

The link carries one parameter, the masked path a recipient is already given:

    https://puter.com/?shared=%2Falice%2F<uuid>%2Freport.txt

Its second segment is the uuid, so there is no second copy of it to disagree
with the path, and the GUI can still find the entry when a rename has left the
name segment stale - it stats the path, then falls back to the uuid.

Built from the owner, uuid and name rather than from `ResolvedShare.path`.
That path is masked for whoever made the request, and the issuer owns the
entry, so it comes back as the owner's *real* path - mailing it would tell the
recipient which folders the owner keeps things in, which is the one thing
masking exists to prevent. A test asserts the real path never reaches the mail.

`digestLines` now returns `lead`/`items`/`trail` beside `what`, so the template
can put an anchor around each name while Handlebars keeps escaping the names
themselves; the URL is machine-built from the configured origin and one encoded
path, so it stays literal. Concatenating the parts reproduces `what` exactly,
which a test pins - the linked and sentence forms must not describe different
shares.

Notifications carry the masked path rather than a URL: the recipient is already
in the GUI, which opens the item in place instead of reloading. Only a
single-item notification gets a target; folding into a group drops it rather
than picking one of five.

In the GUI, `?shared=` joins `?download=` and `?app=` as a param that keeps the
desktop booting at `/`, and the handler reuses the `/@user` public-folder flow -
extracted to `open_path_target`, which carried a TODO asking for exactly this -
so a file opens in its associated app and a folder in an explorer window. The
param is stripped from the address bar first, so a reload lands on the desktop
rather than opening the item twice.

Invites are deliberately not linked: there is no account to route to yet, and
the invite's own call to action is to create one.

Rolling-deploy safe: a digest entry queued before this has `names` and no
`items`, and still flushes - without links. New entries write both, so a node
on the previous build can flush them too.
2026-08-21 16:08:39 -04:00
Juan Castro e7be457a56 docs: redirect the retired perms pages, add the missing playground examples
Both were left as follow-ups on the previous commit.

Thirteen `puter.perms` method pages went when the surface collapsed onto
`request(resource, details)`, and `requestAppData` moved to `/Perms/appData`.
All fourteen had shipped, so an external link or a bookmark landed on nothing.
The build already generates meta-refresh redirects from a map; they are entries
in it now. Pages that only ever existed on the branch that removed them are
deliberately absent — nobody can hold a link to a URL that was never published.

Six documented APIs annotated a code block with a playground id but had no
example file behind it, so the build warned on every one and the Try-it link
resolved to nothing: `fs.share`, `fs.unshare`, `fs.listShared`, `fs.getShares`,
`ai.txt2speech` with Speechify, and `ui.showFeedbackDialog`. The files are the
documented blocks themselves, extracted rather than rewritten, so the example
and the page it appears on cannot drift. Each is indexed in `examples.js` beside
its siblings.

The build now runs clean: 12 warnings to none.
2026-08-21 11:26:32 -04:00
Juan Castro 9bf49b7574 docs(perms): drop the app-root-dir raw string, wire the perms playground
`app-root-dir:` was listed among the raw permission strings, but it reads as
nothing during a permission check - the rewriter yields the sentinel outside a
grant, so `check()` reports it ungranted even once it is and `request()` prompts
every call. Both contradict what the same page promises. The `'appRootDir'`
resource is the supported path and asks the server instead; the list now says so
rather than implying the string behaves like its neighbours.

The playground listed no perms examples at all: six files existed but nothing
referenced them in examples.js, so a freshly-reshaped API shipped with nothing
runnable. Adds a Perms group after UI, matching the sidebar's order, and two
examples for the features that had none - one prompt covering several resources,
and check() deciding whether to prompt.
2026-08-21 10:20:05 -04:00
f0cd251626 🛠️ PUT-1521: Cleanup puter js permissions api + backend routes (#3607)
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
* refactor(puter-js): collapse puter.perms request* to resource + access

Fifteen request methods differed only by a folder name or an access level, so
every new resource meant another method. Replace them with requestFolder,
requestApps, requestSubdomains and requestAppRootDir, each taking the access
level as an argument.

The old names stay as @deprecated aliases: puter.js ships unpinned from
js.puter.com/v2, so removing them would break live apps. They remain in the
generated declarations because stripInternal has no effect on declarations
emitted from JavaScript, and hand-omitting them would break TypeScript callers
the runtime still serves.

Also drops the user-to-user and user-to-group grant wrappers (groups.js and the
grantUser/grantGroup half of grants.js) plus the req_ shim, none of which were
documented or called. The app, origin and dev-app grants stay: the dashboard
uses puter.perms.revokeApp() to clear grants on app uninstall.

* docs(perms): document the collapsed puter.perms surface

Replace the twelve one-method-per-task pages with requestFolder, requestApps
and requestSubdomains, and rewrite the Perms overview around the seven public
methods. The deprecated aliases keep working but are no longer documented.

Boy Scout: drops the long-dead commented-out grantUser/revokeOrigin sidebar
block for pages that were never published.

* refactor(perms): drop unused user-to-user and user-to-group permission routes

Filesystem access is shared through /share, which records the grant so the
owner can see and revoke it. The older direct-grant paths were left behind with
no caller anywhere - not the GUI, not a doc, not an app: grant-user-user
(already a 501 stub), revoke-user-user, grant/revoke-user-group, and the five
/group/* CRUD routes.

Removing them orphans PermissionService.grant/revokeUserGroupPermission and its
group-members cache bump, the three PermissionStore group writers, and six
GroupStore methods, so those go too.

What stays, and why:
- grant/revokeUserUserPermission - ACLService and ShareService power fs.share
  through them.
- The group permission read path (#scanUserGroup, readUserGroupPerms) - a
  migration seeds the admin group unrestricted driver access, so it is
  load-bearing.
- GroupStore getByUid/addUsers/removeUsers - signup, save_account, OIDC and the
  self-hosted default user assign group membership.

No schema change: user_to_user_permissions, user_to_group_permissions and their
audit tables are untouched. Group rows now come only from migrations, so tests
that need one seed it with SQL the way a migration does.

* refactor(perms): reduce GroupStore to membership writes

With the /group/* routes gone, `getByUid` had no production caller left — the
routes were the only thing that read a group back. Removing it takes the row
decoder and the GroupRow type with it, since they exist only to shape its
result.

What remains is `addUsers`/`removeUsers`: signup, save_account, OIDC and the
self-hosted admin bootstrap all assign group membership. Permissions attached to
a group are read through PermissionStore, which joins the junction table itself
and never needed the store.

Tests that wanted a group id now select it, which is all `getByUid` was doing
for them.

* chore(perms): drop the three by-hand groups no code reads

freeai, experimental and dangerous exist in prod but in no migration — they
were added by hand when hardcoded permissions were keyed by group name. That
map is now a flat per-user floor (`default_user_permissions`), so a group
nothing looks up grants nothing.

Guarded rather than unconditional, because both tables the delete can reach
cascade: dropping a group that still carries permissions or members would
silently revoke them from every member. Only a group with neither goes. One that
survives has dependents and needs a deliberate decision — query
user_to_group_permissions by group_id to see what it holds.

system, admin, user and temp are untouched: config names two of them and code
names the others.

Matches on `extra.name`, not `metadata.name` — `metadata` carries the display
title and colour, and `critical: true` is set on all of these including freeai,
so it does not discriminate.

* feat(puter-js): collapse puter.perms onto request(resource, details) + check()

One method per task meant a new method, doc page and sidebar entry for every
resource. `request` now takes the resource and a payload whose accepted fields
depend on it, and `check` answers the same question without prompting.

    request('folder', { name: 'Documents', access: 'write' })  -> path
    request('apps', { access: 'read' })                        -> boolean
    request('email')                                           -> address
    check('folder', { name: 'Documents', access: 'write' })     -> boolean

Returns stay per-resource: a folder gives its path, email the address, the rest
a boolean, and anything denied is falsy so one `if` covers both.

An array asks for several at once. Everything already held is settled first, so
the prompt covers only what is missing and does not appear when the whole set is
held - the user answers once for the lot. `check` answers per entry, in order,
so a caller can tell which parts are missing rather than only that some are.

Each resource declares four things in one registry entry: how to ask for it
alone, whether it is held, the strings a batch pools into a prompt, and the
value once held. The strings themselves are defined once in
lib/permissionStrings.js, so a request and its check cannot name them
differently. `check` is built on /auth/check-permissions, already live and
already used by UI.js, and it throws rather than answering false when the check
cannot run: a caller that cannot tell "denied" from "never ran" would prompt
someone who had already granted it.

Backward compatibility: all 22 older methods stay callable and typed, marked
@deprecated with the call that replaces them. A lone string still routes to the
raw-permission path - no resource name contains a `:` and every permission
string does, so the two forms cannot collide. The grant/revoke app methods are
untouched; the consent dialog and the dashboard's uninstall path use them.

Also drops three copies of the access-level assertion onto one shared
validator, and gives `appRootDir` a non-prompting server probe, since
`app-root-dir:` only resolves while a grant is being written and a permission
check on it always answers false.

* docs(perms): document request() and check() as the perms surface

Five per-method pages became one `request()` page carrying the resource table,
the batch form and the raw-string escape hatch, plus a `check()` page. The
overview is rewritten around the two methods.

requestAppData's page is re-homed as /Perms/appData rather than deleted - its
scope table, private-entry guidance and lifetime notes are not signature
documentation and have nowhere else to live. Inbound links from KV/set.md and
Objects/app.md follow it.

Playground examples move to the new call form. They are not wired into
examples.js, but an example demonstrating a deprecated method is worse than one
nobody loads.

* fix(perms): keep /auth/revoke-user-user as a deprecated route

Dropping this route with the rest of the unused user-to-user plumbing went too
far. The grant side is retired and stays retired - puter.fs.share() is the only
way in - but access those grants left behind has to remain withdrawable, and a
caller reaching the endpoint over HTTP directly had no replacement. Revoking can
only ever narrow what someone can reach, so keeping it carries no risk.

revokeUserUserPermission never left the permission service; it is load-bearing
for puter.fs.share(). This only re-wires the handler to it, with the gates it
always had.

Nothing in this repo calls the route, which makes it exactly what a later
cleanup reads as dead, so a test pins the registration and its gate alongside
the restored 400 and grant/revoke round-trip cases.

The 501 stub at grant-user-user and the never-called /group/* routes stay
deleted, as does puter.perms.revokeUser - puter.fs.unshare() replaces it and
falls back to live grants when no share row exists.

* fix(perms): let a write grant satisfy a read check on apps and subdomains

`apps-of-user:<uuid>:write` covers managing the user's apps, which includes
reading them, but nothing said so to the permission system. Prefix implication
only widens the other way — an `apps-of-user:<uuid>` grant covers both modes —
so a scan for `:read` missed a `:write` grant, and `puter.perms.check('apps')`
reported an app holding write as holding nothing. A batched request would then
prompt again for access already granted.

Adds the read-from-write exploder for both namespaces, mirroring
`fs-access-levels`. The widening runs one way only, and does not cross into
another user's namespace; both are covered by tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(perms): answer the app-root-dir check without provisioning it

`/auth/request-app-root-dir` conflates two questions: may the caller claim its
root directory, and where is it. The second provisions `AppData/<uid>` on first
ask, so a caller that only wanted the first — `puter.perms.check('appRootDir')`
— created a directory by asking about it.

Adds `check: true`, which runs the same actor guard and stops at the answer.
A caller that may not claim it still gets the 403, so the flag can't widen
anything. Existing callers are unaffected: without it the route behaves exactly
as before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(perms): put request() and check() on one path

`request` dispatched to the old per-task methods while `check` asked the
permission tables, so the two answered different questions about the same
access. Concretely, before this: `request('folder', { access: 'write' })`,
`'apps'`, `'subdomains'`, `'appData'` and `'permission'` prompted every time,
whether or not the access was held — which the docs said they wouldn't;
`check('folder')` reported false for a folder the app could read through an ACL
grant that no `fs:` string names, so a batch prompted for it needlessly; a
batch entry for `'appRootDir'` skipped the post-grant retry the single call
does, resolving `undefined` after a grant that had in fact succeeded; and an
N-entry batch made N permission reads plus 2N `whoami` calls.

Both now run the same pipeline — resolve the permission strings, read what is
held once, prompt for the remainder, resolve each entry — with per-resource
hooks for the parts only that resource can answer. So a batch costs one
permission read and one `whoami`, a check reports exactly what a request would
skip the prompt for, and `'folder'` uses the same stat-or-permission reading in
both.

Also:

- A resource is looked up as an own property, so `request('constructor')` is
  the permission string it always was rather than a TypeError.
- A permission read that fails no longer decides anything: `request` falls
  through to the prompt it would have raised anyway, `check` throws. Before,
  `check('appRootDir')` folded a failed check into "not granted", which is what
  the documentation says must not happen.
- Drops `requestFolder`, `requestApps`, `requestSubdomains` and
  `requestAppRootDir`. They were added in this branch and immediately deprecated
  — never shipped, and `request()` no longer needs to route through them. The 22
  methods that did ship keep their exact behaviour, prompting without consulting
  what is held, which the suite now asserts alongside the new behaviour.
- Documents `'appRootDir'`, which was a supported resource in every overload and
  in `PermsResource` but named in none of the docs.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

* fix(perms): act on review of the three preceding commits

- `src/puter-js/test/perms.test.js` still called `requestApps`,
  `requestFolder`, `requestSubdomains` and `requestAppRootDir`, which the
  previous commit removed. Four cases in the interactive browser harness threw.
  Pointed at `request(...)` instead.

- `request('appRootDir', …)` made two round trips where the shipped method
  makes one: a read-only probe, then the call that names the directory. A
  request is going to claim it either way, so the claim is now the check, and
  the entry it returns carries through to the result. `check` keeps the
  read-only mode, which is the reason that mode exists. Matters because the
  route sits on the FS_SIGN bucket, shared with signed-URL minting.

- `requestPermission` is one of the shipped methods, and the previous commit's
  message was wrong to say all 22 keep their exact behaviour: it forwards to
  `request`, so it now settles a permission the caller already holds instead of
  prompting for it. The value can differ, not just the prompt count — a user who
  would have clicked Deny on a re-prompt used to get `false`. It is the more
  honest answer (the app does hold the access, and denying a re-prompt never
  took it away), but it is a change, and the suite assertion had been switched
  to an unheld permission, which hid it. Asserted both ways instead, in the unit
  tests and the API suite.

- An entry that names no permission no longer rides a grant given for the other
  entries in the same call. Unreachable today — every resource either names one
  or reports itself held — but nothing pinned it.

- Reverted three type-union reformats in `LegacyFSController.ts` that a
  formatter had folded into the app-root-dir commit. That file was not
  prettier-clean to begin with; reformatting it is somebody else's change.

- Docs and types: `Perms.md`'s `appRootDir` row now matches `request.md`'s,
  `check.md` says that a `true` is per entry and a batch still prompts if any
  one entry is missing, and `types.js` no longer names `requestFolder` /
  `requestAppData` in prose that ships in the generated declarations.

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>
2026-08-20 23:48:08 -07:00
ZHOU7821 943adae622 docs: use absolute link to router page in workers create docs (#3615) 2026-08-21 13:30:30 +07:00
Daniel Salazar 65572dcf23 fix: nicer sms card fallback (#3621) 2026-08-20 23:12:09 -07:00
ZHOU7821andZHOU7821 b3923f86da docs: fix typo in workers description (#3614)
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
Co-authored-by: ZHOU7821 <318904296+ZHOU7821@users.noreply.github.com>
2026-08-21 12:26:20 +07:00
Nariman Jelveh 729114bd09 Prioritize app-center in recommendations
Reordered the recommended app list so app-center appears earlier and dev-center is kept at the end. This keeps the default app suggestions aligned with the expected homepage/app-center placement without changing behavior beyond ordering.
2026-08-20 19:16:03 -07:00
Nariman Jelveh da1c85d3c5 Add Meetings to recommended app defaults
Include `meetings` in `RECOMMENDED_APP_NAMES` so it appears in the default recommended apps set alongside other core productivity tools.
2026-08-20 17:16:39 -07:00
Nariman Jelveh 66410d7f1f Revert "Let a browser send the device fingerprint header it is offered"
This reverts commit fef1337b2b.
2026-08-20 17:10:55 -07:00
Nariman Jelveh 76bd05f228 Dashboard: let the trial be a detail line, not an alarm
The Usage card gives each of its three columns a meter and two lines. The
plan column had a boxed, tinted, bordered paragraph instead — and at the
~230px a third of that card comes to, thirty words of it wrapped into a
five-line ribbon standing twice as tall as the storage and resources meters
beside it. The box is the shape we use for the dunning banner. A trial is a
date you should know about, not a fault, and it was reading as the loudest
thing on the card.

So it takes the slot its neighbours use for "n% of 100 GB": a quiet hint-
coloured line under the badge. All three columns now measure the same.

The copy stopped repeating itself while it was there. The badge said "ends
Sep 20" and the sentence beneath it said "ends on September 20, 2026" — two
elements, one fact, and neither of them answering the question someone
actually has, which is how much room is left to decide. The badge now
carries "31 days left" and the line under it carries the consequence:
"First charge Sep 20 unless you cancel." Billing keeps the full sentence and
the opt-out, and Manage is directly below.
2026-08-20 16:59:07 -07:00
Nariman Jelveh fef1337b2b Let a browser send the device fingerprint header it is offered
`fingerprint.ts` reads the device fingerprint from a request body or, for
authenticated requests with no body to carry it, from
`x-puter-device-fingerprint`. That header is documented there as "the
header the GUI may send the device fingerprint on for non-signup
requests", and the middleware has always honoured it.

A browser could never actually send it. The CORS allowlist in
`#installCors` does not include it, so the preflight comes back without
it in `Access-Control-Allow-Headers`, the browser abandons the request,
and `fetch` rejects before anything reaches the server. Nothing in tree
sends the header today, which is why this went unnoticed: the first
client to try it sees every call fail with a network error rather than a
readable status.

Adds the header to the allowlist, and a preflight test asserting the
browser is allowed to ask for it.
2026-08-20 12:40:48 -07:00
404oops 58794f4f76 feat: add MetaProvider for Muse Spark models and integrate with ChatCompletionDriver (#3616)
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
- Implemented MetaProvider to interface with Meta's Muse Spark models.
- Integrated MetaProvider into ChatCompletionDriver, allowing for dynamic model selection.
- Added unit and integration tests for MetaProvider to ensure functionality and reliability.
- Updated documentation to include Meta as a supported vendor for AI models.
2026-08-20 15:09:56 -04:00
Nariman Jelveh 9809e428b5 Update shared label in email template
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
Adjust the share-notification email copy to reference the current "Shared" files section instead of "Shared with me," in both HTML and plain-text variants. This keeps onboarding messaging aligned with the UI.
2026-08-19 20:05:56 -07:00
Nariman Jelveh eaddd1d44e redesign: share notification emails (#3612)
* redesign: share notification emails

The share digests were an unstyled fragment: a 520px div with a couple
of paragraphs, no preview line, no small-screen or dark-mode handling,
and an html-only body that left every text-only client reading a machine
down-conversion of the markup.

Both digests (`file_shared_with_you` and `file_shared_invite`) now render
through one layout: a 600px table column that goes full width under
600px, every color written inline so a client that drops `<style>` still
gets the design, with the stylesheet carrying only what inline CSS can't
express — the small-screen and dark-mode overrides. The shares are a
tinted panel of hairline-separated rows rather than loose text, the call
to action is a cell-padded button Outlook can draw, and the reason for
receiving plus the unsubscribe sit below the card where they belong. No
images at all, so nothing depends on an asset a client may refuse.

Templates may now carry a `text` part, and both digests do: it goes out
beside the HTML as multipart/alternative, which is what plain-text
clients, screen readers and spam scoring prefer.

Copy: a preview line that adds to the subject instead of repeating it, a
heading before the list, and an honest unsubscribe label — the link
silences notification mail account-wide, not just shares. The invite now
says what to do with the address and that an existing account can claim
the shares by confirming it.

Also drops `share_by_username` and `share_by_email`, unreferenced since
the digests replaced them.

* fix: widen the template map to the interface before compiling

`Object.entries` over the literal map yields a union with a distinct
type per template, and only the share digests carry a `text` part —
reading it off the union is an error the build tsconfig hides
(`noCheck: true`) and `npm run typecheck` catches.
2026-08-19 18:49:11 -07:00
Reynaldi Chernando 53b792c52c connect to worker's kv with cli (#3613) 2026-08-20 08:36:46 +07:00
Daniel Salazar 2dd7073034 fix: vscode webdav (#3610) 2026-08-19 14:04:56 -07:00
Daniel Salazar a552ce0b87 fix: better metrics for cache hit rates (#3609)
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: better metrics for cache hit rates

* chore: type + docs
2026-08-19 11:38:14 -07:00
Neal Shah c04827fd3f add dynamic worker create event (#3608) 2026-08-19 14:20:15 -04:00
Daniel Salazar 9debdffc5d fix: email send frequency + ai ordering (#3604)
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
2026-08-18 18:13:55 -07:00
Nariman JelvehandJuan Castro 57b4cbab66 feat(dashboard): rewrite Files-tab sharing as a from-scratch modal (#3598)
* feat(dashboard): rewrite Files-tab sharing as a from-scratch modal

The Files tab's Share… action opened UIWindowShare, a desktop UIWindow,
which doesn't fit the dashboard. Replace it with UIShareModal, built on
the same overlay pattern as UIItemPropertiesModal: a centered card on
desktop, a bottom sheet on mobile, styled with the dashboard's design
tokens (dark mode included).

Wiring follows the onShowProperties precedent: generate_file_context_menu
gains an optional onShare hook that TabFiles supplies; every other caller
falls back to the desktop share window unchanged.

Feature parity with UIWindowShare — grant by email/username with an
access level, list who has access (owner, direct, and inherited-from-
ancestor grants), change a grant's mode, revoke — plus:

- revoke confirmation inlined into the row instead of a stacked UIAlert
- real form semantics: Enter submits, button disabled until input,
  spinner while in flight, API errors keep the typed name for correction
- dialog a11y: focus moves in on open and back on close, Tab is trapped,
  Escape closes (first Escape cancels an open revoke confirmation),
  status messages announce via aria-live
- backdrop close keyed on where the press started, so a text-selection
  drag out of the input can't dismiss the dialog
- name-derived avatar colors and a loading state for the first fetch

Also fixes a double-encoding bug (shared by UIWindowShare) where mode
labels ran through html_encode twice, rendering "Can edit &amp; share".

* fix(dashboard): keep keyboard focus inside the share modal across re-renders

Changing a grant's mode, revoking, or Escape-canceling the inline
confirmation re-renders the access list (or disables the focused
control), which dropped focus onto <body> - outside the dialog's Tab
trap and invisible to screen readers. Focus is now restored explicitly
after each of those actions: to the same holder's control when it
survives, else to the dialog itself (tabindex=-1), and the Tab trap
steps back into the cycle when focus rests on the dialog container.
A failed grant likewise returns focus to the recipient input.

* fix(dashboard): give share modal controls 44px touch targets

On touch devices the close (32x32) and revoke (30x30) buttons and the
26px-tall per-row mode select were below the 44px minimum. On coarse
pointers the icon buttons now hit-test at 44x44 through a centered
pseudo-element (visuals unchanged, and the row spacing absorbs the
overhang without overlapping neighbors), and the row select gets
taller padding. Fine-pointer rendering is untouched.

* fix(dashboard): clear the iOS home indicator in the share bottom sheet

The app opts into viewport-fit=cover, so on notched phones the
bottom-docked sheet extends to the physical screen edge and its last
row sat under the home indicator. The sheet body's bottom padding now
adds env(safe-area-inset-bottom), matching how the codebase pads other
bottom-docked surfaces.

* fix(share): say 'Updated access' when changing a grant's mode

Changing someone's access level reported 'Shared with X', the same
message as a fresh grant, which reads as if a new share happened.
Both share dialogs now confirm mode changes with a dedicated
'Updated access for X' message. The UIWindowShare call also drops the
html_encode() around the recipient - i18n() already encodes
interpolations, so the wrapper double-encoded.

* fix(dashboard): align the share dialog's accessible name with the properties modal

The dialog's aria-label read 'name - Share' with an em dash while the
sibling properties modal uses plain 'name Properties'; screen readers
announce the dash as noise. Use the same name-then-noun pattern.

* fix(dashboard): ellipsize the share input's placeholder when it overflows

At phone widths the 'Add people by email or username' placeholder was
clipped mid-letter; text-overflow: ellipsis truncates it cleanly.

* fix(dashboard): raise the share selects' chevron contrast in light mode

The hardcoded slate-400 stroke measured ~2.6:1 against light surfaces,
below the 3:1 minimum for non-text indicators. Light mode now uses
slate-500 (~4.7:1); dark mode keeps slate-400, which already clears
5:1 there.

* fix(dashboard): stop Files-tab shortcuts from firing behind the share modal

The document-level keydown.tabfiles handler kept running while the
share (or item-properties) modal was open. With focus on any of the
modal's buttons or selects, Enter and Space were preventDefault-ed
before they could activate the control, arrows could not drive the
mode selects, and letter typeahead was hijacked into row typesearch —
while Enter opened the selected row behind the overlay, Delete moved
it to Trash, and Cmd+A/C/X/V acted on the hidden list. Yield the
keyboard to the modal for as long as one is up; its own handlers
already cover Escape and Tab.

* fix(dashboard): keep the on-screen keyboard down when the share sheet opens

Autofocusing the recipient input popped the keyboard over the bottom
sheet the moment it opened, hiding the access list before the user had
chosen what to do — the same reason the revoke flow already focuses
the dialog instead of the input. On touch-primary devices give the
dialog container initial focus (which also anchors the Tab trap);
desktop keeps the input autofocus.

* fix(dashboard): give the share sheet's confirm and submit buttons 44px touch height

The inline revoke confirmation's Cancel/Remove pair rendered ~29px
tall on touch — small targets 6px apart where one of the two is
destructive — and the submit button was fixed at 38px. Grow both to
44px under pointer: coarse, matching the standard the modal's icon
buttons already meet. The touch block moves below the confirm-button
base rules it now overrides, since a media query adds no specificity
and source order decides.

* fix(dashboard): name the person in the share rows' accessible labels

Every grant row's mode select announced as bare 'Access level' and
every revoke button as 'Remove access', so a screen reader user
tabbing the list could not tell whose grant a control changes. Carry
the holder in the aria-label ('Access level for alice' / 'Remove
access for alice'); the visible UI and the revoke tooltip stay as
they were. i18n() encodes the interpolated string as a whole, quotes
included, so the labels stay attribute-safe for any holder name.

* fix(dashboard): keep the share status region in the accessibility tree

* fix(dashboard): give the share modal's add-row controls 44px touch height

* refactor(gui): extract the share dialogs' pure logic into tested modules

* feat(dashboard): adopt the shared mode helpers and handle pending invitations

---------

Co-authored-by: Juan Castro <jfcastro9208@gmail.com>
2026-08-18 17:34:40 -07:00
Juan Fernando Castro 5e1645d40e 🔧 PUT-1524: Sanity check file sharing api (#3603)
* fix: sharing answers "who can reach this" from the grants, not the index

Two ways a share listing could name access that was no longer there.

A recipient's listing paired the flat permission read with the list of
permissions it asked for by array index. That read drops misses and
dedupes its keys, so the two are not positional: one entry's live grant
vouched for another entry that had none, and listShared() kept
publishing a withdrawn item's name, size and signed thumbnail URL to
someone who could no longer open it. Read the permission off the value
instead.

getShares() had the same gap from the owner's side, with no liveness
check at all — a grant withdrawn through /auth/revoke-user-user or an
ACL mode change left the index row behind, and the owner was told
someone could reach a file they could not. Checked against the grants
now, one batched read per distinct holder. Pending invites are not
subject to it: they have no grant yet, which is the point of them.

Also covers the access-token actor, which reaches the same reach bound
as an app through a different arm of the ACL check. No behavior change
there — it was correct and untested.

* test: pin that a revoke crosses regions

A revoke reaches a peer region as a replicated SQL delete plus three
invalidation events, one per cache the region owns: the u2u row cache,
the flat view and the scan generation. The delete alone changes nothing
there, and each cache has a different consequence if its event is lost
— 20s for the generation, 5 minutes for the row cache, and forever for
the flat view, whose grant-path entries carry no expiry.

Nothing covered the whole path end to end. The store tests prove each
event is emitted and applied; this proves the result, which is that the
recipient stops being able to read. It asserts the read still succeeds
after the SQL delete alone, so the test also records why replication is
not sufficient on its own.

* test: pin that a leaked uuid buys no access

A masked share path hides which folder an item sits in; it was never the
thing deciding who may open it. Nothing checked that at the route level,
so the guarantee rested on unit tests of the resolver alone.

Reads one shared file through its masked path, then tries the sibling
four ways: the shared uuid with the sibling's name, the sibling's own
uuid, a `..` back out of the root, and the owner's real path.

Worth knowing about this one: it's mutation-checked. Removing the head !== root.name guard in sharePathMask.ts fails it with reachable: /testuser/55dd54c0…/share-http-1df8933e.txt. I verified that specifically because two tests I wrote earlier in this chunk passed with their guards broken — both were vacuous, and I deleted them rather than commit false assurance.

* docs: say what listShared's total actually counts

`total` counts the shares recorded for you; items are filtered after the
page is read, so a withdrawn grant leaves the count higher than anything
paging will yield. The page description already explained the short-page
behaviour, but the field read as an exact count and the example printed
it as one.

The test pins the gap it describes: two shares, one withdrawn outside
the index, one item listed and a total of two.

* fix: keep an undelivered broadcast event instead of dropping it

The outbound queue was cleared before the send, so a peer that timed out
took its events with it. Most were survivable — a lost cache invalidation
heals when the entry's TTL lapses. A revoke's flat-perm invalidation is
not: grant-path entries carry no expiry, so a peer went on serving a
withdrawn grant until something else wrote that key.

Failed sends now go back on the queue, which the existing flush timer
retries. Anything queued since wins over the retry, and the queue is
bounded at 10,000 with the oldest dropped first, so a peer that stays
down cannot grow it without limit. Each retry is signed at send time, so
it is not rejected against the replay window.

* fix: stop plus-addressing from deciding who a share reaches
2026-08-18 15:51:23 -07:00
b795b219a8 PUT-1497: Share file link sharing and notifications (#3595)
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
* 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&#x3D;…`, 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>
2026-08-18 09:36:33 -07:00
Reynaldi Chernando 401b6066b3 Use gpt 5.6 luna as default for docs (#3602) 2026-08-18 09:36:23 -07:00
Daniel Salazar ec3c33fea6 fix: misc hardening fixes (#3600)
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
2026-08-18 00:06:58 -07:00
Anany Singh 95d797b047 feat(workers): expose app_uid in puter.workers.get and puter.workers.list (#3596) 2026-08-18 00:05:17 -07:00