Skip to content

πŸ› οΈ PUT-1521: Cleanup puter js permissions api + backend routes - #3607

Merged
Salazareo merged 12 commits into
mainfrom
juancastro/put-1521-cleanup-puter-js-permissions-api-backend-routes
Aug 21, 2026
Merged

πŸ› οΈ PUT-1521: Cleanup puter js permissions api + backend routes#3607
Salazareo merged 12 commits into
mainfrom
juancastro/put-1521-cleanup-puter-js-permissions-api-backend-routes

Conversation

@jfcastro92

@jfcastro92 jfcastro92 commented Aug 19, 2026

Copy link
Copy Markdown
Collaborator

Two piles of unused surface had accumulated around permissions, and the request* family had grown one method per task. This removes the former, and replaces the latter with a single front door rather than a shorter list of the same shape.

1. User↔user and user↔group permission plumbing that nothing called. Filesystem sharing moved to puter.fs.share(), which records a share row so the owner can see and revoke it. The older direct-grant paths were left behind: /auth/grant-user-user was already a 501 stub, and grant/revoke-user-group plus the five /group/* CRUD routes had no caller anywhere β€” not the GUI, not a doc, not an app. puter.perms layered 8 undocumented wrappers on top of them.

2. One request* method per task. 15 methods differed only by a folder name or an access level (requestReadDesktop, requestWriteDesktop, … requestManageSubdomains). Every new resource meant another method, another doc page, another sidebar entry.

Supported puter.perms surface: 33 β†’ 8. Two permission methods (request, check) plus the six app/origin grant methods. Eight backend routes gone, along with the service and store methods they alone fed.

puter.js: one request(), one check()

The resource is the first argument and it decides which payload fields are accepted β€” the shape emit(name, data) uses, so a caller writes one call regardless of what they are asking for:

await puter.perms.request('email');                                         // β†’ address
await puter.perms.request('folder', { name: 'Documents', access: 'write' }); // β†’ path
await puter.perms.request('apps', { access: 'read' });                       // β†’ boolean
await puter.perms.request('subdomains', { access: 'write' });                // β†’ boolean
await puter.perms.request('appData', { app: 'contacts', scopes: 'read' });    // β†’ boolean
await puter.perms.request('appRootDir', { app: uid });                        // β†’ fs item
await puter.perms.request('permission', { permission: 'fs:<uid>:read' });     // β†’ boolean

Returns stay per-resource, so a call gives back what the caller actually needed rather than a boolean plus a second call to go get it. Everything denied is falsy, so one if covers both outcomes either way.

PermsBatchEntry is a discriminated union and each resource gets its own @overload, so request('apps', { name: 'Desktop' }) is a type error rather than a silently ignored field. The generated .d.ts carries all sixteen overloads.

check() β€” the same question, without a prompt

if ( ! await puter.perms.check('folder', { name: 'Documents', access: 'write' }) ) {
    showEnableBackupsButton();
}

Takes the same resource and details as request, and never opens a dialog. This is what lets an app offer an opt-in only where one is needed instead of prompting on load. Built on /auth/check-permissions, which is already live and already used internally by UI.js, so no new backend surface.

Two deliberate choices:

  • A failed check throws; it does not answer false. A caller that cannot tell "denied" from "the check never ran" would prompt someone who had already granted it.
  • A set is held only when all of it is. 'appData' with several scopes, or 'permission' with a list, answers false when partly granted β€” the prompt is still needed.

'appRootDir' is the one resource that asks the server rather than checking a permission string: app-root-dir:… is a pseudo-permission that only resolves while a grant is being written, so a permission check on it always answers false.

Batching

Either method takes an array, each entry naming its own resource:

const [documents, apps] = await puter.perms.request([
    { resource: 'folder', name: 'Documents', access: 'write' },
    { resource: 'apps' },
]);

Everything already held is settled before anything is asked, so the dialog lists only what is actually missing β€” and never appears at all when the whole batch is already granted. The set goes up as one request, which is the point: the user answers once and the answer covers all of it. A denial denies every entry that needed the prompt; entries that were already held keep their value, since nothing was asked about them.

check([...]) answers per entry in the order asked, so a caller can tell which parts are missing rather than only that some part is.

How the resources are wired

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 matching check cannot name the same permission differently. Single-resource request delegates to the method that has always served that resource, so those paths behave exactly as before.

Backward compatibility

All 22 older methods stay callable, typed, and unchanged β€” same arguments, return values and error codes. They carry @deprecated naming the call that replaces them:

Deprecated Replacement
requestEmail() request('email')
requestFolder(name, access) request('folder', { name, access })
requestApps(access) / requestSubdomains(access) request('apps' | 'subdomains', { access })
requestAppData(app, scopes) request('appData', { app, scopes })
requestAppRootDir(app, access) request('appRootDir', { app, access })
the 16 one-per-task names the calls above

They stay in the generated .d.ts rather than being hidden β€” stripInternal has no effect on declarations emitted from JavaScript, and dropping them by hand would break TypeScript callers the runtime still serves. Editors show them struck through.

request('fs:/path:read') still works: a lone string routes to the raw-permission path. No resource name contains a : and every permission string does, so the two forms cannot be confused.

grantApp, revokeApp, grantOrigin, revokeOrigin, grantAppAnyUser and revokeAppAnyUser are untouched and not deprecated β€” the consent dialog and the dashboard's uninstall path run through them.

Eight methods are removed outright and this is a break, signed off after review: grantUser, revokeUser, grantGroup, revokeGroup, createGroup, addUsersToGroup, removeUsersFromGroup, listGroups, plus the req_ shim. All were undocumented, absent from the sidebar, and grantUser already returned 501 from the server. puter.fs.unshare() replaces revokeUser and covers the legacy case, since it falls back to live grants when no share row exists.

New exported types: PermsResource, PermsBatchEntry, PermsAccessRequest, PermsFolderRequest, PermsAppDataRequest, PermsAppRootDirRequest, PermsPermissionRequest, PermsRequestDetails. Validation is client-side and rejects with { message, code: 'invalid_argument' } before any network call.

Backend

Routes deleted: grant-user-user, grant-user-group, revoke-user-group, /group/create, /group/add-users, /group/remove-users, /group/list, /group/public-groups.

That orphaned, and so also removes:

  • PermissionService.grant/revokeUserGroupPermission and its group-members cache bump
  • the three PermissionStore group writers
  • GroupStore down to addUsers/removeUsers β€” getByUid had no production caller once the routes went, which took the row decoder and GroupRow with it

revoke-user-user is kept, deprecated

Deleting this one went too far and it has been restored. 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.

PermissionService.revokeUserUserPermission never left (it is load-bearing for puter.fs.share()), so 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 SDK's revokeUser wrapper is not restored: puter.fs.unshare() covers it, and re-adding the wrapper would re-grow the surface this PR exists to shrink.

Direction this sets, per review: user-to-user access should be built as feature-specific APIs backed by the permission service β€” the way sharing was, where the grant and a listable, revocable share row are written together β€” rather than a catch-all string give/revoke API. That is the reasoning for deleting the rest rather than keeping them around.

Migration: three groups no code reads

freeai, experimental and dangerous exist in prod but in no migration β€” added by hand when hardcoded permissions were keyed by group name. That map is now a flat per-user floor (default_user_permissions in data/hardcoded-permissions.js), so a group nothing looks up grants nothing. Dropped for sqlite, mysql and postgres.

Guarded, not unconditional. user_to_group_permissions.group_id and jct_user_group.group_id are both ON DELETE CASCADE, so removing a group that still carries permissions or members would silently revoke them from every member. Only a group with neither is dropped, and four tests cover both branches. system, admin, user and temp are untouched β€” config names two, code names the others.

Consequence worth knowing at deploy time: if a group survives the migration, the cleanup silently did not happen for it. Check which way it went:

SELECT g.id, g.extra ->> '$.name' AS name,
       (SELECT COUNT(*) FROM user_to_group_permissions p WHERE p.group_id = g.id) AS perms,
       (SELECT COUNT(*) FROM jct_user_group j WHERE j.group_id = g.id) AS members
FROM `group` g
WHERE g.extra ->> '$.name' IN ('freeai','experimental','dangerous');

Non-zero perms or members means those grants need a deliberate decision before the rows can go.

⚠️ Permissions callout

Per AGENTS.md, this is a permission-related change. No permission check logic was modified. What deliberately stayed:

  • PermissionService.grant/revokeUserUserPermission β€” ACLService and ShareService power puter.fs.share() through them. The grant route is gone and the revoke route is deprecated-but-live; the service methods are load-bearing.
  • The group permission read path (#scanUserGroup, readUserGroupPerms, FSService) β€” kept because prod may hold group permission rows this repo can't see. Note the migration-seeded system β†’ admin group β†’ driver row is not the justification: default_user_permissions already gives every user actor bare driver, so that row is redundant.
  • GroupStore.addUsers/removeUsers β€” signup, save_account, OIDC and the self-hosted default user all assign group membership.
  • grant/revoke-user-app and grant/revoke-dev-app β€” the consent-dialog path (UIPermissionDialog, UIDesktop), and the dashboard calls puter.perms.revokeApp(appUid, '*') to clear grants on app uninstall.
  • /auth/check-permissions β€” unchanged; check() is a new consumer of it, not a new endpoint.
  • No table is dropped and no schema is altered. user_to_user_permissions, user_to_group_permissions and both audit tables keep their rows, so no historical grant or audit record is lost.

check() widens no access: it reports what the caller already holds, evaluated by the same checkMany path the route already used.

Docs

The review asked that the docs wait for a nicer request surface, so they now land on it rather than on the collapsed one-per-task names:

  • /Perms/request β€” the resource table, the details each takes, batching, and the raw-string escape hatch
  • /Perms/check β€” new
  • /Perms/appData β€” requestAppData.md re-homed rather than deleted: its scope table, private-entry guidance (disableSharing) and grant-lifetime notes are not signature documentation and have nowhere else to live. Inbound links from KV/set.md and Objects/app.md follow it.
  • Deleted: the four thin per-method pages (requestEmail, requestFolder, requestApps, requestSubdomains) β€” signature plus one example, all of it now on the request() page
  • The Perms.md overview is rewritten around the two methods, and the playground examples move to the new call form

requestAppRootDir stays undocumented, matching its status today β€” this PR shrinks published surface rather than growing it.

Testing

Not re-run: the Playwright requestPermission.spec.js suite (33 passed earlier on this branch). It drives the GUI consent dialog through puter.ui.requestPermission and the grant/revoke-user-app routes; it does not reference puter.perms, and none of that changed here.

Notes for reviewers

  • This reverses one of the eight route deletions you approved. revoke-user-user came back deliberately, for the backward-compat reason above; the other seven and the 501 stub stand.
  • One test from πŸ”§ PUR-1072: Flatten driver permissions to hardcoded valuesΒ #3545 was deleted. never queries group membership to resolve a user permission spied on GroupStore.listGroupsWithMember, which this PR removes, so the test couldn't survive. The invariant now holds by construction: the only jct_user_group reference left in production code is the inline JOIN inside readUserGroupPerms β€” one query, no membership enumeration β€” and the method it guarded no longer exists to call. Happy to add a replacement guard if reviewers want belt-and-braces.
  • @internal does not do what AGENTS.md line 118 says. It claims tagged members are stripped from the generated declarations. stripInternal has no effect on declarations emitted from JavaScript β€” verified with an isolated repro, and a pre-existing @internal method in src/puter-js/src/index.js also survives into types/. That's why the aliases here are marked @deprecated only. Worth a separate docs fix.
  • A batch can skip a prompt a single call would raise. check is the authority on what is held, and the batch consults it before prompting; single-resource request keeps its existing flow unchanged (requestFolder, for instance, always prompts for write). For an app-under-user actor β€” the audience for these calls β€” the two agree, since the app holds nothing implicitly. Happy to align the single path too if you'd rather, but that changes a shipped method's flow and felt out of scope here.

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.
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.
…n 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.
@github-actions

github-actions Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Coverage Report

Status Category Percentage Covered / Total
πŸ”΅ Lines 93.59%
⬇️ -0.04%
24127 / 25777
πŸ”΅ Statements 91.87%
⬇️ -0.04%
25988 / 28285
πŸ”΅ Functions 89.82%
⬆️ +0.07%
4149 / 4619
πŸ”΅ Branches 80.46%
⬆️ +0.16%
17547 / 21808
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
src/backend/clients/database/SqliteDatabaseClient.ts 89.14%
🟰 ±0%
57.45%
🟰 ±0%
81.6%
🟰 ±0%
95.58%
🟰 ±0%
6, 9, 13, 16, 24-26, 32, 33, 34, 38-39, 44, 45, 46, 47, 48, 233-235, 483-486, 501
src/backend/controllers/auth/AuthController.ts 90.57%
⬇️ -0.13%
78.98%
⬇️ -0.24%
77.39%
⬇️ -1.47%
91.5%
⬇️ -0.09%
6, 24-26, 32, 33, 34, 38-39, 44, 45, 47, 468-470, 484-486, 614-615, 689-691, 708-710, 839, 901-905, 923, 964-969, 978, 989, 1003-1011, 1052-1053, 1056-1061, 1095, 1201, 1245-1246, 1266-1270, 1284-1285, 1293-1295, 1297-1299, 1347, 1375-1386, 1461-1463, 1476, 1536-1538, 1572, 1585-1587, 1613-1615, 1646, 1650-1652, 1750, 1836, 1895, 1940, 1999, 2012-2015, 2057-2059, 2077-2080, 2134-2136, 2148-2150, 2166-2168, 2182-2185, 2191, 2210, 2221, 2337-2341, 2370, 2401-2403, 2415-2421, 2481-2486, 2560-2562, 2625, 2653, 2694-2696, 2762, 2792, 2871-2873, 2875-2877, 2881-2884, 2897-2900, 2965-2970, 2973-2975, 3002, 3021, 3039, 3057, 3070, 3093, 3105, 3122, 3132, 3143, 3292-3296, 3317-3319, 3333, 3336-3340, 3415
src/backend/controllers/fs/LegacyFSController.ts 92.29%
⬆️ +0.02%
80.63%
⬆️ +0.07%
80.24%
🟰 ±0%
93.28%
⬆️ +0.03%
100-110, 410-416, 470, 999-1000, 1192, 1203, 1275-1277, 1344-1353, 1358-1364, 1377-1380, 1422-1424, 1738, 1874-1876, 2003, 2059-2060, 2074, 2106-2110, 2115, 2256-2258, 2298-2300, 2443-2448, 2452-2457, 2461-2466, 2502-2508, 2512, 2526, 2534, 2544, 2548, 2569-2570, 2575-2578, 2597-2599, 2611, 2612, 2614, 2645-2655
src/backend/services/apps/AppPermissionService.ts 93.79%
⬆️ +0.32%
90.82%
⬆️ +0.44%
100%
🟰 ±0%
97.5%
⬆️ +0.14%
89, 93, 95, 122, 174-176, 189-191, 227-231, 251, 419
src/backend/services/permission/PermissionService.ts 90.71%
⬇️ -0.12%
73.72%
⬇️ -0.63%
73.25%
⬆️ +0.43%
94.41%
🟰 ±0%
6, 9, 13, 16, 24-26, 32, 33, 34, 38-39, 45, 46, 48, 152-153, 221-231, 331, 366-368, 389-392, 413-415, 443-446, 481-483, 514-516, 518-520, 549-551, 577-579, 603-605, 607-609, 626-628, 635-637, 782, 789, 875, 889, 901, 920, 930
src/backend/services/share/ShareService.ts 92.52%
🟰 ±0%
80.6%
⬇️ -0.33%
94.87%
🟰 ±0%
95.37%
🟰 ±0%
40, 77, 88, 100-104, 109, 116-120, 133, 183, 184, 267-269, 301-306, 355, 360, 370, 459, 482, 518, 588, 789, 839, 847-848, 858-859, 874-875, 884, 897-898, 906-910, 967-968, 1000-1002, 1023-1025, 1054, 1188-1189
src/backend/stores/group/GroupStore.ts 100%
⬆️ +4.17%
100%
⬆️ +25.00%
100%
⬆️ +7.70%
100%
⬆️ +3.45%
src/backend/stores/permission/PermissionStore.ts 94.07%
⬇️ -0.09%
83%
🟰 ±0%
93.4%
⬇️ -0.21%
96.41%
⬇️ -0.06%
29, 33, 35-38, 53, 57, 115, 155, 227, 529, 565-568, 587, 627, 726, 728, 733
Generated in workflow #810 for commit d278219 by the Vitest Coverage Report Action

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.
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.
@jfcastro92
jfcastro92 marked this pull request as ready for review August 19, 2026 19:53
@jfcastro92
jfcastro92 requested a review from Salazareo August 19, 2026 19:53
@github-actions

github-actions Bot commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Coverage Report for puter.js SDK

Status Category Percentage Covered / Total
πŸ”΅ Lines 62.11%
⬆️ +0.32%
3927 / 6322
πŸ”΅ Statements 60.94%
⬆️ +0.36%
4138 / 6790
πŸ”΅ Functions 62.33%
⬆️ +0.84%
758 / 1216
πŸ”΅ Branches 54.93%
⬆️ +0.28%
2539 / 4622
File Coverage
File Stmts Branches Functions Lines Uncovered Lines
Changed Files
src/puter-js/src/modules/perms/appData.js 10.71%
⬇️ -0.26%
0%
🟰 ±0%
0%
🟰 ±0%
13.04%
⬇️ -0.39%
34-51, 63-110, 115-189
src/puter-js/src/modules/perms/appRootDir.js 58.82%
⬆️ +10.82%
45.45%
⬇️ -10.80%
75%
⬇️ -5.00%
62.06%
⬆️ +7.52%
66, 68-100, 118, 125
src/puter-js/src/modules/perms/folders.js 75%
⬇️ -25.00%
83.33%
⬆️ +8.33%
66.66%
⬇️ -33.34%
75%
⬇️ -25.00%
34, 76, 103, 121, 139
src/puter-js/src/modules/perms/grants.js 100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
src/puter-js/src/modules/perms/index.js 100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
100%
🟰 ±0%
src/puter-js/src/modules/perms/permissions.js 76.47%
🟰 ±0%
33.33%
⬆️ +8.33%
100%
🟰 ±0%
75%
🟰 ±0%
77-82
src/puter-js/src/modules/perms/request.js 79.52% 75% 90.47% 81.08% 48, 68, 71, 75, 81, 130, 133, 138, 160, 186-194, 215-216, 223-228, 255, 258, 284, 291-294, 315, 366-367, 401
src/puter-js/src/modules/perms/lib/holds.js 75% 25% 100% 75% 19-22
src/puter-js/src/modules/perms/lib/permissionStrings.js 100% 100% 100% 100%
src/puter-js/src/modules/perms/lib/validate.js 100% 100% 100% 100%
Generated in workflow #186 for commit d278219 by the Vitest Coverage Report Action

@Salazareo Salazareo left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

backend changes look good,

but I think the docs should wait until we expose a nicer way to request the permissions:

puter.perms.request(Enum, detailsObj)

jfcastro92 and others added 7 commits August 20, 2026 16:32
…+ 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.
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.
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.
…ains

`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>
`/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>
`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>
- `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>

@Salazareo Salazareo left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

made some changes, but should be good now

@Salazareo
Salazareo merged commit f0cd251 into main Aug 21, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants