fix: CODEOWNERS + perm dialogs outside puter dashboard/desktop (#3833)
Maintain Release Merge PR / update-release-pr (push) Canceled after 0s
Notify HeyPuter / notify (push) Canceled after 0s
release-please / release-please (push) Canceled after 0s

This commit is contained in:
Daniel Salazar
2026-09-08 22:56:03 -07:00
committed by GitHub
parent 9bb5bffd01
commit 8a70fa2e5c
3 changed files with 191 additions and 10 deletions
+63 -2
View File
@@ -1,3 +1,64 @@
* @Salazareo @ProgrammerIn-wonderland @jelveh @jfcastro92
# Default owner, including backend and SDK areas without a specialist below.
# The last matching rule takes precedence.
* @Salazareo
/src/docs/ @Salazareo @ProgrammerIn-wonderland @jelveh @jfcastro92 @reynaldichernando
# GUI
/src/gui/ @jelveh @Salazareo
# Documentation, developer site, and CLI
/doc/ @reynaldichernando
/src/docs/ @reynaldichernando
/src/dev-center/ @reynaldichernando
/src/cli/ @reynaldichernando
# Backend: AI, hosting, workers, networking, and WebDAV
/src/backend/controllers/puterai/ @ProgrammerIn-wonderland
/src/backend/controllers/hosting/ @ProgrammerIn-wonderland
/src/backend/controllers/webdav/ @ProgrammerIn-wonderland
/src/backend/controllers/wisp/ @ProgrammerIn-wonderland
/src/backend/controllers/peer/ @ProgrammerIn-wonderland
/src/backend/drivers/ai-*/ @ProgrammerIn-wonderland
/src/backend/drivers/util/ @ProgrammerIn-wonderland
/src/backend/drivers/subdomain/ @ProgrammerIn-wonderland
/src/backend/drivers/workers/ @ProgrammerIn-wonderland
/src/backend/services/subdomain/ @ProgrammerIn-wonderland
/src/backend/services/localworker/ @ProgrammerIn-wonderland
/src/backend/services/socket/ @ProgrammerIn-wonderland
/src/backend/stores/subdomain/ @ProgrammerIn-wonderland
/extensions/workerSandbox* @ProgrammerIn-wonderland
/src/worker/ @ProgrammerIn-wonderland
/src/worker-types/ @ProgrammerIn-wonderland
# Backend: sharing, permissions, and teams
/src/backend/controllers/share/ @jfcastro92
/src/backend/controllers/team/ @jfcastro92
/src/backend/services/share/ @jfcastro92
/src/backend/services/permission/ @jfcastro92
/src/backend/services/acl/ @jfcastro92
/src/backend/services/team/ @jfcastro92
/src/backend/services/apps/AppPermissionService* @jfcastro92
/src/backend/stores/share/ @jfcastro92
/src/backend/stores/permission/ @jfcastro92
/src/backend/stores/group/ @jfcastro92
/src/backend/stores/team/ @jfcastro92
# SDK: AI, hosting, workers, and networking
/src/puter-js/src/modules/ai/ @ProgrammerIn-wonderland
/src/puter-js/src/modules/hosting/ @ProgrammerIn-wonderland
/src/puter-js/src/modules/Workers* @ProgrammerIn-wonderland
/src/puter-js/src/modules/networking/ @ProgrammerIn-wonderland
/src/puter-js/src/modules/Peer* @ProgrammerIn-wonderland
/src/puter-js/src/lib/networkUtils* @ProgrammerIn-wonderland
/src/puter-js/tests/api/suites/ai.suite.ts @ProgrammerIn-wonderland
/src/puter-js/tests/api/suites/hosting.suite.ts @ProgrammerIn-wonderland
/src/puter-js/tests/api/suites/workers.suite.ts @ProgrammerIn-wonderland
/src/puter-js/tests/api/suites/net.suite.ts @ProgrammerIn-wonderland
# SDK: sharing and permissions
/src/puter-js/src/modules/perms/ @jfcastro92
/src/puter-js/src/modules/FileSystem/operations/share* @jfcastro92
/src/puter-js/src/modules/FileSystem/operations/unshare* @jfcastro92
/src/puter-js/src/modules/FileSystem/operations/getShares* @jfcastro92
/src/puter-js/src/modules/FileSystem/operations/listShared* @jfcastro92
/src/puter-js/tests/api/suites/perms.suite.ts @jfcastro92
/src/puter-js/tests/api/suites/sharing.suite.ts @jfcastro92
+45 -6
View File
@@ -734,6 +734,36 @@ async function get_app_by_uid (uid) {
}
}
/**
* The uid of the app making the request, for the describers that compare it
* against a uid the permission itself names.
*
* The in-GUI path knows the uid outright (IPC.js passes it); the popup and
* iframe flows know the requester only by origin, so it is resolved here. It is
* resolved for the description alone and never sent to /auth/grant-user-app,
* which keeps receiving the origin and re-resolving it server-side: an origin
* with no app row of its own resolves to a synthetic `app-<uuidv5(origin)>`,
* and the grant endpoint reads its `app_uid` as uid-or-name, so a forwarded
* synthetic uid would hand the grant to whoever registered an app under that
* literal name.
*
* Null when there is nothing to resolve or the lookup fails —
* `getAppUIDFromOrigin` reports failure by resolving to a null or undefined uid
* rather than throwing (a refused origin comes back as an error body with no
* uid), and throws only when the call itself cannot be made.
*/
async function resolve_requesting_app_uid (options) {
if ( options.app_uid ) return options.app_uid;
if ( ! options.origin ) return null;
try {
const uid = await window.getAppUIDFromOrigin(options.origin);
return uid ?? null;
} catch (e) {
console.error('Failed to resolve requesting app', options.origin, e);
return null;
}
}
/**
* Describes `app-data:<uid>[:<store>[:<op>]]` — one app using another's data.
*
@@ -747,7 +777,12 @@ export async function get_app_data_description (parts, options) {
if ( ! target_uid ) return null;
// An app already reaches its own data; approving that would mean nothing.
if ( options.app_uid && target_uid === options.app_uid ) return null;
// A requester that will not resolve is not fatal here, unlike in the
// delegation describer below: this check only suppresses a prompt that
// would mean nothing, and denying on a failed lookup would refuse a
// cross-app request that is perfectly good.
const requester_app_uid = await resolve_requesting_app_uid(options);
if ( requester_app_uid && target_uid === requester_app_uid ) return null;
const app = await get_app_by_uid(target_uid);
if ( ! app ) return null;
@@ -791,17 +826,21 @@ export async function get_app_data_description (parts, options) {
* region of the data it keeps for this user to other people it picks.
*
* Returns null (which denies without prompting) for anything this copy cannot
* honestly bound: another user's data, a namespace that is not the requester's
* own, or a request naming no region — that last one is the whole of the app's
* data, which is a different decision and not one a prompt can put in a line.
* honestly bound: another user's data, a requester this copy cannot name, a
* namespace that is not the requester's own, or a request naming no region —
* that last one is the whole of the app's data, which is a different decision
* and not one a prompt can put in a line.
*/
export async function get_kv_share_description (parts, options) {
const [, , owner_uuid, namespace_app_uid, ...segments] = parts;
if ( ! owner_uuid || ! namespace_app_uid || segments.length === 0 ) return null;
// An app reaches its own namespace and no other, so a request naming
// another one describes access it could not use.
if ( ! options.app_uid || namespace_app_uid !== options.app_uid ) return null;
// another one describes access it could not use. Checking that at all
// needs the requester named, so an origin that will not resolve is refused
// here rather than prompted for.
const requester_app_uid = await resolve_requesting_app_uid(options);
if ( ! requester_app_uid || namespace_app_uid !== requester_app_uid ) return null;
const whoami = await puter.auth.whoami();
if ( whoami.uuid !== owner_uuid ) return null;
+83 -2
View File
@@ -16,6 +16,7 @@ const { get_app_data_description, get_kv_share_description } =
const CONTACTS = 'app-contacts';
const CALENDAR = 'app-calendar';
const OWNER = '2a1b0c9d-0000-4000-8000-000000000001';
const SITE = 'https://calendar.example';
/** Stub the app lookup the describer performs. */
const stubApp = (app) => {
@@ -25,12 +26,18 @@ const stubApp = (app) => {
}));
};
/** Stub the origin → app-uid lookup the popup flow's describers perform. */
const stubOriginApp = (uid) => {
window.getAppUIDFromOrigin = vi.fn(async () => uid);
};
const describeScope = (permission, options = { app_uid: CALENDAR }) =>
get_app_data_description(permission.split(':'), options);
describe('UIPermissionDialog app-data descriptions', () => {
beforeEach(() => {
stubApp({ uid: CONTACTS, name: 'contacts', title: 'Contacts' });
delete window.getAppUIDFromOrigin;
});
it('names the target app and the read verb', async () => {
@@ -106,12 +113,29 @@ describe('UIPermissionDialog app-data descriptions', () => {
});
expect(await describeScope(`app-data:${CONTACTS}:kv:get`)).toBeNull();
});
it('refuses a sites request for its own data', async () => {
// The popup flow reaches the same self-request check the in-GUI flow does.
stubOriginApp(CONTACTS);
expect(
await describeScope(`app-data:${CONTACTS}:kv:get`, { origin: SITE }),
).toBeNull();
});
it('still describes a cross-app request when the requester will not resolve', async () => {
// An unresolvable requester only costs the self-request check here; it
// must not deny a valid cross-app request.
stubOriginApp(null);
const d = await describeScope(`app-data:${CONTACTS}:kv:get`, { origin: SITE });
expect(d.html).toContain('perm_app_data_read');
});
});
describe('UIPermissionDialog key-value delegation descriptions', () => {
beforeEach(() => {
stubApp({ uid: CALENDAR, name: 'calendar', title: 'Calendar' });
globalThis.puter = { auth: { whoami: async () => ({ uuid: OWNER }) } };
delete window.getAppUIDFromOrigin;
});
const describeShare = (permission, options = { app_uid: CALENDAR }) =>
@@ -142,15 +166,72 @@ describe('UIPermissionDialog key-value delegation descriptions', () => {
`manage:kv-share:${OWNER}:${CONTACTS}:workspace:abc`,
),
).toBeNull();
// No requesting app at all — the popup flow — cannot be bounded either.
});
it('describes a delegation from a site that resolves to the namespaces app', async () => {
// The popup flow: the requester arrives as an origin, not a uid.
stubOriginApp(CALENDAR);
const d = await describeShare(
`manage:kv-share:${OWNER}:${CALENDAR}:workspace:abc`,
{ origin: SITE },
);
expect(d.html).toContain('perm_kv_share_manage');
expect(d.html).toContain('Calendar');
expect(d.html).toContain('region=workspace:abc:');
expect(window.getAppUIDFromOrigin).toHaveBeenCalledWith(SITE);
});
it('refuses a site that resolves to a different app', async () => {
stubOriginApp(CONTACTS);
expect(
await describeShare(
`manage:kv-share:${OWNER}:${CALENDAR}:workspace:abc`,
{ origin: 'https://site.example' },
{ origin: SITE },
),
).toBeNull();
});
it('refuses a site whose app uid does not resolve', async () => {
stubOriginApp(null);
expect(
await describeShare(
`manage:kv-share:${OWNER}:${CALENDAR}:workspace:abc`,
{ origin: SITE },
),
).toBeNull();
// The lookup reports failure by value, both shapes.
window.getAppUIDFromOrigin = vi.fn(async () => undefined);
expect(
await describeShare(
`manage:kv-share:${OWNER}:${CALENDAR}:workspace:abc`,
{ origin: SITE },
),
).toBeNull();
});
it('refuses when the lookup itself throws', async () => {
// A failed lookup must deny, never prompt.
window.getAppUIDFromOrigin = vi.fn(async () => {
throw new Error('network down');
});
expect(
await describeShare(
`manage:kv-share:${OWNER}:${CALENDAR}:workspace:abc`,
{ origin: SITE },
),
).toBeNull();
});
it('keeps naming the requester by uid when the GUI supplies one', async () => {
// A stub that would deny if it were consulted.
stubOriginApp(CONTACTS);
const d = await describeShare(
`manage:kv-share:${OWNER}:${CALENDAR}:workspace:abc`,
);
expect(d.html).toContain('perm_kv_share_manage');
expect(window.getAppUIDFromOrigin).not.toHaveBeenCalled();
});
it('refuses another users data', async () => {
expect(
await describeShare(