diff --git a/package-lock.json b/package-lock.json index b37dd8a59..7ca0a1796 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1216,6 +1216,7 @@ }, "node_modules/@clack/prompts/node_modules/is-unicode-supported": { "version": "1.3.0", + "extraneous": true, "inBundle": true, "license": "MIT", "engines": { diff --git a/src/backend/controllers/share/ShareController.http.test.ts b/src/backend/controllers/share/ShareController.http.test.ts index b93484811..047aacd91 100644 --- a/src/backend/controllers/share/ShareController.http.test.ts +++ b/src/backend/controllers/share/ShareController.http.test.ts @@ -1127,12 +1127,16 @@ describe('share endpoints over HTTP', () => { expect(mine.status).toBe(200); expect(await mine.text()).toContain(email); + // The app is answered, but bounded to the rows it issued — and it + // issued none, so the owner's invite is not among them. const app = await appHolding(owner, file.uid, (u) => `fs:${u}:read`); const peeked = await get('/share/shares', app.token, { uid: file.uid, }); - expect(peeked.status).not.toBe(200); - expect(await peeked.text()).not.toContain(email); + expect(peeked.status).toBe(200); + const peekedBody = await peeked.text(); + expect(peekedBody).not.toContain(email); + expect(JSON.parse(peekedBody).items).toEqual([]); // `return_shares` is the same listing on an access-token surface. const stat = await post('/fs/stat', app.token, { @@ -1147,7 +1151,7 @@ describe('share endpoints over HTTP', () => { // The ticket's second route in: `ShareController` admits no access // token, but `/fs/stat` does, and `return_shares` is the same listing. - it('refuses a list-scoped access token on the `return_shares` surface', async () => { + it('withholds the listing from a list-scoped access token', async () => { const owner = env.users.user; const { file, email } = await invitedFile(owner); const user = await env.server.stores.user.getByUsername( @@ -1208,8 +1212,13 @@ describe('share endpoints over HTTP', () => { }); expect(res.status).toBe(200); const text = await res.text(); - // The invite is listed; the address it names is not. - expect(JSON.parse(text).items.some((i: { pending?: boolean }) => i.pending)).toBe(true); + // `manage` buys an app authority over the item, not sight of who + // else the owner invited to it. + expect( + JSON.parse(text).items.some( + (i: { pending?: boolean }) => i.pending, + ), + ).toBe(false); expect(text).not.toContain(email); }); }); diff --git a/src/backend/services/share/ShareService.ts b/src/backend/services/share/ShareService.ts index e7d751df5..6dc9d1908 100644 --- a/src/backend/services/share/ShareService.ts +++ b/src/backend/services/share/ShareService.ts @@ -2208,10 +2208,19 @@ export class ShareService extends PuterService { // reaches the node — another app, a delegate, an address the user // invited — is the user's business, not the app's. const actingApp = this.#actingAppUid(actor); - const issuedHere = (list: T[]): T[] => - actingApp - ? list.filter((row) => issuedByApp(row) === actingApp) - : list; + // A scoped token issues nothing under its own name, so it is bounded + // to nothing: filtering it by a null app would hand it the owner's own + // rows instead of none. A full-access token holds the account's reach + // and is not bounded at all. + const scopedToken = + !!actor.accessToken && actor.accessToken.fullAccess !== true; + const issuedHere = (list: T[]): T[] => { + if (scopedToken) return []; + if (actingApp) { + return list.filter((row) => issuedByApp(row) === actingApp); + } + return list; + }; const inherited: Array<{ row: ShareIndexRow; via: string }> = issuedHere(