Skip to content

feat: let apps use another app's data with user consent - #3516

Merged
Salazareo merged 19 commits into
HeyPuter:mainfrom
jfcastro92:juancastro/put-1449-create-api-for-allowing-apps-to-use-other-apps-state
Aug 8, 2026
Merged

feat: let apps use another app's data with user consent#3516
Salazareo merged 19 commits into
HeyPuter:mainfrom
jfcastro92:juancastro/put-1449-create-api-for-allowing-apps-to-use-other-apps-state

Conversation

@jfcastro92

@jfcastro92 jfcastro92 commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Lets one app use another app's per-user state — that app's KV namespace and its /<user>/AppData/<appUid> directory — after the user consents. A calendar can read a contacts app's entries to show birthdays, add an invite, and cancel it again if the user allowed that.

const contacts = await puter.apps.get('contacts');
await puter.perms.requestAppData(contacts.uid, { kv: ['get', 'list'] });
await puter.kv.get('birthdays', { appUuid: contacts.uid });

Permission model

One new namespace, app-data:<targetAppUid>:<store>:<op>, where store is kv or fs. Grants are ordinary user_to_app_permissions rows written by the existing /auth/grant-user-app, so consent, audit, revoke and cache
invalidation all come for free. No new tables, no migration.

Three classes per store: read, write, and delete. delete is orthogonal to write — neither implies the other. That is what makes "may add invites but not remove them" expressible, and it is pinned by a test so a later
simplification can't quietly fold them together. Coarser grants (app-data:X:kv, app-data:X) still cover all three through the existing prefix implication.

Security callout

Flagging this per AGENTS.md, since it is a permission change.

  • Only a user prompt can grant this. app-data: has no manage: form, and canManagePermission gates grantUserUserPermission and grantDevAppPermission — so a developer cannot pre-authorise their own app
    for every user, and a user cannot pass the access on to another user.
  • Namespace-wide destruction is unreachable. kv:flush maps to null and is refused before any lookup, at every scope including app-data:<uid>.
  • Expiry is treated as deletion. set/batchPut accept expireAt and update accepts ttl; a past value makes a key vanish. Cross-app calls carrying either additionally require the delete class, otherwise kv:set alone would be a delete capability under another name.
  • Self-access is untouched. An app reaching its own data returns before any permission lookup; compat tests assert the permission service is never consulted on that path.
  • A grant creates no authority the user lacks. #scanUserApp still resolves every row through the issuing user.

Behaviour change

An app passing a foreign appUuid to puter.kv.* previously had it silently dropped and read its own namespace. It now reads the target's data with a grant, or gets a 403 (404 if the target app does not exist). Silently answering a different question than the caller asked was the worse failure, but it is a change — the test that asserted the old behaviour is rewritten rather than deleted.

Where it is enforced

Concern Location
Grant resolves at all implicator in AppPermissionService
Class → op widening exploder in AppPermissionService
KV access KVStoreDriver.#opts + SystemKVStore.getNamespace
FS read/write app-owns-appdata implicator in FSService
FS delete/move/rename FSService.remove/move/rename
Grant validation, batching AuthController
Withdrawal app.changed listener + PermissionStore prefix sweep

FS deletion is enforced in FSService rather than ACL because delete, move and rename all ask ACL for fs:write — ACL structurally cannot tell them apart, and adding a delete mode would force a platform-wide answer to "does write imply delete" that breaks either this feature or existing grants. FSService is also the one choke point both FS controllers and the /batch dispatcher pass through.

Per-entry privacy

Consent is coarse: a user allowing "Contacts' data" cannot see that the namespace holds an OAuth token. So an app can mark entries private when it writes them:

await puter.kv.set('googleRefreshToken', token, { disableSharing: true });

A private entry is invisible and untouchable to any other app — reads return null, listings omit it, writes and deletes are refused — regardless of what the user granted. Stored as a reserved DynamoDB item attribute beside value/ttl,
so no table change. list filters inside the query rather than after, so includeTotal cannot leak what the flag hides.

An app can also opt out entirely with share_app_data: false in its metadata, which is the only lever for secrets written before this shipped.

Withdrawal

The target app's uid lives inside the permission string, not a column, so no foreign key can cascade it. Grants are swept when the target is deleted, when its uid is reused by a new app (origin-derived uids are deterministic uuidv5 and regenerate verbatim), and when it stops sharing. Each removal is audited and the holder's permission cache generation bumped, so it takes effect immediately rather than after the 20s scan TTL.

Frontend

puter.perms.requestAppData() builds the scopes and prompts; IPC and the popup transport carry a list; the consent dialog renders one row per scope, names the target app rather than a uid, and refuses the whole prompt if any single scope is undescribable — a scope the user never saw must not ride along on an Allow. Deletion is named explicitly in the copy, including for coarse scopes that imply it.

Testing

  • Backend: full suite green (5033 tests), ~51 new across the permission service, KV driver and store, FS service, permission store and auth controller.
  • SDK: 20 new unit tests; GUI: 10 new for the dialog's description rules, six of which are the cases that must never prompt.
  • Verified against a running server end to end: cross-app read after a grant, read not carrying write, del refused without the delete class, flush refused at every scope, expiry refused without delete, a private entry hidden from a granted app but visible to its owner, mkdir-on-grant, and cascade-revoke taking effect on the very next read with a warm cache.

The GUI click-through path has been exercised manually; the third-party popup transport has not.

Known gaps and follow-ups

  • disableSharing is honoured by set() only; update/add ignore it.
  • The private-entry check is not atomic with the write that follows — a one
    operation window, and the flag is only ever set by the owner.
  • An unknown target returns 404 while a missing grant returns 403, so the two are
    distinguishable. App uids are already discoverable via puter.apps.get, and
    checking permission first would not close it.
  • No prefix-revoke endpoint: a settings UI must enumerate app-data: rows from
    /auth/list-permissions and revoke each.
  • New i18n keys are in en.js only. npm run check-translations is broken
    repo-wide (both package.json files point at a missing script).
  • Next step for informed consent is declare-to-share: the target publishes a
    shareable key prefix or subdirectory and no grant can exceed it.

@CLAassistant

CLAassistant commented Aug 6, 2026

Copy link
Copy Markdown

CLA assistant check
All committers have signed the CLA.

@jfcastro92

Copy link
Copy Markdown
Contributor Author

@Salazareo Following up on our conversation about reusing the ACL to manage file system permissions because of the existing rules, I took another look at it. After reviewing the implementation, I decided it's better to keep the logic independent in FSService.ts for a couple of reasons.

  • Read/write access is a permission rule, not an ACL rule. It's an implicator registered in PermissionService, and FSService is already where the fs: namespace semantics live (is-owner, app-owns-appdata, and the access-level expansion). There's also a correctness issue: permission.checkMany bypasses ACL entirely (it's what access-token minting uses). If this logic lived only in ACL, an app could read another app's AppData but wouldn't be able to mint a token for the same permission it was just granted. That's also why app-owns-appdata already exists outside of ACL.

  • The delete guard is a bit trickier. ACLService only receives a mode, and delete, move, and rename operations all reach it as write. By that point, ACL no longer knows whether the operation is actually a deletion. Introducing a separate delete mode would require us to define whether write implies delete across the platform, and neither option is ideal. If write implies delete, then cross-app write grants would also allow deletions again. If it doesn't, then every existing fs:<uuid>:write grant (access tokens, dev-app grants, shares, etc.) would suddenly lose delete permissions. Keeping this check in FSService, where the operation type is still known, avoids those issues and fits naturally alongside the ownership check that's already implemented there.

@jfcastro92
jfcastro92 marked this pull request as ready for review August 7, 2026 22:11
also adds the same exclusion for the batchPut api, small change

@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.

gave it a test, and was mostly good, one small thing we missed was the app access tokens, but thats on me, we didn't have that well explained in the code or actor object, have added that and updated some adjacent code.

also fixed a small bug with the batchPut, and some sql sanitation for the perm which likely wouldn't have been an issue, but wasn't too signficant

@Salazareo
Salazareo merged commit d202be1 into HeyPuter:main Aug 8, 2026
9 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.

3 participants