mirror of
https://github.com/HeyPuter/puter.git
synced 2026-10-04 19:07:56 +00:00
24fb679dd3dcc0a81ada62616832235b95ad91f4
98
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
9292771554 | fix: hardening (#3904) | ||
|
|
59f73528cb |
feat: share with anyone with the link (PUT-1580) (#3874)
* feat(email): inline cid attachments and Puter mailbox delivery for sendTransactional EmailAttachment gains cid/contentDisposition so the transactional driver can send inline images. The SDK's EmailAttachment typedef now comes from types.js, which already carried cid. Docs describe delivery to <username>@puter.email recipients and the not_found code. * feat: share with anyone with the link (PUT-1580); gate sharing on a verified phone or card |
||
|
|
45aff36f0c | fix: auto create folders for fs perms (#3873) | ||
|
|
b136c56cb5 |
Merge pull request #3846 from HeyPuter/juancastro/put-1792-optional-seat-email
feat: the team seat experience — no email required, forced password change, team label, and plan-based limits (PUT-1792) Note: Bypassing the code owners rule, since there are couple approvals in place for this. |
||
|
|
c3e2717e8e |
fix: close the review findings on the seat experience
From the adversarial review of this PR. The forced password-change gate could deadlock: it POSTed to the cookie-only route with a bare fetch, and both initgui call sites open it before update_auth_data mints the session cookie — a fresh browser with a token URL 401'd every submit inside a non-dismissible loop. It uses the session-cookie retry wrapper now, taking the caller's token because window.auth_token does not exist yet on that path. It also gains the logout footer its sibling gates have; a lost temporary password was a hard lock with devtools as the only exit. Password recovery refused for seats: the address is admin-supplied and never verified, so whoever holds that inbox could take the seat over at any later time. A seat's recovery channel is its admin's reset. change-email gets the same seat guard as change-username and deletion — the address is where admin-issued credentials go. The login response now carries `team` alongside requires_password_change: no-reload logins store that payload as window.user verbatim, and every seat restriction keys on it. Smaller: the team-badge tooltip no longer double-encodes; the create-token hint for an emailless account stops pointing at a verification it can never perform; the quotas doc records the halved org_seat_free allowance; the config template tells upgrading operators how to keep the old flat cap; the SDK suite covers emailless provisioning and the owner-only uuid. |
||
|
|
480d6bc3b5 |
feat: give a team seat the account surface that is actually its own
A provisioned account could rename itself, delete itself, and see a
Billing tab for a subscription it does not hold — all of it the team's,
not the account's. Each is now refused server-side and dropped from the
UI, keyed on one predicate: whoami reports a team only for an org-owned
seat, and the owner joined their own team.
The Teams tab showed a seat nothing but its own audit rows, so a member
could not see who else was on the team they were told they shared it
with. It now lists them; listMembers was already membership-gated and
already withholds from a member what is not theirs.
The dashboard share modal had no way to reach a team, though the desktop
dialog has had one since team sharing shipped and the SDK has always
taken `{ team }`. Same control, same copy, same helper. The access list
needed a team bucket to go with it: a team share names no holder, so the
aggregate dropped it and a team you had just shared with vanished.
|
||
|
|
9940beb4bb | fix: card verification missing email (#3850) | ||
|
|
a983969f32 |
feat: prompt a team seat to choose its own password on first sign-in
The backend already refused every route with `password_change_required` until a provisioned account replaced the password its administrator chose, and `/user-protected/change-password` was already exempt so the account could act. The client half was missing entirely: nothing in the GUI referenced that code, so a seat signed in and then failed at everything with no prompt and no way out. Two gaps, both closed here. The flag never reached the client. Neither the login response nor `/whoami` carried `requires_password_change`, so the GUI could not have known even if it wanted to. It ships from both now, alongside the other three verification flags the whoami extension already describes as "the flags the GUI acts on". There was no window to show. `UIWindowChangePassword` is a settings dialog -- closable, and it never resolves on success -- so it cannot act as a gate. `UIWindowPasswordChangeRequired` mirrors the existing `*Required` windows: it resolves true only once the change lands, and `initgui` loops on it. It runs last in the boot chain, matching the server order in assertVerifiedAccount. It refuses a new password equal to the current one. Without that the account stays on the credential its administrator still holds, which is the entire thing the gate exists to end. Falsified twice: dropping the same-password guard fails "refuses to reuse the password the admin handed over", and resolving on a rejected response fails "stays open on a rejected change, so the gate cannot be escaped" -- each breaking only its own test. 175 backend tests, 326 GUI/SDK tests, typecheck clean. |
||
|
|
f5b2365e83 |
feat: enforce the forced password change on team seats
`user.requires_password_change` shipped with the team columns but nothing enforced it and nothing ever cleared it, so a provisioned seat kept its administrator-issued password indefinitely and `reissueCredential`'s "already activated" 409 was unreachable. Adds the fourth clause to `assertVerifiedAccount`, the only place a verification gate may live -- WebDAV builds its own actor and calls that function directly, so a second implementation would bypass it the way the phone and card gates once were bypassed. A gate that refuses everything also refuses the endpoint that clears it, so `/user-protected/change-password` opts out with `allowUnconfirmed`. That widens the route: an account pending email, phone or card verification can now change its password, which it could not before. The caller is authenticated and proves the current password, so this is benign, but it is a behaviour change to a shared route. Also here, because the gate is worthless without them: - change-password and the recovery-token path clear the flag, and record an `activate` entry when the account is a seat. - Reset takes a live account back with a fresh credential, capped at 20 per day and audited as `reset_member_password` with no credential in the row. Re-issue is audited the same way; it stays closed once a seat has chosen its own password. - An issued credential expires after 24h (new `temp_password_expires_at` column, three dialects) and login refuses it after that, so an unused reset dies instead of becoming a standing credential. - 2FA is untouched by a reset, so a reset alone is not takeover. |
||
|
|
e5f322f68c | fix: misc event hardening + perm fixes (#3831) | ||
|
|
00afea1285 |
feat: a seat is created, never adopted (#3720)
`addMember` refuses to turn an account that already has a password into a team seat. No service path did this — `provisionAccount` always creates — but the store permitted it, and the design rules out existing accounts joining a team. Provisioning passes the guard because it admits the account before setting its temporary password. There is no bypass parameter. Both HTTP suites now provision a real seat and authenticate as it, using the same token-minting the harness uses for `POST /login`. That surfaced something worth knowing: an unactivated seat cannot call the API at all. Provisioning leaves `requires_email_confirmation` set and `requireVerified` rejects it, so the suites activate the seat first — which is the state a member is actually in when making requests. `listMembers` and `getMembership` also return `u.uuid`, which the billing events need in order to name the account without a second lookup. |
||
|
|
927317bc4e | fix: events hardening (#3814) | ||
|
|
b451d05d10 | feat: app-minted kv share handles (PUT-1688) (#3692) | ||
|
|
ac5446f877 | feat: cross-user KV share grants and handles (PUT-1686) (#3690) | ||
|
|
799fc4ac3c |
feat: send app icons as a subdomain URL plus an API fallback (#3734)
App payloads carried only the /app-icon endpoint URL, which 302s to the icons hosting subdomain. Networks that mangle that redirect render no icon at all, and every icon load pays a round trip for the hop. Ship the direct subdomain URL as `iconCdnUrl` alongside it (taskbar items, installedApps, recent/recommended launch apps, suggested apps), and have the GUI load that first with the endpoint URL as a one-shot retry - desktop taskbar, start menu, dashboard app grid and recents. Only rows whose `icon` column is already an http(s) URL get one: a data: column means the resize pipeline has not written anything to the subdomain yet. Also folds the four copies of the generated-size list into one exported APP_ICON_SIZES. |
||
|
|
673f7fe75f |
feat: provision org accounts from a workspace
Covers PUT-1705. The master account supplies { username, email }; the account
is created with no password, gets the default filesystem tree, joins with
org_owned = 1, and receives a one-shot activation link.
Activation reuses password recovery rather than new token machinery: the same
pass_recovery_token, the same one-hour purpose-scoped JWT, the same
/action/set-new-password link. No team_activation table, no new token type,
and no unauthenticated endpoint on the team surface. Activation state needs no
column either -- an unactivated account is one with no password.
Applies the same username and email rules as signup rather than its own:
USERNAME_REGEX, USERNAME_MAX_LENGTH, RESERVED_USERNAMES and validator.isEmail,
now exported from AuthController. Without them a workspace could mint accounts
signup would refuse -- the username becomes the /username home-directory
segment -- claim unregistered reserved names, and send activation mail to
arbitrary unvalidated addresses at the route's daily limit.
Usernames come from Puter's global pool, so a taken one is refused with free
alternatives rather than silently modified: a suffixed name would appear in
every share dialog that person ever sees, and they never agreed to it. The
check runs before any write, so a rejected provision leaves no orphaned user
row -- asserted by a test on the workspace's member count.
The new account carries requires_email_confirmation, since the address came
from the administrator rather than its holder.
Adds a team_account_activation email template stating what the workspace can
and cannot do -- including that it can reset the password, which the design
requires be said rather than only claiming files are private.
free_storage stamping and the billing event are phase 3.
|
||
|
|
9fab4742c9 |
fix: complete the malformed-input guards, including the two paths still live (#3663)
Review of the previous change found three of its claims unmet.
The image-generation crash it reported fixed is still reachable. The assert was
scattered across three helpers, and Gemini and OpenAI call `isHttpUrl` directly
on `input_images` without going through any of them — so two of seven providers
still 500 on a non-string. `isHttpUrl` now refuses a non-string itself, and the
shape is settled once in `ImageGenerationDriver.generate`, where the driver call
arrives, rather than per helper. That also covers `input_images` that isn't an
array, which produced a different crash per provider.
The sixth case in the ticket, previously unlocated, is
`Messages.js` reading `tool_call.function.name` with no guard — reachable with
`{"messages":[{"role":"assistant","tool_calls":[{"id":"x"}]}]}`. Guarded, along
with the same shape in `make_claude_tools`: a TypeError there carries no status,
so the retry loop reads it as a provider failure and marks the route unhealthy
for every caller.
`#hardExpiryFromExpiresIn` returning null for a bad type moved the failure past
the session INSERT, leaving an orphaned non-expiring row and still answering
500. Reverted; the controller guard is the fix, now covering fractions,
negatives and unparseable durations rather than only wrong types.
Also: the batch write handlers check that the body is an array but not what is
in it, so a null element 500s the same way; `#requireObjectBody` accepted an
array despite its name; `handleCreateAccessToken` destructured a body that may
be absent; and two AGPL notices had been rewrapped with a Markdown link.
|
||
|
|
e460e9034c |
fix: answer 400 instead of 500 on four malformed-input paths (#3662)
Each of these read a field off caller input that wasn't the shape the code assumed, threw a TypeError, and was served as a 500 with a critical page. - POST /fs/write and /startWrite: a request whose body never parsed left `req.body` undefined, and the first read of `fileMetadata` threw. - POST /drivers/call, image generation: `input_image` / `input_images` entries are documented as strings but nothing checked, so a number or an object reached `.startsWith`. Type confusion on caller input, so reachable on demand rather than by accident. - POST /auth/create-access-token: `expiresIn` went to the expiry parser unvalidated, where anything but a string or a number has no `.trim`. - POST /login/wait: destructuring `session` out of an absent body threw. Same class as the POST /login body ticket; that one covers /login itself. |
||
|
|
88b851a423 |
fix: derive the signup client IP from req.ip instead of x-forwarded-for (#3661)
Both signup paths read the x-forwarded-for header directly for the puter.signup.validate event, the puter.signup.success event and the signup_ip_forwarded column. Behind an appending proxy a client can prefix that header with anything it likes, which gives every per-IP abuse signal a fresh bucket per request. req.ip is the value `trust proxy` resolves, so it is the one the server can stand behind; server.ts already uses it for the ip.validate gate for exactly this reason. All three now share one derivation, so a per-IP counter is written and read under the same key. The raw chain is still recorded, but only as audit_metadata.ip_fwd, where nothing keys on it. Rows written before this hold whatever the proxy appended, so per-IP velocity over the trailing window is undercounted until they age out. |
||
|
|
c4be7fabac |
fix: settle a permission request from what is already granted
`puter.perms.request()` already pooled a permission read and prompted only for what was missing. The raw `puter.ui.requestPermission()` did not, so every caller still on it re-asked the user on each launch — including `perms.requestAppData()`, whose own docs promise the opposite, and the driver-denial retry. - puter.js: `ui.requestPermission()` reads what is held before prompting and resolves true when the whole request is covered. Only in env=app and env=web, the environments that raise a prompt; elsewhere the method still answers false without asking anyone. A check that cannot be made — no token, an unreadable request shape, a failed read, or one that outlasts its timeout — falls through to the prompt rather than standing in for an answer. Public signature unchanged. - GUI: the request-permission popup asks the same question as the app, using the user-app token its own exchange already mints, and skips the dialog when the access is held. This is the one case the SDK cannot settle for itself: a signed-out site holds no token to check with. An origin the browser does not vouch for never reaches the check, since the exchange fails first. Both checks are time-boxed, because each one stands in front of something that is waiting: the popup's gates the dialog, so a stalled read would leave the prompt unshown and the opener pending, and the SDK's spends the browser's transient activation, which a slow read would cost the popup. Note that driver, service and feature scopes are implicitly granted to every app (backend/data/hardcoded-permissions.js), so requests for those now settle silently — the dialog was asking about access the app already had. Consent scopes (email, fs, apps, subdomains, app-data, app-root-dir) are unaffected and still prompt until granted. Fixes a bug this method already had on the way past: `pollDecision` read an undeclared `permission`, so every attempt threw a ReferenceError into its network-failure catch and the COOP-severed-opener recovery burned its full five-minute timeout before answering false. It polls `requested` now, and requires the whole list. Tests: the e2e suite drove its dialogs with an implicitly-held driver permission, so the fixture now asks for a driver nothing implies, fresh per page load, which also removes the cross-test grant carry-over the old revokes worked around. The reconciliation tests ask for the held scope plus an unheld one, since a fully-held request no longer reaches a dialog. Adds a backend contract test for check-permissions under an app-under-user actor, which is what the two new client paths rest on. |
||
|
|
909949c68d | fix: failed email alarms (#3651) | ||
|
|
1600dc38a5 |
Retire the share index row with the grant it records
revoke-user-user withdraws a grant without touching the share index, so the row outlived the access — invisible until now, because listSharesOf filters against live grants, but the new flag reads the index and would report a file as shared to nobody, permanently. Drop the row where the grant goes. The alternative, filtering liveness on the read side, is the per-entry work the flag exists to avoid. |
||
|
|
684d6752f9 | fix: provide fallback for signup verification (#3631) | ||
|
|
732786a029 | fix: clean up email validation (#3622) | ||
|
|
f0cd251626 |
🛠️ PUT-1521: Cleanup puter js permissions api + backend routes (#3607)
* 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>
|
||
|
|
ec3c33fea6 | fix: misc hardening fixes (#3600) | ||
|
|
2c852bf6b3 |
✨ PUT-1412 File sharing backend api (#3553)
* refactor(permissions): drop hardcoded group permission map for a flat default
* test(drivers): assert credential-gate intent instead of a 403 proxy
* fix(permissions): report whether a revoke removed anything and persist the linked grant row before the flat view
* feat(permissions): replicate permission invalidations across regions
* feat(share): extend the share table into an index of active shares
* feat(share): query and maintain active shares in ShareStore
* feat(users): add a batched lookup by email
* fix(cache): apply cache updates broadcast from peer regions
* fix(permissions): scope a revoke to the issuer that granted it
* feat(share): add ShareService with a per-day share limit
A share is two writes that belong together: the permission grant, which
authorizes access, and a share row, which makes it listable and ties it to an
fsentry so it dies with the file. Nothing else grants fs:* to a user.
Authorization reuses canManagePermission — an owner satisfies it through the
is-owner implicator, a delegate through an explicit manage:fs:<uid> grant. An
owner may clear any issuer's share of their node; anyone else only the ones
they issued, or their own access. Self-revoke skips the manage gate but still
requires `see`, so it cannot be used to probe for files.
The per-day limit counts shares created rather than live rows, so revoking and
re-sharing cannot recycle a slot, and changing an existing share's mode is not
new reach and does not spend budget. Tunable via share_daily_limit.
* feat(share): expose sharing over HTTP
POST /share, POST /share/revoke, GET /share/shared-with-me, GET /share/shares.
The controller was registered but entirely commented out.
Recipients × items fan out concurrently — every pair is a distinct
(holder, entry) key, so none of them contend — bounded by
runWithConcurrencyLimitSettled, which returns results index-aligned with the
input for the per-pair outcome list. Responses carry usernames only, never
internal ids, and the 404-not-403 rule is preserved so a failed call cannot
confirm a file the caller could not otherwise see. Notifications are fired off
the response path; a share must not fail over its own notification.
Per-request caps on recipients and items bound one call's fan-out; the daily
limit bounds the total.
* feat(share): keep recipients consistent when a shared item changes
* fix(fs): stop listing issuer homes at the filesystem root
* fix(acl): serialize concurrent mode changes on one node and pin app containment on shared paths
* fix(fs): expire signed URLs over entries the signer doesn't own
signFile defaults to a ~317k-year TTL and verifySignature checks only uid,
expires and signature — never the ACL. A recipient who ever signed a shared
file therefore held a permanent, revocation-proof URL to its bytes: revoking
the share did nothing to it.
signEntry now takes the acting user and drops to NON_OWNER_SIGNATURE_TTL_SECONDS
(1 hour) when the signer is not the entry's owner. Owners keep the permanent
default, so no existing client changes behavior.
The signature-authenticated directory listing bounds its children
unconditionally: that route has no session actor, and a signature proves
possession rather than ownership, so a recipient holding a short-lived
directory signature could otherwise mint permanent URLs for every child.
A bounded window is not revocation — the durable fix is a per-entry signature
epoch folded into the HMAC and bumped on any permission change.
* refactor(permissions): drop the unused permission-issuer lookup
listUserPermissionIssuers and its store method listUserPermissionIssuerIds
existed to synthesize the filesystem root from the home directories of everyone
who had granted the caller a permission. That listing is gone — it advertised
folders readdir then refused to open — and the share index answers "who shared
with me" directly, so nothing wants them back.
One removed test only asserted that the call returned an array; the other
covered readLinkedUserUserPerms round-tripping and is kept, rewritten without
the issuer lookup.
* fix(share): return the created share, not just an acknowledgement
* feat(puter.js): add file sharing to puter.fs
share(), unshare(), listShared() and getShares() on puter.fs, following the
existing FS operation shape: positional and options-object forms through
defineOperation, JSDoc overloads as the published signature, relative paths
resolved against the app's root directory.
A bare recipient string is read as an email when it contains @ and as a
username otherwise. Sharing an item with someone who already has it replaces
their access rather than stacking a second grant, so raising read to write is
one more call.
Adds a sharing suite to the API runner, which passes unchanged on node,
browser and workerd. Documents all four methods with runnable examples, and
corrects the FS overview callout that told readers one user cannot read
another's files — true before this, not after.
* feat(gui): add a Shared folder for items others shared with you
A sidebar entry listing everything other users have shared with you, backed by
puter.fs.listShared().
The path is the sentinel `puter://shared` rather than /<user>/Shared: this is a
query, not a directory, and a path-shaped value could collide with a folder
someone actually creates. refresh_item_container and update_window_path both
branch on it to skip the stat there is no fsentry for, and the listing swaps
readdir for listShared.
Entries render at their real paths under their owners' directories — the item
container already preferred an explicit fsentry.path over joining onto the
container, so nothing else had to change. Each carries who shared it and at
what level, which the context menu reads next.
* feat(gui): share items from the context menu
A sharing dialog shaped like its neighbours — options object, HTML-string
template, jQuery wiring, delegating to UIWindow() — with a recipient field, a
read/edit/share dropdown, and the current access list with revoke buttons.
Reached from a new "Share…" context menu entry, which is hidden on items shared
*with* you: re-sharing needs manage, so the dialog would only surface an error.
Those items get "Remove from Shared" in place of Delete. Delete moves an item
to *your* trash, which for someone else's file means moving their data out of
their tree — FSService refuses it, and the user saw a bare 403. Removing your
own access is what the action was reaching for, so that is what it now does.
* fix(share): withdraw what a removed recipient re-shared
* feat(share): report access inherited from a parent folder
* fix(gui): load the puter.js bundle the server configured
* refactor(gui): extract the action icon set into a helper
* feat(gui): surface Shared in the file browser
* feat(gui): manage access from the share dialog
* test(share): cover access inherited from a parent folder
* fix(share): keep downstream access from surviving a delegate who leaves
* fix(gui): name the real owner in the share dialog
* fix(gui): page through every shared item instead of the first 50
* feat(share): return item metadata with a share
* fix(share): invalidate a holder's cache when the entry is deleted
* fix(gui): treat items inside a shared folder as someone else's
* feat(permissions): let manage inherit down the filesystem tree
Access already reached descendants through the ancestor chain while authority did not, so someone trusted to manage a shared folder could re-share the folder but nothing inside it, and could not see who had access to a file within it.
A manage-inherits-from-ancestor implicator resolves it in the permission layer, beside is-owner, so every caller agrees rather than just ShareService. It consults only the immediate parent — resolving that re-enters one level up, making a chain of depth d cost d checks rather than d².
That makes two cascade gaps reachable, both fixed here. A revoke now walks the subtree, since a grant on a descendant can rest on authority held at the folder. And it stops at a delegate whose authority survives another issuer, because what they granted was never theirs to lose.
Also pins that manage is not transitive: granting it needs manage:manage:fs:<uid>, which only the owner holds, so delegation is one level deep by construction.
* fix(gui): offer sharing inside a folder you manage
The menus encoded "manage does not inherit" and would now hide an action that works. The Shared listing records each root's mode; the menus resolve a child's by longest matching ancestor, loading on demand so a deep link or restored window works too.
* fix(share): make the daily share limit hold under concurrency
* test(share): cover concurrency, measure cost, and name cases for what they verify
* fix(gui): import the ownership helpers the item menu calls
The single-item context menu handler calls is_owned_by_me and
shared_mode_for, but the imports were only ever added to
generate_file_context_menu.js — so every right-click on an item threw a
ReferenceError before the menu could build, and the non-owner Delete
gating never ran.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(share): authorize before resolving the recipient
share() looked up the recipient in parallel with the entry, before the
manage check — and the two failures carried different error codes. Any
verified user with a real entry uid could probe arbitrary emails and
usernames for account existence, at no quota cost. Resolve the entry,
authorize, and only then resolve the recipient: an unauthorized caller
now sees the identical safe 404 whether or not the recipient exists.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(permissions): broadcast permission row-cache invalidations to peer regions
Every publishCacheKeys call for the u2u, u2a, and access-token row
caches omitted broadcast, so a revoke only cleared the mutating
region's Redis. A peer region applied the replicated generation bump,
re-scanned, read the deleted row from its own still-warm 5-minute row
cache, and re-warmed the flat view from it — revoked access outlived
the revoke by the row-cache TTL instead of the intended 60-second
bound. CacheReplicationService already consumes these events; the
emits were just never sent.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(fs): refuse to rename an entry owned by another user
remove and move both refuse to act on an entry the caller does not
own, even when the ACL allows the write — rename had no such guard, so
a write-mode share recipient could rename the owner's file, or the
shared folder itself, rewriting the owner's whole subtree's paths.
rename now takes the acting user and applies the same policy.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(permissions): decide a flat delete from the primary, not a lagging replica
revokeUserUserPermission deletes the SQL grant, then only drops the
flat KV entry once no issuer still grants the permission. That
remaining-check read through the row cache the delete had just
invalidated, straight to a replica — under any lag the deleted row
reappeared, the flat delete was skipped, and the stale rows were
re-cached for another five minutes. Grant-path flat entries carry no
TTL, so the holder kept working access with zero SQL rows behind it,
invisible to every listing. The check now reads the primary and
re-warms the cache with what it actually saw.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(permissions): keep a failed remote flat-invalidation from crashing the process
The outer.permission.flatInvalidated applier was fire-and-forget with
no catch, and it awaits a KV delete — one transient KV error while
applying a peer region's revoke became an unhandled rejection, which
is process-fatal under default Node. Its sibling appliers were already
guarded; this one now logs and moves on, leaving the entry to the next
invalidation or its TTL, same as a lost event.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(share): revoke every requested item and recipient, not just the first
revokeShare destructured only the first recipient and first item while
the parsers accept arrays up to the request caps — unshare({items:
[a, b, c]}) returned success having revoked only a, leaving access the
caller believes is gone. Revoke now fans out over every (recipient,
item) pair exactly like POST /share, reports per-pair outcomes, and
sums the revoked count; the response stays backward compatible.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(share): only a confirmed email designates a recipient
Recipient resolution by email accepted unconfirmed accounts, so
pre-registering someone else's address (unconfirmed) was enough to
receive shares meant for them once no confirmed account held it.
An email now only resolves to an account that has confirmed it;
username shares are unaffected.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(share): accept tilde-rooted paths like the FS routes do
The SDK resolves relative paths to ~/..., but the share routes never
expanded the tilde — a ~-prefixed string was read as a uid and every
relative-path call 404'd. Item parsing now treats ~ as path-shaped and
expands it to the actor's home with the same helper the legacy FS
routes use, on share, revoke, and the shares listing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(share): walk a directory revoke by parent linkage, not path prefix
listByFsentrySubtree matched descendants with fsentry_id = ? OR path
LIKE ?, which has two problems: fsentries.path is lazily backfilled
and NULL on old rows, so those descendants' shares silently survived a
directory revoke, and the OR'd predicates forced a scan of every
active share. A recursive CTE over parent_id — the same shape the
lineage resolver already uses — covers every descendant and runs on
idx_parentId_name.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* fix(gui): give each item its own share dialog
single_instance keyed the dialog on the app id alone, so opening
Share… on a second file focused the first file's dialog — typing a
recipient there granted access to the wrong file, with only the title
hinting at it. The dialog is now instanced per path: same item
refocuses, different item opens fresh. Also stops pre-encoding the
title, which UIWindow encodes again.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
* feat(permissions): let manage answer a write check
A manage grant let its holder re-share a folder but not work in it: the
ACL mode family stops at write, and the fs exploder had no rule for the
narrowest mode, so `manage:fs:<uid>` never satisfied `fs:<uid>:write`.
Fold manage into the candidate list for every non-manage mode, in both
the access-token branch and the scan branch, and give `write` an (empty)
exploder rule so the manage arm is emitted for it too.
* fix(fs): authorize restructuring by write on the parent
* fix(fs): let a share recipient work inside a shared folder
rename, remove and move refused outright when the entry belonged to
someone else, so a recipient with write could neither delete nor rename
anything inside a folder shared with them. The GUI compounded it by
hiding Delete for any item it did not own.
Authorize the three by ACL write on the entry's parent. For an owner
that is the same answer; for a recipient it grants the inside of a
shared folder and withholds the folder itself, whose parent is the
owner's private tree.
Deleting sends the item to its owner's trash rather than the deleter's,
so it leaves the recipient's view without leaving the owner's account
and without changing hands. A move may not otherwise carry someone
else's entry out of their tree.
* fix(fs): give a new entry to the owner of the folder it lands in
A file a share recipient added to a shared folder was recorded as
theirs while living in the owner's tree, so a subtree could hold rows
belonging to several people — and the storage it consumed was checked
against the writer while being counted against the owner.
Take the owner from the parent row at every insert, charge the
allowance to that owner, and hand a moved entry over to the tree it
moves into. An entry now always belongs to whoever owns the directory
holding it.
* feat(fs): address shared entries as ~/share/<uid>
A recipient could read the owner's whole path off any shared entry —
where they keep the file and what sits beside it, neither of which the
share is about.
Give shares their own namespace. `~/share/<entry-uid>/rel/path` resolves
to the real path on the way in, and outgoing paths are rewritten to it
on the way out. Entries the actor owns pass through untouched, so no
existing client contract moves.
* revert(fs): mask only the directory bar, not the addressing
|
||
|
|
19cd5f4476 |
feat: add BytePlus ModelArk providers (chat, image, video) (#3498)
* feat: add BytePlus ModelArk chat provider Adds BytePlus ModelArk as a provider for the puter-chat-completion driver, following the MiniMax/ZAI providers as reference per doc/contributing-apis.md. - OpenAI-compatible endpoint at ark.ap-southeast.bytepluses.com/api/v3 (apiBaseUrl config selects the region) - Static catalog of 16 chat models (Seed 2.x/1.x incl. vision, GLM, DeepSeek, GPT-OSS) with limits and per-token pricing from the official docs - Passes Ark's thinking/response_format/stop params through custom; normalizes reasoning_content to reasoning - Bare deepseek-v4-* names stay with the first-party DeepSeek provider; BytePlus only claims prefixed aliases - Offline unit tests (mocked SDK against a real test server) plus an env-gated integration test * feat: add BytePlus image and video providers Extends the BytePlus ModelArk integration to the puter-image-generation and puter-video-generation drivers, reusing the same services.byteplus API key and regional apiBaseUrl as the chat provider. Image (Seedream/SeedEdit via OpenAI-compatible /images/generations): - dola-seedream-5-0-pro (pixel-tier pricing + billed input images from the 2nd on), seedream-5-0-lite, 4-5, 4-0, and seededit-3-0-i2i - quality tiers 1K/1.5K/2K; aspect ratios resolve to Ark's documented pixel sizes; explicit WxH passes through with Ark's bounds enforced Video (Seedance via Ark's async /contents/generations/tasks + polling): - Seedance 2.0 / 2.0 Fast / 2.0 Mini / 1.5 Pro / 1.0 Pro / 1.0 Pro Fast (2.5 is priced but its API isn't live yet, so it's excluded) - per-video-token billing from usage.completion_tokens, with per-second estimates feeding the credit cap; audio vs silent rates for 1.5 Pro - first/last frame and reference-image inputs; generate_audio param added to IGenerateVideoParams Pricing and capabilities hardcoded from the official docs (ModelArk pages 1544106, 1330310, 1520757, 1521309, 1541523). Offline unit tests mock the SDK / global fetch; integration tests are env-gated on PUTER_TEST_AI_BYTEPLUS_API_KEY. * fix: correct BytePlus catalogs and validation against the live API Verified the three BytePlus providers against ModelArk with a real key; these are the mismatches that surfaced. - Drop seededit-3-0-i2i-250628. Ark reports it as Shutdown and every request 404s. Its now-unreachable image-to-image branches in the provider go with it. - seedream-4-5 and the 5.0 series enforce a 3,686,400 pixel minimum, so they only accept the 2K tier. Mark them 2k-only and snap an unsupported tier up to the nearest allowed one, which also keeps the aspect-ratio table from mapping to a sub-minimum size. - glm-4-7 has a 204,800 token context, not 256K. - Guard the actor in the image provider like the video provider does. - Round a sub-minimum video duration up to the shortest supported clip instead of reporting it as insufficient funds. - Gate video resolution on the model's own dimensions; the dims table is shared across a family and accepts more than any one model does. * Tighten BytePlus AI provider handling Extract shared reasoning-content normalization for OpenAI-style chat providers, and harden BytePlus image/video behavior. This updates image tier and size validation, normalizes aspect ratios and input image refs, prevents mismatched BytePlus key/base URL fallback config, makes video resolution matching case-insensitive, and rejects excess reference images instead of silently truncating them. Tests were expanded to cover the new BytePlus request and validation paths. |
||
|
|
f15d835eeb | fix: restrict openai and anthropic compatible endpoints to be subscription (#3583) | ||
|
|
22f5bf5429 | fix: duplicate emails (#3556) | ||
|
|
d5ae5a0049 |
🔧 PUR-1072: Flatten driver permissions to hardcoded values (#3545)
* refactor(permissions): drop hardcoded group permission map for a flat default * test(drivers): assert credential-gate intent instead of a 403 proxy |
||
|
|
79d4201f12 |
fix: rate limits, AI routing, and a type-check gate (#3529)
- declare rate + concurrency limits on every route and driver that lacked one - add acquireConcurrent for websocket connections and the DAV mount - bucket AI models by identity key only; keep resold duplicates of any vendor - skip recently-failed provider routes; cap the fallback chain at 3 attempts - let full-access access tokens bind a worker to an app their own user owns - cache resolved subscriptions so tiered limits don't add a round trip |
||
|
|
d202be10a9 |
feat: let apps use another app's data with user consent (#3516)
* feat(perms): add cross-app app-data permission vocabulary * feat(perms): sweep app grants by permission prefix * feat(perms): resolve and withdraw cross-app data grants * feat(kv): support an authorized namespace override and per-key privacy * feat(kv): gate cross-app KV access behind app-data grants * feat(fs): allow cross-app AppData access and require a scope to delete * feat(auth): accept permission lists and gate app-data grants * feat(perms): add requestAppData to the puter.js SDK * feat(gui): carry permission lists through the IPC and popup transports * feat(gui): describe cross-app data requests in the consent dialog * docs: document requestAppData and per-entry KV privacy * perf(perms): sweep cross-app grants only for origin-bootstrapped apps * fix(gui): stop double-encoding cross-app consent text * fix(perms): close three gaps in cross-app grant enforcement * fix(kv): meter and batch the per-entry privacy probe * fix(perms): resolve app identifiers and scopes more strictly in the SDK * test(perms): cover the cross-app consent flow end to end * fix: small missing token resolution for app also adds the same exclusion for the batchPut api, small change * fix: make resolved actor optional --------- Co-authored-by: Daniel Salazar <daniel.salazar@puter.com> |
||
|
|
eb53c842dd |
fix: oidc issues with cache (#3512)
closes #3497 closes #3502 |
||
|
|
116d6e6663 | tests: big test push for better coverage (#3490) | ||
|
|
c8113d7514 |
fix: auth message popups (#3487)
* fix: auth message popups * remove v1 auth |
||
|
|
1f1f95c2f8 | fix: auth me for local dev (#3484) | ||
|
|
08d1708378 |
change cors auth path (#3464)
* change cors auth path * undo oidc ref changes * scary OIDC state changes * fix: bad cors signin * puterjs changes --------- Co-authored-by: Daniel Salazar <daniel.salazar@puter.com> |
||
|
|
69529ee0db |
perf: try to improve app opens speed (#3462)
* perf: try to improve app opens speed * make /rao non blocking to allow faster conn swap |
||
|
|
5bad6a972f |
fix: puter-js cleanup around docs and ai resolution (#3459)
* fix: puter-js cleanup around docs and ai resolution * more jsdoc stuff * fix: docs * fix: doc types + misc hardening * fix: harden auth |
||
|
|
f3fd8a30da |
Rework permission requests: new dialog, working popup flow for websites (#3447)
* Rework permission requests: new dialog, working popup flow for websites - Replace the UIWindow-based permission prompt with a standalone top-layer <dialog> (responsive, light/dark, app/site identity, input protection) - Implement puter.ui.requestPermission for env=web: opens the GUI's /action/request-permission popup with pinned origin/source/msg_id, popup-closed detection, and a check-permissions polling fallback for crossOriginIsolated openers - Move the GUI's request-permission action into postAuthActions so signed-out users sign in first; identify the app by opener origin, correlate responses with original_msg_id, close the popup after answering - Always respond from the IPC handler so the SDK promise can't hang; normalize the result to a strict boolean (a failed grant no longer resolves truthy) - Accept origin in /auth/grant-user-app and /auth/revoke-user-app, mirroring grant-dev-app (fixes puter.perms.grantOrigin/revokeOrigin) - Add Playwright e2e coverage for both the desktop and popup flows; update e2e harness for the auth_token_v2 localStorage key - Update types and docs (perms request methods now work on websites) * Harden permission request flows - Grace period before treating popup close as denial: the GUI posts the decision then closes the popup, and postMessage delivery is not ordered relative to `closed` becoming true, so a grant could race to a false - Unique popup window name per request so window.open name-reuse can't hijack a still-pending request's popup - request-permission action always answers the requester and closes the popup, even when app resolution or the dialog throws - Permission dialog: refuse unidentifiable requesters, allowlist icon URL schemes, and time out the grant request into the retryable error path - Validate app_uid/origin/permission types and length in grant-user-app and revoke-user-app - Tests: revoke-by-origin and input-validation backend tests; e2e for dialog dedup, unsupported permissions, and the consent-dialog path * Sign in first in the requestPermission fixture's email flow on the web In env=web the site has no auth token, so whoami threw 401 immediately and the email button appeared to do nothing. Sign in via popup first, matching the real third-party flow; env=app already has a token and is unaffected. * Point the requestPermission fixture at the real api subdomain The SDK sends credentialed CORS requests; the GUI host doesn't answer with Access-Control-Allow-Credentials, so whoami (and any authed call) from the fixture origin failed CORS and looped through retries. * Fix /auth/list-permissions schema mismatches The endpoint's queries referenced columns that don't exist: user_to_app_permissions stores a numeric app_id FK (not app_uid), and user_to_user_permissions uses holder_user_id (not target_user_id) — every call 500'd. Join apps to expose the app's uid and use the real column names. Replace the catch-either-branch test (which documented the breakage instead of failing on it) with real assertions covering all three sections of the response. * Identify permission requesters by origin only The request-permission action took `app_uid` straight from the query string and used it as the grant target whenever the origin was absent or unresolvable. Now that /auth/grant-user-app accepts `origin` and prefers `app_uid` when both arrive, the displayed identity and the grant target could diverge; with only `app_uid` in the URL the dialog rendered with an empty name, so a link could produce a bare "Allow" prompt for an unnamed requester. Resolve the uid from the origin alone, and let the server resolve it from that same origin when the client lookup fails. Also give the no-gesture consent popup a unique window name. UI.js does this on the direct path because window.open() reuses a window with a matching name, but the PuterDialog fallback opened under the default 'Puter' — the same name sign-in uses, so a consent click could navigate an in-progress sign-in popup away. And guard the IPC responder: an app that closes its own window while the dialog is up leaves target_iframe.contentWindow null. * Keep the permission popup from signing the site in The popup loads the GUI with embedded_in_popup=true, so it ran the sign-in token exchange and posted puter.token to the opener before the user answered the prompt. A site that called requestPermission() walked away holding a user-app token for the account even when the user pressed "Don't Allow" — and because the SDK's global puter.token handler feeds event.data.token into setAuthToken() without looking at `success`, a failed exchange posted token: null and wiped a token the site already had. Keep running the exchange (it bootstraps the app row the grant needs and caches host_app_uid) but leave the token in the popup. A site that wants credentials still has to call signIn(). Escape on the SDK's consent dialog left the caller pending forever. PuterDialog wired its Cancel and close buttons but not the <dialog>'s native cancel event, so the browser dismissed the dialog and nothing reported it: no dialog, no popup, no answer. Route cancel to the same handler. Programmatic close() fires only `close`, so launching the popup — which closes this dialog — is unaffected, and the implicit-auth flow stops hanging on Escape too. Serialize the permission dialogs. showModal() makes the whole document inert rather than just the requesting app's window (which is what the UIWindow it replaced did), and the dedup map only coalesced identical requests, so an app asking for permissions in a loop stacked one modal per request and walled the user off from the desktop — including from the app doing it. Prompts now queue and open one at a time, and each caller still gets its own decision. Identify apps by more than their title. `title` is free-form text the author picks and is not unique, so it was the whole identity of a prompt an app titled "Puter Settings" could raise; the registered `name` is unique and format-restricted, so show it underneath. Give the name line the unicode-bidi isolation the origin line already had, since escaping leaves bidi overrides intact. Stop the dialog from answering over its own in-flight grant: a dismissal while the POST was outstanding resolved false for a permission the server was committing. Ignore dismissals while granting, and time-box the request with AbortController (AbortSignal.timeout isn't everywhere) so a hung network can't leave a modal no one can close. Fail closed on the remaining paths that could reject or prompt uselessly — showModal() throwing under <iframe sandbox>, and a requester known only by app_name, whose Allow the server would always reject. Pass the error string to .text() unencoded so translations containing an apostrophe don't render '. The e2e suite covers all of it; each new test fails without its fix. * Restrict the permission popup flow to third-party websites requestPermission's new web path ran in every environment with a window, including env='gui' — so a permission_denied driver retry inside the Puter GUI would open a popup to the Puter origin from the desktop itself and try to grant the permission to a phantom app for Puter's own origin. Resolve false everywhere except env='web', the previous behavior. * Settle the permission dialog when a failed grant has no dialog left The cancel handler is preventDefault'd, but close requests can't be suppressed forever: Chrome's close watcher lets a repeated Esc skip cancel and force-close the dialog while the grant POST is in flight. The close handler defers to that grant on purpose — but if the grant then failed, fail_grant re-enabled buttons on a closed dialog and the promise never settled, leaving the requesting app waiting forever. Settle as a denial when the dialog is no longer open. Also add regression tests for this and for the popup-flow env guard (routing the CDN SDK URL to the local build, since the prod-built GUI loads its SDK from js.puter.com). * Withhold the auth token on the popup's first-visit paths Keeping the token inside the permission popup only covered the plain token exchange. Two other popup paths mint a user-app token and posted it to the opener unconditionally: first-visit temp-user creation, and the manual signup shown when temp users are refused. Both sit on the path a brand-new visitor takes — the audience the website popup flow exists for — so a site that asked about one permission and was denied still walked away holding a token, for a temp account or a real one. The SDK's global puter.token handler feeds whatever arrives straight into setAuthToken(), so posting it is the whole of it. Move the rule into util/popupAuth.js and consult it at every site that posts the token, so the next token path has one place to ask. The first-visit path also left the prompt itself unreachable: it waits on the spinner promise, which only resolves when the spinner was up for under 2s. End that wait for any action that keeps the popup open; sign-in still closes the window as before. The e2e test fails without the fix — the site holds a token after the user presses "Don't Allow". * Poll for the decision when the popup's opener is severed crossOriginIsolated was the test for "the popup can't message us back", but being isolated also requires COEP. A site sending COOP: same-origin on its own still has its opener relationship severed when it opens the Puter popup, and took the watch-the-window path instead — where the detached proxy reports closed === true on the first tick, so requestPermission resolved false about a second after the popup opened, while the user was still reading the dialog. Their "Allow" then had nowhere to go. Treat an already-closed popup as severed and poll. Pin the expected event.source before those early returns. popupWindow was assigned after them, so for the whole consent-dialog wait — as long as the user takes to click Continue — the handler accepted a decision from any window on the GUI origin. A forged answer is only advisory since the grant is written server-side, but the check may as well hold. Settle instead of rejecting when the consent dialog can't be appended: document.body is null in a <head> script, and the throw both rejected a promise documented to resolve to a boolean and left the message listener behind. * Key the dialog dedup by the identity its gate accepts The gate treats an empty app_uid as absent and falls through to the origin; the dedup key used ?? and kept the empty string, so two requests from different origins would collide on one key and share a single decision. No caller can produce a blank uid today — server uids are never empty and the IPC path's empty attribute is stopped by the gate — but the two lines have to agree. * Close the gaps the permission-request flow left open Seven defects found reviewing the new permission flow end to end, each reproduced against a running server before being fixed. Security: - `cross_origin_isolated=true` bypassed `deliversTokenToOpener` entirely. That branch is checked first, mints a user-app token, publishes it via `/login/set` and returns — so one query parameter on a request-permission URL skipped the prompt and handed the opener a token through the unauthenticated `/login/wait`. Gate it with the same rule. - Grant/revoke by `origin` could land on an unrelated app. An origin with no app row synthesises `app-<uuidv5>`, and the permission services resolve their identifier as uid *or name* — and the uuid namespace is a source constant, so the string is computable offline and registrable as an app name. Resolve origins to a uid that names a real app row. - A website's host was elided on the right, hiding the registrable domain that says who is asking. Elide it from the left, as the sibling rule already intended. - A grant whose response was lost (client-side abort, dropped reply) left the row committed while the dialog reported a denial. Withdraw it when the user then answers "Don't Allow". Correctness: - `pollDecision` needs the site's own token, which a permission popup deliberately never delivers, so a signed-out cross-origin-isolated site burned the full five-minute timeout before answering. Answer at once when there is nothing to poll with. - `getUserAppToken` reports failure by returning null, and three callers read `.app_uid` off it. Guard all three, keep the first-visit spinner promise settling on its failure paths, and dispatch the `login` event on the manual-signup path so `postAuthActions` runs at all — a user who signed up inside a permission popup got a blank window and the site got no answer. - Time-box the lookups that run while a request holds the dialog queue's slot: they have no timeout of their own, and a stall (not a failure) wedged every later permission request in the page. Also harden the grant/revoke input validation the PR introduced — it skipped `extra`/`meta`, so a non-object faulted *after* the row was written, and its length cap was 16x the column it lands in — stop a non-URL `origin` from throwing past the answer-and-close, and drop the CSS left behind by the deleted dialog. * Close three gaps left in the permission-request flow Each was reproduced first — the squatting grant against a running server, the COOP timing in a real browser — and each fix was then confirmed by reverting it and watching the new test fail. Security: the dialog could name one site and grant to another. The squatter guard added for grant/revoke by `origin` only ran when `app_uid` was absent, and the dialog sends both — so `app_uid` won and the guard never applied. `getAppUIDFromOrigin` returns the synthetic `app-<uuidv5(origin)>` for any origin with no app row of its own, and the grant endpoint resolves `app_uid` as uid *or name*, so the grant landed on whoever registered an app under that computed name (the format allows it, and the namespace is a source constant). A link like `/action/request-permission?origin=https://a-site-you-trust.example` named that site in the prompt while "Allow" handed the permission elsewhere. Fixed on both sides of the wire. The action now sends the origin alone — no uid resolved in the browser is safe to forward, whatever its source — and a supplied `origin` now decides the target on the server even when an `app_uid` travels beside it: the origin is what the prompt showed the user, so it is what the grant has to follow. Correctness: a COOP-only site was answered before the user decided. |
||
|
|
5faed55076 | fix: autoclaim app when making a subdomain (#3426) | ||
|
|
a8833de9d5 | fix: misc hardening (#3414) | ||
|
|
0e1be72f92 |
test: tests for puter.js (#3396)
* test: tests for puter.js * fix: ship lockfile for coverage devDeps; tolerate missing base coverage npm ci failed on CI because package.json gained the babel/istanbul devDependencies without the matching package-lock.json update. Also make the coverage workflow's base leg best-effort so a base ref that predates the coverage script reports without the comparison column instead of failing the run. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
0462ddd6f5 |
add support for step-up sessions (#3395)
* add support for step-up sessions * update step up session |
||
|
|
27def94d8b | feat: support cross-origin-isolated login (#3338) | ||
|
|
5160b44c4b |
fix: missing OIDC error messages (#3377)
* fix: missing OIDC error messages * fix: card verification user prefill out for subs |
||
|
|
f2ffaeb823 | fix: add phone errors into kv for debugging (#3367) | ||
|
|
d09aa11f2e | feat: add whatsapp support? (#3356) | ||
|
|
0801e1dc44 |
feat: add signup disable config and fix telemetry startup (#3319)
* feat: add signup disable config and fix telemetry startup * fix: nits for config spread and 403 checks Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Daniel Salazar <daniel.salazar@puter.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com> |