From 1736d23ee71d7e2a5e40c83b9f787ae2110e8828 Mon Sep 17 00:00:00 2001 From: Daniel Salazar Date: Tue, 1 Sep 2026 14:07:15 -0700 Subject: [PATCH] Ds/put 1674 (#3715) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: app-scoped share listing and revoke surface (PUT-1670) * fix: address review on app-scoped share surface - Scope the uid-addressed revoke to the named row: only that row's issuer's grant is withdrawn, and only that one invite cancelled — an app or owner addressing one row no longer takes another issuer's grant on the same (item, recipient) pair with it. - Delete a pending row directly on uid-addressed revoke, so an invite whose address registered but never claimed can still be withdrawn. - Read the legacy `issuerAppUid` data key in the SQL app filter and grouping, alongside the unified `issuedByApp`. - Refuse malformed `appUid` input (duplicated param, empty string) instead of silently listing everything, and refuse app-listing cursors that decode but name no appUid. - Derive the acting app from `effectiveApp` alone, per the actor contract; drop the second derivation site. - Pin the attribution semantics with tests: one row records one issuance, so re-sharing the same pair re-attributes it to whoever issued last, in both directions. - Soften the uniform-404 docblocks to what the gates actually answer. * feat: readable grant audit trail (PUT-1674) * fix: cover the apps summary's no-app-group first page (PUT-1670) listOutboundApps sorts the no-app group first via an empty-string sentinel. Add a regression test pinning that a first page (no cursor) actually returns it, and that the cursor it hands back resumes past it into the app-keyed groups rather than skipping or repeating. --- .../share/ShareController.http.test.ts | 275 +++++++- .../controllers/share/ShareController.ts | 128 +++- .../services/permission/PermissionService.ts | 23 + .../services/share/ShareService.test.ts | 634 +++++++++++++++++- src/backend/services/share/ShareService.ts | 306 ++++++++- .../stores/permission/PermissionStore.test.ts | 88 +++ .../stores/permission/PermissionStore.ts | 131 +++- src/backend/stores/share/ShareStore.js | 165 ++++- src/backend/stores/share/ShareStore.test.js | 203 ++++++ src/docs/src/FS/listSharedByMe.md | 1 + src/puter-js/index.d.ts | 1 + .../FileSystem/operations/listSharedByMe.js | 8 +- src/puter-js/src/modules/FileSystem/types.js | 11 + 13 files changed, 1927 insertions(+), 47 deletions(-) diff --git a/src/backend/controllers/share/ShareController.http.test.ts b/src/backend/controllers/share/ShareController.http.test.ts index 1756cdfcd..8cdab331e 100644 --- a/src/backend/controllers/share/ShareController.http.test.ts +++ b/src/backend/controllers/share/ShareController.http.test.ts @@ -18,6 +18,8 @@ */ import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { makeActor } from '../../core/actor.js'; +import { runWithContext } from '../../core/context.js'; import { createTestUser, setupPuterTestEnv, @@ -58,6 +60,21 @@ describe('share endpoints over HTTP', () => { return fetch(url, { headers: { authorization: `Bearer ${token}` } }); }; + const del = (path: string, token: string) => + fetch(new URL(path, env.apiOrigin), { + method: 'DELETE', + headers: { authorization: `Bearer ${token}` }, + }); + + /** Fresh accounts: the seeded ones accumulate shares across this file. */ + const makeUser = async () => { + const username = `sbm${Math.random().toString(36).slice(2, 9)}`; + return createTestUser(env.server, { + username, + password: 'puter-test-user-password', + }); + }; + /** A file in the owner's home. Written directly — these tests are about * the share routes, not the upload path. */ const makeFile = async (owner: { username: string }) => { @@ -196,15 +213,6 @@ describe('share endpoints over HTTP', () => { }); describe('GET /share/shared-by-me', () => { - /** Fresh accounts: the shared ones accumulate shares across this file. */ - const makeUser = async () => { - const username = `sbm${Math.random().toString(36).slice(2, 9)}`; - return createTestUser(env.server, { - username, - password: 'puter-test-user-password', - }); - }; - it('lists what the caller shared out, across unrelated items', async () => { const owner = await makeUser(); const recipient = await makeUser(); @@ -332,6 +340,26 @@ describe('share endpoints over HTTP', () => { expect(replayed.items).toEqual([]); }); + // A caller who believes they filtered must not silently receive + // everything: a duplicated param arrives as an array, and an empty + // string names nothing. + it('refuses a malformed appUid instead of listing everything', async () => { + const owner = await makeUser(); + expect( + (await get('/share/shared-by-me', owner.token, { appUid: '' })) + .status, + ).toBe(400); + expect( + ( + await get( + '/share/shared-by-me?appUid=a&appUid=a', + owner.token, + {}, + ) + ).status, + ).toBe(400); + }); + // The two directions of one listing; a gate on only one of them is a // hole in whichever was forgotten. it('is gated like the inbound listing', () => { @@ -350,6 +378,235 @@ describe('share endpoints over HTTP', () => { }); }); + describe('the outbound listing scoped to an app', () => { + const actorFor = async (username: string) => { + const user = await env.server.stores.user.getByUsername(username); + return makeActor({ user: user! }); + }; + + /** An app of the user's, with a token and reach over one file. */ + const makeApp = async ( + owner: { username: string }, + file: { uid: string }, + ) => { + const actor = await actorFor(owner.username); + const app = await env.server.stores.app.create( + { + name: `share-http-app-${crypto.randomUUID()}`, + title: 'Share app', + index_url: `https://share-${crypto.randomUUID()}.test/`, + }, + { ownerUserId: actor.user.id }, + ); + await runWithContext({ actor }, () => + env.server.services.permission.grantUserAppPermission( + actor, + app.uid, + `fs:${file.uid}:read`, + ), + ); + const token = await env.server.services.auth.getUserAppToken( + actor, + app.uid, + ); + return { ...app, token }; + }; + + /** An owner whose two apps have each shared a file of theirs. */ + const twoApps = async () => { + const owner = await makeUser(); + const recipient = await makeUser(); + const files = [await makeFile(owner), await makeFile(owner)]; + const apps = [ + await makeApp(owner, files[0]), + await makeApp(owner, files[1]), + ]; + for (const [index, app] of apps.entries()) { + const res = await post('/share', app.token, { + recipients: [recipient.username], + items: [{ uid: files[index].uid }], + mode: 'read', + }); + expect(res.status).toBe(200); + } + return { owner, recipient, files, apps }; + }; + + it('shows an app its own grants only', async () => { + const { files, apps } = await twoApps(); + + for (const [index, app] of apps.entries()) { + const body = (await ( + await get('/share/shared-by-me', app.token, { + includeTotal: 'true', + }) + ).json()) as { + items: Array>; + total?: number; + }; + expect(body.items.map((i) => i.uid_entry)).toEqual([ + files[index].uid, + ]); + expect(body.items[0].issued_by_app).toBe(app.uid); + expect(body.total).toBe(1); + } + + // Naming the other app changes nothing: the credential is the scope. + const crossed = (await ( + await get('/share/shared-by-me', apps[0].token, { + appUid: apps[1].uid, + }) + ).json()) as { items: unknown[] }; + expect(crossed.items).toEqual([]); + }); + + it('groups a session listing by app and drills back in', async () => { + const { owner, recipient, files, apps } = await twoApps(); + const byHand = await makeFile(owner); + await post('/share', owner.token, { + recipients: [recipient.username], + items: [{ uid: byHand.uid }], + mode: 'read', + }); + + const grouped = (await ( + await get('/share/shared-by-me/apps', owner.token, { + includeTotal: 'true', + }) + ).json()) as { + items: Array<{ + appUid: string | null; + name: string | null; + count: number; + }>; + total?: number; + }; + expect(grouped.total).toBe(3); + const byApp = new Map(grouped.items.map((i) => [i.appUid, i])); + expect(byApp.get(null)?.count).toBe(1); + expect(byApp.get(apps[0].uid)).toMatchObject({ + name: apps[0].name, + count: 1, + }); + + const drilled = (await ( + await get('/share/shared-by-me', owner.token, { + appUid: apps[0].uid, + }) + ).json()) as { items: Array<{ uid_entry: string }> }; + expect(drilled.items.map((i) => i.uid_entry)).toEqual([ + files[0].uid, + ]); + + const manual = (await ( + await get('/share/shared-by-me', owner.token, { + appUid: 'none', + }) + ).json()) as { items: Array<{ uid_entry: string }> }; + expect(manual.items.map((i) => i.uid_entry)).toEqual([byHand.uid]); + }); + + it('keeps the grouped view off app tokens', async () => { + const { apps } = await twoApps(); + const res = await get('/share/shared-by-me/apps', apps[0].token, {}); + expect(res.status).toBe(403); + }); + + it('revokes a listed share, and answers 404 for one that is not the caller\'s', async () => { + const { owner, apps } = await twoApps(); + const stranger = await makeUser(); + const listed = (await ( + await get('/share/shared-by-me', apps[1].token, {}) + ).json()) as { items: Array<{ uid: string }> }; + const uid = listed.items[0].uid; + + expect((await del(`/share/shared-by-me/${uid}`, apps[0].token)).status) + .toBe(404); + expect( + (await del(`/share/shared-by-me/${uid}`, stranger.token)).status, + ).toBe(404); + expect( + ( + await del( + `/share/shared-by-me/${crypto.randomUUID()}`, + owner.token, + ) + ).status, + ).toBe(404); + + const revoked = await del(`/share/shared-by-me/${uid}`, owner.token); + expect(revoked.status).toBe(200); + expect(await revoked.json()).toMatchObject({ uid, revoked: 1 }); + + const after = (await ( + await get('/share/shared-by-me', apps[1].token, {}) + ).json()) as { items: Array<{ uid: string }> }; + expect(after.items).toEqual([]); + }); + + it('reads the grant audit trail, and keeps it after a revoke', async () => { + const { owner, recipient, files, apps } = await twoApps(); + + const trail = async (token: string, params = {}) => + (await (await get('/share/audit', token, params)).json()) as { + items: Array>; + total?: number; + }; + + const granted = await trail(owner.token, { + uid: files[0].uid, + includeTotal: 'true', + }); + expect(granted.total).toBe(granted.items.length); + expect(granted.items[0]).toMatchObject({ + action: 'grant', + entryUid: files[0].uid, + issuer: owner.username, + holder: recipient.username, + appUid: apps[0].uid, + }); + // Nothing internal rides along. + for (const key of ['issuer_user_id', 'holder_user_id', 'reason']) { + expect(granted.items[0]).not.toHaveProperty(key); + } + + await post('/share/revoke', owner.token, { + recipients: [recipient.username], + items: [{ uid: files[0].uid }], + }); + const afterRevoke = await trail(owner.token, { + uid: files[0].uid, + }); + expect( + afterRevoke.items.map((i) => i.action), + ).toEqual(expect.arrayContaining(['grant', 'revoke'])); + }); + + it('will not hand one account the trail of another\'s', async () => { + const { files } = await twoApps(); + const stranger = await makeUser(); + + const res = await get('/share/audit', stranger.token, { + uid: files[0].uid, + }); + expect(res.status).toBe(404); + + // Their own listing is theirs alone, and they have granted nothing. + const own = (await ( + await get('/share/audit', stranger.token, {}) + ).json()) as { items: unknown[] }; + expect(own.items).toEqual([]); + }); + + it('keeps the audit trail off app tokens', async () => { + const { apps, files } = await twoApps(); + const res = await get('/share/audit', apps[0].token, { + uid: files[0].uid, + }); + expect(res.status).toBe(403); + }); + }); + it('revokes every item in the request, not just the first', async () => { const owner = env.users.user; const recipient = env.users.other; diff --git a/src/backend/controllers/share/ShareController.ts b/src/backend/controllers/share/ShareController.ts index fa16c6cae..f9246743b 100644 --- a/src/backend/controllers/share/ShareController.ts +++ b/src/backend/controllers/share/ShareController.ts @@ -58,6 +58,9 @@ const SHARE_LIST_LIMIT = { const SHARE_CONCURRENCY = 8; const LIST_LIMIT_CAP = 200; +/** `appUid` value asking for the grants no app issued. App uids are uuids. */ +const NO_APP = 'none'; + /** * Caps on one request's fan-out. Recipients matter most: that number is how * many people a single call can reach, so it stays small by default and only @@ -275,6 +278,10 @@ export class ShareController extends PuterController { * shared out. `GET /share/shares` answers this for one item at a time, * which cannot answer it at all for a caller who doesn't know what to ask * about. + * + * `appUid` narrows to one app's grants, or to `none` for the ones the user + * made themselves. An app token is bound to its own app regardless, so it + * only ever sees what it issued. */ @Get('/shared-by-me', { subdomain: 'api', @@ -282,8 +289,9 @@ export class ShareController extends PuterController { rateLimit: SHARE_LIST_LIMIT, }) async listSharedByMe(req: Request, res: Response): Promise { + const appUid = this.#appUidFilter(this.#query(req)); await this.#listSharePage(req, res, (actor, opts) => - this.services.share.listSharedByMe(actor, opts), + this.services.share.listSharedByMe(actor, { ...opts, appUid }), ); } @@ -321,6 +329,60 @@ export class ShareController extends PuterController { }); } + /** + * GET /share/shared-by-me/apps — which apps hold shares the caller made, + * with a count each. The way into `appUid` for someone who doesn't know + * which apps to ask about; the group with a null `appUid` is what they + * shared themselves. + */ + @Get('/shared-by-me/apps', { + subdomain: 'api', + requireUserActor: true, + requireVerified: true, + rateLimit: SHARE_LIST_LIMIT, + }) + async listSharedByMeApps(req: Request, res: Response): Promise { + const actor = this.#requireActor(req); + const query = this.#query(req); + + const page = await this.services.share.listSharedByMeApps(actor, { + limit: normalizeLimit(query.limit, { cap: LIST_LIMIT_CAP }), + cursor: typeof query.cursor === 'string' ? query.cursor : undefined, + includeTotal: query.includeTotal === 'true', + }); + + res.json({ + items: page.items, + ...(page.cursor ? { cursor: page.cursor } : {}), + ...(page.total !== undefined ? { total: page.total } : {}), + }); + } + + /** + * DELETE /share/shared-by-me/:uid — withdraw one listed share, scoped to + * that row's issuer. + * + * An uid outside the caller's view — another account's, or another app's to + * an app — answers 404 like one that names nothing, so this can't be used + * to learn which. Within their view, the revoke's own rules answer: lapsed + * authority gets the ACL's error, a grant already withdrawn elsewhere + * reports `revoked: 0`. + */ + @Delete('/shared-by-me/:uid', { + subdomain: 'api', + requireVerified: true, + rateLimit: SHARE_LIMIT, + }) + async revokeSharedByMe(req: Request, res: Response): Promise { + const actor = this.#requireActor(req); + const uid = String(req.params.uid ?? ''); + const { revoked } = await this.services.share.revokeSharedByMe( + actor, + uid, + ); + res.json({ uid, revoked }); + } + /** GET /share/shares — who can reach one item. */ @Get('/shares', { subdomain: 'api', @@ -348,6 +410,50 @@ export class ShareController extends PuterController { }); } + /** + * GET /share/audit — when a grant was made, by whom, and under which app. + * + * With `uid` or `path`, the trail of everything granted on that item, for + * whoever may manage it; with neither, the trail of what the caller + * granted. Rows outlive the grants they describe, so this still answers + * after a revoke. + */ + @Get('/audit', { + subdomain: 'api', + requireUserActor: true, + requireVerified: true, + rateLimit: SHARE_LIST_LIMIT, + }) + async listGrantAudit(req: Request, res: Response): Promise { + const actor = this.#requireActor(req); + const query = this.#query(req); + const target: ShareTarget = {}; + if (typeof query.uid === 'string') target.uid = query.uid; + if (typeof query.path === 'string') + target.path = expandTildePath(query.path, actor.user?.username); + + const page = await this.services.share.listGrantAudit(actor, target, { + limit: normalizeLimit(query.limit, { cap: LIST_LIMIT_CAP }), + cursor: typeof query.cursor === 'string' ? query.cursor : undefined, + includeTotal: query.includeTotal === 'true', + }); + + res.json({ + items: page.items.map((entry) => ({ + action: entry.action, + permission: entry.permission, + entryUid: entry.entryUid, + mode: entry.mode, + issuer: entry.issuer.username, + holder: entry.holder.username, + appUid: entry.appUid, + createdAt: entry.createdAt, + })), + ...(page.cursor ? { cursor: page.cursor } : {}), + ...(page.total !== undefined ? { total: page.total } : {}), + }); + } + // -- Blocking ----------------------------------------------------- // User sessions only: a block list is a safety control, not an app's to touch. @@ -512,6 +618,26 @@ export class ShareController extends PuterController { return (req.query ?? {}) as Record; } + /** + * The app a listing is narrowed to: an uid, `null` for the grants no app + * issued, or undefined for all of them. `NO_APP` is the drill-in for the + * group the summary reports with a null `appUid`. + * + * Malformed input is refused rather than read as "unscoped": a duplicated + * query param arrives as an array and an empty string names nothing, and a + * caller who believes they filtered must not silently receive everything. + */ + #appUidFilter(query: Record): string | null | undefined { + const value = query.appUid; + if (value === undefined) return undefined; + if (typeof value !== 'string' || value === '') { + throw new HttpError(400, '`appUid` must be a single app uid', { + legacyCode: 'bad_request', + }); + } + return value === NO_APP ? null : value; + } + #recipients(body: Record): ShareRecipient[] { const raw = body.recipients ?? body.recipient; const list = Array.isArray(raw) ? raw : [raw]; diff --git a/src/backend/services/permission/PermissionService.ts b/src/backend/services/permission/PermissionService.ts index 12023b2e2..e72ab3c59 100644 --- a/src/backend/services/permission/PermissionService.ts +++ b/src/backend/services/permission/PermissionService.ts @@ -869,6 +869,7 @@ export class PermissionService extends PuterService { permission, action: 'grant', reason: meta.reason ?? 'granted via PermissionService', + extra: this.#auditActorContext(actor), }) .catch((err) => { console.warn( @@ -963,6 +964,7 @@ export class PermissionService extends PuterService { permission, action: 'revoke', reason: meta.reason ?? 'revoked via PermissionService', + extra: this.#auditActorContext(actor), }) .catch((err) => { console.warn( @@ -978,6 +980,27 @@ export class PermissionService extends PuterService { return revoked; } + /** + * What an audit row records about who was acting: which app asked, which is + * the whole question for anything minted under a standing consent. + * + * A user-user grant belongs to the user, so callers deliberately hand this + * layer an app-less actor (`userRelatedActor`) — the app survives only on + * the request actor, and is taken from there when it is the same user + * acting. A different user in scope is a background pass on someone else's + * behalf, whose app says nothing about this grant. + */ + #auditActorContext(actor: Actor): Record | null { + const requestActor = Context.get('actor') as Actor | undefined; + const acting = + actor.effectiveApp ?? + actor.app ?? + (requestActor?.user?.id === actor.user?.id + ? (requestActor?.effectiveApp ?? requestActor?.app) + : null); + return acting?.uid ? { appUid: acting.uid } : null; + } + /** * Rewrite a permission on its way into (or out of) a user-app row. * diff --git a/src/backend/services/share/ShareService.test.ts b/src/backend/services/share/ShareService.test.ts index baf1fdd77..bcb08a128 100644 --- a/src/backend/services/share/ShareService.test.ts +++ b/src/backend/services/share/ShareService.test.ts @@ -19,7 +19,7 @@ import { v4 as uuidv4 } from 'uuid'; import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; -import type { Actor } from '../../core/actor.js'; +import { makeActor, type Actor } from '../../core/actor.js'; import { runWithContext } from '../../core/context.js'; import { PuterServer } from '../../server.js'; import { createTestUser, setupTestServer } from '../../testUtil.js'; @@ -117,6 +117,34 @@ describe('ShareService', () => { 'read', ); + const makeApp = async (ownerUserId) => + server.stores.app.create( + { + name: `share-app-${uuidv4()}`, + title: 'Share app', + index_url: `https://share-${uuidv4()}.test/`, + }, + { ownerUserId }, + ); + + // Built the way the request path builds it, so `effectiveApp` is derived + // rather than left unresolved. + const asApp = (owner, app) => + makeActor({ + user: owner.user, + app: { uid: app.uid, id: app.id }, + }); + + /** Hand an app the reach it needs to share one of its user's files. */ + const grantAppReach = (owner, app, entry, mode = 'read') => + runWithContext({ actor: owner.actor }, () => + server.services.permission.grantUserAppPermission( + owner.actor, + app.uid, + `fs:${entry.uuid}:${mode}`, + ), + ); + const share = (actor: Actor, input: Record) => runWithContext({ actor }, () => server.services.share.share(actor, input as never), @@ -1002,6 +1030,595 @@ describe('ShareService', () => { }); }); + describe('the outbound listing scoped by app', () => { + const listSharedByMe = ( + actor: Actor, + opts?: { + limit?: number; + cursor?: string; + includeTotal?: boolean; + appUid?: string | null; + }, + ) => + runWithContext({ actor }, () => + server.services.share.listSharedByMe(actor, opts), + ); + + const listApps = ( + actor: Actor, + opts?: { limit?: number; cursor?: string; includeTotal?: boolean }, + ) => + runWithContext({ actor }, () => + server.services.share.listSharedByMeApps(actor, opts), + ); + + const revokeByUid = (actor: Actor, uid: string) => + runWithContext({ actor }, () => + server.services.share.revokeSharedByMe(actor, uid), + ); + + /** An owner, a recipient, and one file shared through each app. */ + const shareThroughApps = async (appCount: number) => { + const owner = await makeUser(); + const recipient = await makeUser(); + const apps = []; + const files = []; + for (let i = 0; i < appCount; i++) { + const app = await makeApp(owner.user.id); + const file = await makeFile(owner.user); + await grantAppReach(owner, app, file); + await share(asApp(owner, app), { + uid: file.uuid, + recipient: { username: recipient.user.username }, + mode: 'read', + }); + apps.push(app); + files.push(file); + } + return { owner, recipient, apps, files }; + }; + + it('shows an app its own grants and nothing another app issued', async () => { + const { owner, apps, files } = await shareThroughApps(2); + + for (const [index, app] of apps.entries()) { + const listed = await listSharedByMe(asApp(owner, app), { + includeTotal: true, + }); + expect(listed.items.map((i) => i.entryUid)).toEqual([ + files[index].uuid, + ]); + expect(listed.items[0].issuedByApp).toBe(app.uid); + expect(listed.total).toBe(1); + } + }); + + it('gives an app nothing when it asks about another app', async () => { + const { owner, apps } = await shareThroughApps(2); + + const listed = await listSharedByMe(asApp(owner, apps[0]), { + appUid: apps[1].uid, + includeTotal: true, + }); + expect(listed.items).toEqual([]); + expect(listed.total).toBe(0); + }); + + it('lets a session filter to one app, or to what it shared itself', async () => { + const { owner, recipient, apps, files } = + await shareThroughApps(2); + const byHand = await makeFile(owner.user); + await share(owner.actor, { + uid: byHand.uuid, + recipient: { username: recipient.user.username }, + mode: 'read', + }); + + const across = await listSharedByMe(owner.actor); + expect(across.items.map((i) => i.entryUid).sort()).toEqual( + [...files.map((f) => f.uuid), byHand.uuid].sort(), + ); + + const scoped = await listSharedByMe(owner.actor, { + appUid: apps[0].uid, + includeTotal: true, + }); + expect(scoped.items.map((i) => i.entryUid)).toEqual([ + files[0].uuid, + ]); + expect(scoped.total).toBe(1); + + const manual = await listSharedByMe(owner.actor, { appUid: null }); + expect(manual.items.map((i) => i.entryUid)).toEqual([byHand.uuid]); + }); + + it('groups the listing by app, naming each one', async () => { + const { owner, recipient, apps } = await shareThroughApps(1); + const byHand = await makeFile(owner.user); + await share(owner.actor, { + uid: byHand.uuid, + recipient: { username: recipient.user.username }, + mode: 'read', + }); + + const grouped = await listApps(owner.actor, { includeTotal: true }); + expect(grouped.total).toBe(2); + const byApp = new Map(grouped.items.map((i) => [i.appUid, i])); + expect(byApp.get(null)?.count).toBe(1); + expect(byApp.get(apps[0].uid)).toMatchObject({ + name: apps[0].name, + title: apps[0].title, + count: 1, + }); + }); + + it('keeps the grouped view to user sessions', async () => { + const { owner, apps } = await shareThroughApps(1); + await expect( + listApps(asApp(owner, apps[0])), + ).rejects.toMatchObject({ statusCode: 403 }); + }); + + it('still shows and revokes what a removed app left behind', async () => { + const { owner, recipient, apps, files } = + await shareThroughApps(1); + await server.stores.app.delete(apps[0].id); + + const grouped = await listApps(owner.actor); + expect( + grouped.items.find((i) => i.appUid === apps[0].uid), + ).toMatchObject({ name: null, title: null, count: 1 }); + + const listed = await listSharedByMe(owner.actor, { + appUid: apps[0].uid, + }); + expect(listed.items.map((i) => i.entryUid)).toEqual([ + files[0].uuid, + ]); + + expect(await canRead(recipient.actor, files[0].path)).toBe(true); + await revokeByUid(owner.actor, listed.items[0].uid); + expect(await canRead(recipient.actor, files[0].path)).toBe(false); + expect((await listSharedByMe(owner.actor)).items).toEqual([]); + }); + + it('settles the grant when a listed share is revoked', async () => { + const owner = await makeUser(); + const recipient = await makeUser(); + const file = await makeFile(owner.user); + await share(owner.actor, { + uid: file.uuid, + recipient: { username: recipient.user.username }, + mode: 'read', + }); + + const listed = await listSharedByMe(owner.actor); + expect(await canRead(recipient.actor, file.path)).toBe(true); + + expect(await revokeByUid(owner.actor, listed.items[0].uid)).toEqual( + { revoked: 1 }, + ); + expect(await canRead(recipient.actor, file.path)).toBe(false); + expect((await listSharedByMe(owner.actor)).items).toEqual([]); + }); + + it('takes back an unclaimed invite by its uid', async () => { + const owner = await makeUser(); + const file = await makeFile(owner.user); + const email = `invitee-${uuidv4().slice(0, 8)}@test.local`; + await share(owner.actor, { + uid: file.uuid, + recipient: { email }, + mode: 'read', + }); + + const listed = await listSharedByMe(owner.actor); + expect(listed.items[0].pending).toBe(true); + await revokeByUid(owner.actor, listed.items[0].uid); + expect((await listSharedByMe(owner.actor)).items).toEqual([]); + }); + + it('revokes only the named row when two issuers reach the same pair', async () => { + const owner = await makeUser(); + const delegate = await makeUser(); + const recipient = await makeUser(); + const app = await makeApp(owner.user.id); + const file = await makeFile(owner.user); + await grantAppReach(owner, app, file); + + await share(owner.actor, { + uid: file.uuid, + recipient: { email: delegate.email }, + mode: 'manage', + }); + await share(asApp(owner, app), { + uid: file.uuid, + recipient: { username: recipient.user.username }, + mode: 'read', + }); + await share(delegate.actor, { + uid: file.uuid, + recipient: { username: recipient.user.username }, + mode: 'read', + }); + + // The app addresses its own row; the delegate's grant on the same + // (file, recipient) pair is not its to take. + const listed = await listSharedByMe(asApp(owner, app)); + expect(listed.items).toHaveLength(1); + await revokeByUid(asApp(owner, app), listed.items[0].uid); + + expect(await canRead(recipient.actor, file.path)).toBe(true); + const remaining = await server.services.share.listSharesOf( + owner.actor, + { uid: file.uuid }, + ); + expect( + remaining.some( + (row) => + row.holder.username === recipient.user.username && + row.issuer.username === delegate.user.username, + ), + ).toBe(true); + }); + + it('takes back an invite whose address registered but never claimed', async () => { + const owner = await makeUser(); + const file = await makeFile(owner.user); + const email = `late-${uuidv4().slice(0, 8)}@test.local`; + await share(owner.actor, { + uid: file.uuid, + recipient: { email }, + mode: 'read', + }); + + // The address's owner signs up and confirms, but the claim never + // runs — the row still has no holder, so recipient-addressed + // revocation can't find it. The uid-addressed one must. + const late = await makeUser(); + await server.stores.user.update(late.user.id, { + email, + clean_email: email, + }); + + const listed = await listSharedByMe(owner.actor); + expect(listed.items[0].pending).toBe(true); + expect( + await revokeByUid(owner.actor, listed.items[0].uid), + ).toEqual({ revoked: 1 }); + expect(await server.stores.share.listPendingByEmail(email)).toEqual( + [], + ); + expect((await listSharedByMe(owner.actor)).items).toEqual([]); + }); + + // One row records one issuance, so attribution follows the most + // recent one — in both directions. + it('re-attributes a grant to whoever issued it last', async () => { + const owner = await makeUser(); + const recipient = await makeUser(); + const app = await makeApp(owner.user.id); + const file = await makeFile(owner.user); + await grantAppReach(owner, app, file); + + await share(asApp(owner, app), { + uid: file.uuid, + recipient: { username: recipient.user.username }, + mode: 'read', + }); + const viaApp = await listSharedByMe(asApp(owner, app)); + expect(viaApp.items).toHaveLength(1); + const uid = viaApp.items[0].uid; + + // Re-shared by hand: the grant is now the user's own. The app's + // scoped view drops it, and its uid-addressed delete no longer + // names a row it issued. + await share(owner.actor, { + uid: file.uuid, + recipient: { username: recipient.user.username }, + mode: 'write', + }); + expect((await listSharedByMe(asApp(owner, app))).items).toEqual( + [], + ); + const manual = await listSharedByMe(owner.actor, { appUid: null }); + expect(manual.items.map((i) => i.uid)).toEqual([uid]); + await expect(revokeByUid(asApp(owner, app), uid)).rejects.toMatchObject( + { statusCode: 404 }, + ); + + // And back: re-shared through the app, the same row is the app's + // again. + await share(asApp(owner, app), { + uid: file.uuid, + recipient: { username: recipient.user.username }, + mode: 'read', + }); + expect( + (await listSharedByMe(asApp(owner, app))).items.map( + (i) => i.uid, + ), + ).toEqual([uid]); + }); + + // Every uid the caller may not act on answers alike, or the endpoint + // becomes a way to ask whether one exists. + it('answers 404 for an unknown uid and for another account\'s', async () => { + const owner = await makeUser(); + const stranger = await makeUser(); + const recipient = await makeUser(); + const file = await makeFile(owner.user); + await share(owner.actor, { + uid: file.uuid, + recipient: { username: recipient.user.username }, + mode: 'read', + }); + const listed = await listSharedByMe(owner.actor); + + await expect( + revokeByUid(owner.actor, uuidv4()), + ).rejects.toMatchObject({ statusCode: 404 }); + await expect( + revokeByUid(stranger.actor, listed.items[0].uid), + ).rejects.toMatchObject({ statusCode: 404 }); + expect(await canRead(recipient.actor, file.path)).toBe(true); + }); + + it('answers 404 when an app names another app\'s share', async () => { + const { owner, recipient, apps, files } = + await shareThroughApps(2); + const listed = await listSharedByMe(asApp(owner, apps[1])); + + await expect( + revokeByUid(asApp(owner, apps[0]), listed.items[0].uid), + ).rejects.toMatchObject({ statusCode: 404 }); + expect(await canRead(recipient.actor, files[1].path)).toBe(true); + + // Its own, on the other hand, it may take back. + await revokeByUid(asApp(owner, apps[1]), listed.items[0].uid); + expect(await canRead(recipient.actor, files[1].path)).toBe(false); + }); + + it('lets the owner revoke a delegate-issued share, leaving the delegate itself alone', async () => { + const owner = await makeUser(); + const delegate = await makeUser(); + const third = await makeUser(); + const file = await makeFile(owner.user); + + await share(owner.actor, { + uid: file.uuid, + recipient: { username: delegate.user.username }, + mode: 'manage', + }); + await share(delegate.actor, { + uid: file.uuid, + recipient: { username: third.user.username }, + mode: 'read', + }); + expect(await canRead(third.actor, file.path)).toBe(true); + + // The owner's own listing includes what the delegate issued. + const listed = await listSharedByMe(owner.actor); + const delegateRow = listed.items.find( + (i) => i.holder.username === third.user.username, + ); + expect(delegateRow).toBeDefined(); + + await revokeByUid(owner.actor, delegateRow!.uid); + + // The third party's access is gone; the delegate's own manage + // grant — issued by the owner, not by the delegate — is untouched. + expect(await canRead(third.actor, file.path)).toBe(false); + expect(await canRead(delegate.actor, file.path)).toBe(true); + }); + }); + + describe('the grant audit trail', () => { + const audit = ( + actor: Actor, + target?: Record, + opts?: { limit?: number; cursor?: string; includeTotal?: boolean }, + ) => + runWithContext({ actor }, () => + server.services.share.listGrantAudit( + actor, + target as never, + opts, + ), + ); + + it('names when a grant was made, by whom, and under which app', async () => { + const owner = await makeUser(); + const recipient = await makeUser(); + const app = await makeApp(owner.user.id); + const file = await makeFile(owner.user); + await grantAppReach(owner, app, file); + + await share(asApp(owner, app), { + uid: file.uuid, + recipient: { username: recipient.user.username }, + mode: 'read', + }); + + const trail = await audit( + owner.actor, + { uid: file.uuid }, + { includeTotal: true }, + ); + expect(trail.total).toBe(trail.items.length); + const granted = trail.items.filter((i) => i.action === 'grant'); + expect(granted.length).toBeGreaterThan(0); + for (const row of granted) { + expect(row.entryUid).toBe(file.uuid); + expect(row.issuer.username).toBe(owner.user.username); + expect(row.holder.username).toBe(recipient.user.username); + expect(row.appUid).toBe(app.uid); + expect(row.createdAt).toBeDefined(); + } + expect(granted.map((i) => i.mode)).toContain('read'); + }); + + it('carries no app for a grant the user made themselves', async () => { + const owner = await makeUser(); + const recipient = await makeUser(); + const file = await makeFile(owner.user); + + await share(owner.actor, { + uid: file.uuid, + recipient: { username: recipient.user.username }, + mode: 'read', + }); + + const trail = await audit(owner.actor, { uid: file.uuid }); + expect(trail.items.every((i) => i.appUid === null)).toBe(true); + }); + + // The grant is gone by then; the row is the only record left of it. + it('still reads after the grant is revoked, and says so', async () => { + const owner = await makeUser(); + const recipient = await makeUser(); + const file = await makeFile(owner.user); + + await share(owner.actor, { + uid: file.uuid, + recipient: { username: recipient.user.username }, + mode: 'read', + }); + await unshare(owner.actor, { + uid: file.uuid, + recipient: { username: recipient.user.username }, + }); + expect(await canRead(recipient.actor, file.path)).toBe(false); + + const trail = await audit(owner.actor, { uid: file.uuid }); + const actions = new Set(trail.items.map((i) => i.action)); + expect(actions.has('grant')).toBe(true); + expect(actions.has('revoke')).toBe(true); + expect( + trail.items.every( + (i) => i.holder.username === recipient.user.username, + ), + ).toBe(true); + }); + + it('shows the owner what a delegate granted on their item', async () => { + const owner = await makeUser(); + const delegate = await makeUser(); + const third = await makeUser(); + const file = await makeFile(owner.user); + + await share(owner.actor, { + uid: file.uuid, + recipient: { username: delegate.user.username }, + mode: 'manage', + }); + await share(delegate.actor, { + uid: file.uuid, + recipient: { username: third.user.username }, + mode: 'read', + }); + + const trail = await audit(owner.actor, { uid: file.uuid }); + const issuers = new Set(trail.items.map((i) => i.issuer.username)); + expect(issuers).toEqual( + new Set([owner.user.username, delegate.user.username]), + ); + }); + + it('lists what the caller granted when no item is named', async () => { + const owner = await makeUser(); + const other = await makeUser(); + const recipient = await makeUser(); + const mine = await makeFile(owner.user); + const theirs = await makeFile(other.user); + + await share(owner.actor, { + uid: mine.uuid, + recipient: { username: recipient.user.username }, + mode: 'read', + }); + await share(other.actor, { + uid: theirs.uuid, + recipient: { username: recipient.user.username }, + mode: 'read', + }); + + const trail = await audit(owner.actor); + expect(trail.items.length).toBeGreaterThan(0); + expect( + trail.items.every( + (i) => i.issuer.username === owner.user.username, + ), + ).toBe(true); + expect( + trail.items.some((i) => i.entryUid === theirs.uuid), + ).toBe(false); + }); + + it('refuses the trail of an item the caller cannot manage', async () => { + const owner = await makeUser(); + const stranger = await makeUser(); + const recipient = await makeUser(); + const file = await makeFile(owner.user); + await share(owner.actor, { + uid: file.uuid, + recipient: { username: recipient.user.username }, + mode: 'read', + }); + + await expect( + audit(stranger.actor, { uid: file.uuid }), + ).rejects.toMatchObject({ statusCode: 404 }); + // Holding the share is not authority over what else was granted. + await expect( + audit(recipient.actor, { uid: file.uuid }), + ).rejects.toMatchObject({ statusCode: 403 }); + }); + + it('walks every page through the cursor', async () => { + const owner = await makeUser(); + const recipient = await makeUser(); + const file = await makeFile(owner.user); + + await share(owner.actor, { + uid: file.uuid, + recipient: { username: recipient.user.username }, + mode: 'read', + }); + await unshare(owner.actor, { + uid: file.uuid, + recipient: { username: recipient.user.username }, + }); + + const all = await audit( + owner.actor, + { uid: file.uuid }, + { includeTotal: true }, + ); + expect(all.total).toBe(all.items.length); + + const seen: string[] = []; + let cursor: string | undefined; + for (let page = 0; page < all.items.length + 2; page++) { + const listed = await audit( + owner.actor, + { uid: file.uuid }, + { limit: 1, cursor }, + ); + seen.push( + ...listed.items.map((i) => `${i.action}:${i.permission}`), + ); + cursor = listed.cursor; + if (!cursor) break; + } + + expect(cursor).toBeUndefined(); + expect(seen).toEqual( + all.items.map((i) => `${i.action}:${i.permission}`), + ); + }); + }); + it('takes downstream access with a delegate who leaves', async () => { const owner = await makeUser(); const delegate = await makeUser(); @@ -1702,21 +2319,6 @@ describe('ShareService', () => { }); describe('an app is bounded by what it was given', () => { - const makeApp = async (ownerUserId) => - server.stores.app.create( - { - name: `share-app-${uuidv4()}`, - title: 'Share app', - index_url: `https://share-${uuidv4()}.test/`, - }, - { ownerUserId }, - ); - - const asApp = (owner, app) => ({ - user: owner.user, - app: { uid: app.uid, id: app.id }, - }); - /** A real entry under the app's own AppData for `owner`. */ const makeAppDataFile = async (owner, app) => { const now = Math.floor(Date.now() / 1000); diff --git a/src/backend/services/share/ShareService.ts b/src/backend/services/share/ShareService.ts index e766e0693..fcfcb5148 100644 --- a/src/backend/services/share/ShareService.ts +++ b/src/backend/services/share/ShareService.ts @@ -29,6 +29,7 @@ import { isProviderCanonicalized, } from '../../util/email.js'; import type { FSEntry } from '../../stores/fs/FSEntry'; +import type { UserUserAuditFilter } from '../../stores/permission/PermissionStore'; import type { UserRow } from '../../stores/user/UserStore'; import type { AclMode } from '../acl/ACLService'; import { @@ -87,6 +88,35 @@ interface GrantEvidence { owned: boolean; } +/** + * One entry in the grant audit trail. Written when a grant is made or + * withdrawn, and kept afterwards — the row is what remains once the grant + * itself is gone. + */ +export interface GrantAuditEntry { + /** 'grant' or 'revoke'; null on a row that predates the column. */ + action: string | null; + permission: string; + /** Set when the grant names an fs node; other permissions carry neither. */ + entryUid: string | null; + mode: string | null; + issuer: { username: string | null }; + holder: { username: string | null }; + /** The app that was acting, when one was. */ + appUid: string | null; + createdAt: unknown; +} + +/** One app in the outbound listing's group-by-app view. */ +export interface OutboundAppSummary { + /** Null for the grants the user made themselves, outside any app. */ + appUid: string | null; + /** Null once the app is gone; its grants outlive it. */ + name: string | null; + title: string | null; + count: number; +} + /** One live share, resolved for a response. */ export interface ResolvedShare { uid: string; @@ -187,6 +217,15 @@ export const uuidFromEntryPermission = (permission: string): string | null => { return parts[fsAt + 1] || null; }; +/** The share mode a grant stands for, for the two shapes above. */ +export const modeFromEntryPermission = (permission: string): string | null => { + if (!uuidFromEntryPermission(permission)) return null; + const parts = permission.split(':'); + return parts[0] === MANAGE_PERM_PREFIX + ? MANAGE_PERM_PREFIX + : (parts[2] ?? null); +}; + /** * How many entries' grants one retire query covers. Each entry contributes two * indexed range scans (`fs:` and `manage:fs:`), so this bounds the @@ -874,7 +913,7 @@ export class ShareService extends PuterService { fsentryId: entry.id, mode, recipientEmail: holder.email ?? null, - issuerAppUid: actor.app?.uid ?? null, + issuerAppUid: this.#actingAppUid(actor), }); return { ...this.#resolve(row, entry, actor, holder), @@ -1179,6 +1218,21 @@ export class ShareService extends PuterService { } if (issuers.length === 0) issuers = [issuerId]; + return this.#withdrawGrants(actor, entry, holder, issuers); + } + + /** + * Clear `issuers`' grants to `holder` on this node, and everything the + * holder re-shared in turn. The caller has already decided which issuers + * are in scope — everyone's for an owner, one row's for the uid-addressed + * revoke — and passed the gates for it. + */ + async #withdrawGrants( + actor: Actor, + entry: FSEntry, + holder: { id: number; username: string }, + issuers: number[], + ): Promise<{ revoked: number }> { // Whatever the holder re-shared goes with them, and this has to run // first: when the holder is the actor, clearing their own grants would // strip the very `manage` the cascade needs to do it. @@ -1190,14 +1244,14 @@ export class ShareService extends PuterService { writer, entry, holder.username, - issuer as number, + issuer, ); if (didRevoke) revoked++; if (authorized) { await this.stores.share.deleteActive({ holderUserId: holder.id, fsentryId: entry.id, - issuerUserId: issuer as number, + issuerUserId: issuer, }); } } @@ -1469,19 +1523,36 @@ export class ShareService extends PuterService { * * Trashed items are kept, unlike the inbound listing: the grant on one is * still standing, and this is where someone comes to find that out. + * + * An app credential sees only what its own app issued, whatever `appUid` + * asks for; a session may filter by app, and `null` asks for the grants no + * app issued. */ async listSharedByMe( actor: Actor, - opts: { limit?: number; cursor?: string; includeTotal?: boolean } = {}, + opts: { + limit?: number; + cursor?: string; + includeTotal?: boolean; + appUid?: string | null; + } = {}, ): Promise<{ items: ResolvedShare[]; cursor?: string; total?: number; }> { const userId = this.#requireUserId(actor); + const scope = this.#outboundAppScope(actor, opts.appUid); + if (scope.empty) { + return { + items: [], + ...(opts.includeTotal ? { total: 0 } : {}), + }; + } const page = await this.stores.share.listOutbound(userId, { limit: opts.limit, cursor: opts.cursor, + appUid: scope.appUid, }); const rows: OutboundShareRow[] = page.items; @@ -1540,11 +1611,234 @@ export class ShareService extends PuterService { items, ...(page.cursor ? { cursor: page.cursor } : {}), ...(opts.includeTotal - ? { total: await this.stores.share.countOutbound(userId) } + ? { + total: await this.stores.share.countOutbound(userId, { + appUid: scope.appUid, + }), + } : {}), }; } + /** + * Which apps hold shares the caller made — the way into the per-app listing + * for someone who doesn't know which apps to ask about. The group with no + * `appUid` is what the caller shared themselves. + * + * Counts come from the index, so they are what `includeTotal` reports on + * the listing rather than what survives its per-grant re-checks. + */ + async listSharedByMeApps( + actor: Actor, + opts: { limit?: number; cursor?: string; includeTotal?: boolean } = {}, + ): Promise<{ + items: OutboundAppSummary[]; + cursor?: string; + total?: number; + }> { + const userId = this.#requireUserId(actor); + if (this.#actingAppUid(actor)) { + throw new HttpError( + 403, + 'This view is only available to user sessions', + { legacyCode: 'forbidden' }, + ); + } + + const page = await this.stores.share.listOutboundApps(userId, { + limit: opts.limit, + cursor: opts.cursor, + }); + const apps = await this.stores.app.getByUids( + page.items + .map((row: { appUid: string | null }) => row.appUid) + .filter((uid: string | null): uid is string => Boolean(uid)), + ); + + return { + items: page.items.map( + (row: { appUid: string | null; count: number }) => { + // An app that has since been removed leaves its grants behind, + // so the group stands with nothing to name it. + const app = row.appUid ? apps.get(row.appUid) : null; + return { + appUid: row.appUid, + name: app?.name ?? null, + title: app?.title ?? null, + count: row.count, + }; + }, + ), + ...(page.cursor ? { cursor: page.cursor } : {}), + ...(opts.includeTotal + ? { total: await this.stores.share.countOutboundApps(userId) } + : {}), + }; + } + + /** + * Withdraw one share the caller listed, named by its own uid. + * + * An uid the caller may not see answers 404 alike — one that names nothing, + * another user's row, an app's view of another app's row — so the endpoint + * can't be used to find out which. Past that gate the revoke's own rules + * answer: a caller whose authority over the node has lapsed gets the ACL's + * error, and a grant already withdrawn elsewhere reports `revoked: 0`. + * + * Scoped to the named row: only its issuer's grant is withdrawn, and only + * this one invite is cancelled — an owner (or an app) addressing one row + * must not take another issuer's grant on the same pair with it. The + * item-addressed `unshare` is the broad form. + */ + async revokeSharedByMe( + actor: Actor, + shareUid: string, + ): Promise<{ revoked: number }> { + const userId = this.#requireUserId(actor); + const notFound = () => + new HttpError(404, 'Subject does not exist', { + legacyCode: 'subject_does_not_exist', + }); + + const row = await this.stores.share.getByUid(shareUid); + if (!row?.fsentry_id) throw notFound(); + + const actingApp = this.#actingAppUid(actor); + if (actingApp && issuedByApp(row) !== actingApp) throw notFound(); + + const entry = await this.stores.fsEntry.getEntryById( + Number(row.fsentry_id), + ); + if (!entry) throw notFound(); + // The row must be one the caller's own listing would show them. + if ( + Number(row.issuer_user_id) !== userId && + Number(entry.userId) !== userId + ) { + throw notFound(); + } + if (!(await this.#hasOwnReach(actor, entry, 'see'))) throw notFound(); + + // An invite is just its row — including one whose address has since + // been registered but never claimed: no holder, no grant, so + // recipient-addressed revocation would never find it. + if (!row.holder_user_id) { + const removed = await this.stores.share.deleteByUid(row.uid); + return { revoked: removed ? 1 : 0 }; + } + + const holder = await this.stores.user.getById( + Number(row.holder_user_id), + ); + if (!holder?.username) throw notFound(); + + await this.#assertCanManage(actor, entry); + if (holder.id === entry.userId) { + throw new HttpError(400, 'cannot revoke the owner of an item', { + legacyCode: 'cannot_revoke_owner', + }); + } + + return this.#withdrawGrants(actor, entry, holder, [ + Number(row.issuer_user_id), + ]); + } + + /** + * When a grant was made, by which actor, and under which app. + * + * Named with an item, this is everything granted on it, whoever granted it + * — which is what an owner is left with after a revoke, the grant itself + * being gone by then. Named with nothing, it is what the caller granted, + * wherever it landed. Between them they cover the caller's own trail and + * their items', and nothing else: authority over the item is the gate on + * the first, and being the issuer is the whole of the second. + */ + async listGrantAudit( + actor: Actor, + target: ShareTarget = {}, + opts: { limit?: number; cursor?: string; includeTotal?: boolean } = {}, + ): Promise<{ + items: GrantAuditEntry[]; + cursor?: string; + total?: number; + }> { + const userId = this.#requireUserId(actor); + + let filter: UserUserAuditFilter; + if (target.uid || target.path) { + const entry = await this.#resolveEntry(target, actor); + await this.#assertCanManage(actor, entry); + filter = { permissions: entryPermissions(entry.uuid) }; + } else { + filter = { issuerUserId: userId }; + } + + const page = await this.stores.permission.listUserUserAudit(filter, { + limit: opts.limit, + cursor: opts.cursor, + }); + const users = await this.stores.user.getByIds( + page.items.flatMap((row) => + [row.issuer_user_id, row.holder_user_id].filter( + (id): id is number => typeof id === 'number', + ), + ), + ); + const username = (id: number | null) => + id === null ? null : (users.get(id)?.username ?? null); + + return { + items: page.items.map((row) => ({ + action: row.action, + permission: row.permission, + entryUid: uuidFromEntryPermission(row.permission), + mode: modeFromEntryPermission(row.permission), + issuer: { username: username(row.issuer_user_id) }, + holder: { username: username(row.holder_user_id) }, + appUid: + typeof row.extra?.appUid === 'string' + ? row.extra.appUid + : null, + createdAt: row.created_at, + })), + ...(page.cursor ? { cursor: page.cursor } : {}), + ...(opts.includeTotal + ? { + total: await this.stores.permission.countUserUserAudit( + filter, + ), + } + : {}), + }; + } + + /** + * Which app's grants this actor may see. An app credential is bound to its + * own app whatever it asks for; a session may filter freely. + */ + #outboundAppScope( + actor: Actor, + requested: string | null | undefined, + ): { appUid?: string | null; empty?: boolean } { + const acting = this.#actingAppUid(actor); + if (!acting) return { appUid: requested }; + if (requested !== undefined && requested !== acting) { + return { empty: true }; + } + return { appUid: acting }; + } + + /** + * The app this credential acts as, or null for a plain user session. + * `effectiveApp` is the one derived field for this question — see + * `makeActor`; re-deriving from `app` here would be a second gate to + * drift. + */ + #actingAppUid(actor: Actor): string | null { + return actor.effectiveApp?.uid ?? null; + } + /** * Who can reach one node. Includes shares a `manage` delegate issued, which * the permission tables alone can't show the owner. @@ -2015,7 +2309,7 @@ export class ShareService extends PuterService { displayEmail: email, fsentryId: entry.id, mode, - issuerAppUid: actor.app?.uid ?? null, + issuerAppUid: this.#actingAppUid(actor), }); return { ...this.#resolve(row, entry, actor, { username: null }), diff --git a/src/backend/stores/permission/PermissionStore.test.ts b/src/backend/stores/permission/PermissionStore.test.ts index bd514dd07..b13f32ed0 100644 --- a/src/backend/stores/permission/PermissionStore.test.ts +++ b/src/backend/stores/permission/PermissionStore.test.ts @@ -1010,4 +1010,92 @@ describe('PermissionStore', () => { ).toBe(false); }); }); + + describe('the user-to-user audit trail', () => { + const record = ( + issuerUserId: number, + holderUserId: number, + permission: string, + action: 'grant' | 'revoke', + extra: Record | null = null, + ) => + store.auditUserUserPerm({ + holder_user_id: holderUserId, + issuer_user_id: issuerUserId, + permission, + action, + reason: 'test', + extra, + }); + + it('reads rows back newest first, with what was recorded on them', async () => { + const issuer = await makeUser(); + const holder = await makeUser(); + const permission = `fs:${uuidv4()}:read`; + const appUid = uuidv4(); + + await record(issuer.id, holder.id, permission, 'grant', { appUid }); + await record(issuer.id, holder.id, permission, 'revoke'); + + const page = await store.listUserUserAudit({ permissions: [permission] }); + expect(page.items.map((r) => r.action)).toEqual(['revoke', 'grant']); + expect(page.items[1].extra).toEqual({ appUid }); + expect(page.items[1].issuer_user_id).toBe(issuer.id); + expect(page.items[1].holder_user_id).toBe(holder.id); + expect( + await store.countUserUserAudit({ permissions: [permission] }), + ).toBe(2); + }); + + it('pages backwards through the cursor', async () => { + const issuer = await makeUser(); + const holder = await makeUser(); + const permission = `fs:${uuidv4()}:read`; + for (let i = 0; i < 3; i++) { + await record(issuer.id, holder.id, permission, 'grant'); + } + + const seen: number[] = []; + let cursor: string | undefined; + for (let guard = 0; guard < 10; guard++) { + const page = await store.listUserUserAudit( + { permissions: [permission] }, + { limit: 1, cursor }, + ); + seen.push(...page.items.map((r) => r.id)); + cursor = page.cursor; + if (!cursor) break; + } + + expect(seen).toHaveLength(3); + expect([...seen].sort((a, b) => b - a)).toEqual(seen); + expect(cursor).toBeUndefined(); + }); + + it('narrows to one issuer, and refuses to read the table unfiltered', async () => { + const issuer = await makeUser(); + const other = await makeUser(); + const holder = await makeUser(); + const permission = `fs:${uuidv4()}:read`; + + await record(issuer.id, holder.id, permission, 'grant'); + await record(other.id, holder.id, permission, 'grant'); + + const page = await store.listUserUserAudit({ + issuerUserId: issuer.id, + }); + expect(page.items.every((r) => r.issuer_user_id === issuer.id)).toBe( + true, + ); + await expect(store.listUserUserAudit({})).rejects.toThrow( + /requires a filter/u, + ); + }); + + it('matches nothing for an empty permission list', async () => { + const page = await store.listUserUserAudit({ permissions: [] }); + expect(page.items).toEqual([]); + expect(await store.countUserUserAudit({ permissions: [] })).toBe(0); + }); + }); }); diff --git a/src/backend/stores/permission/PermissionStore.ts b/src/backend/stores/permission/PermissionStore.ts index 4e5df77fc..dc6b5cb55 100644 --- a/src/backend/stores/permission/PermissionStore.ts +++ b/src/backend/stores/permission/PermissionStore.ts @@ -28,6 +28,7 @@ import { PERMISSION_SCAN_CACHE_TTL_SECONDS, } from '../../services/permission/consts'; import { kv } from '../../util/kvSingleton'; +import { decodeCursor, encodeCursor } from '../../util/pagination'; import type { UserRow } from '../user/UserStore'; // Short TTLs: FK CASCADE on user/app delete + PermissionService rewriters @@ -36,6 +37,9 @@ const U2A_CACHE_TTL_SECONDS = 5 * 60; const U2U_CACHE_TTL_SECONDS = 5 * 60; const TOKEN_CACHE_TTL_SECONDS = 10 * 60; +const AUDIT_PAGE_SIZE = 50; +const MAX_AUDIT_PAGE_SIZE = 200; + // Re-export for back-compat — PermissionService et al. import `UserRow` from here. // The canonical definition lives in `UserStore`, which owns the user table. export type { UserRow }; @@ -82,9 +86,30 @@ export interface AccessTokenPermRow { export interface AuditEntry { action: 'grant' | 'revoke'; reason: string; + /** Recorded on the row, and read back by whoever reads the trail. */ + extra?: Record | null; [k: string]: unknown; } +/** One user-to-user audit row, as read back. */ +export interface UserUserAuditRow { + id: number; + /** Null once that account is deleted; the FK columns are ON DELETE SET NULL. */ + issuer_user_id: number | null; + holder_user_id: number | null; + permission: string; + action: string | null; + extra: Record | null; + created_at: unknown; +} + +/** What a read of the user-to-user audit trail may be narrowed to. */ +export interface UserUserAuditFilter { + issuerUserId?: number; + holderUserId?: number; + permissions?: string[]; +} + /** One entry in the flat KV view, as addressed by a delete. */ export interface FlatPermRef { holderUserId: number; @@ -502,19 +527,123 @@ export class PermissionStore extends PuterStore { await this.clients.db.write( 'INSERT INTO `audit_user_to_user_permissions` (' + '`holder_user_id`, `holder_user_id_keep`, `issuer_user_id`, `issuer_user_id_keep`, ' + - '`permission`, `action`, `reason`) VALUES (?, ?, ?, ?, ?, ?, ?)', + '`permission`, `extra`, `action`, `reason`) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', [ entry.holder_user_id, entry.holder_user_id, entry.issuer_user_id, entry.issuer_user_id, entry.permission, + entry.extra ? JSON.stringify(entry.extra) : null, entry.action, entry.reason, ], ); } + /** + * The user-to-user audit trail, newest first, keyset-paginated on `id`. + * + * Rows are never deleted with the grant they describe, so this answers when + * something was granted long after it was withdrawn. The caller decides who + * may see which rows and narrows `filter` accordingly — this applies it, it + * does not authorize it. + */ + async listUserUserAudit( + filter: UserUserAuditFilter, + opts: { limit?: number; cursor?: string } = {}, + ): Promise<{ items: UserUserAuditRow[]; cursor?: string }> { + const size = Math.min( + Math.max(1, Math.floor(Number(opts.limit) || AUDIT_PAGE_SIZE)), + MAX_AUDIT_PAGE_SIZE, + ); + const decoded = decodeCursor(opts.cursor, 'audit cursor'); + const beforeId = Number(decoded?.id ?? 0) || null; + const { sql, params } = this.#auditWhere(filter); + + const rows = await this.clients.db.read( + 'SELECT * FROM `audit_user_to_user_permissions` WHERE ' + + sql + + (beforeId === null ? '' : ' AND `id` < ?') + + ' ORDER BY `id` DESC LIMIT ?', + [...params, ...(beforeId === null ? [] : [beforeId]), size + 1], + ); + + const hasMore = rows.length > size; + const items = rows.slice(0, size).map((row) => this.#auditRow(row)); + const last = items[items.length - 1]; + return { + items, + cursor: hasMore && last ? encodeCursor({ id: last.id }) : undefined, + }; + } + + /** How many rows `listUserUserAudit` walks under the same filter. */ + async countUserUserAudit(filter: UserUserAuditFilter): Promise { + const { sql, params } = this.#auditWhere(filter); + const rows = await this.clients.db.read( + 'SELECT COUNT(*) AS `count` FROM `audit_user_to_user_permissions` ' + + `WHERE ${sql}`, + params, + ); + return Number(rows[0]?.count ?? 0); + } + + /** + * The filter as SQL. An empty filter would read the whole table, so it is + * refused rather than quietly returning everyone's trail. + */ + #auditWhere(filter: UserUserAuditFilter): { + sql: string; + params: unknown[]; + } { + const clauses: string[] = []; + const params: unknown[] = []; + if (filter.issuerUserId !== undefined) { + clauses.push('`issuer_user_id` = ?'); + params.push(filter.issuerUserId); + } + if (filter.holderUserId !== undefined) { + clauses.push('`holder_user_id` = ?'); + params.push(filter.holderUserId); + } + if (filter.permissions !== undefined) { + if (filter.permissions.length === 0) { + return { sql: '1 = 0', params: [] }; + } + clauses.push( + `\`permission\` IN (${filter.permissions.map(() => '?').join(', ')})`, + ); + params.push(...filter.permissions); + } + if (clauses.length === 0) { + throw new Error('listUserUserAudit requires a filter'); + } + return { sql: clauses.join(' AND '), params }; + } + + #auditRow(row: Record): UserUserAuditRow { + let extra = row.extra; + if (typeof extra === 'string') { + try { + extra = JSON.parse(extra); + } catch { + extra = null; + } + } + const userId = (value: unknown) => + value === null || value === undefined ? null : Number(value); + return { + id: Number(row.id), + issuer_user_id: userId(row.issuer_user_id), + holder_user_id: userId(row.holder_user_id), + permission: String(row.permission), + action: (row.action as string | null) ?? null, + extra: (extra as Record | null) ?? null, + created_at: row.created_at, + }; + } + // -- SQL: user-to-app permissions -------------------------------- async readUserAppPerms( diff --git a/src/backend/stores/share/ShareStore.js b/src/backend/stores/share/ShareStore.js index 9fb478cf7..5ff0ae4e5 100644 --- a/src/backend/stores/share/ShareStore.js +++ b/src/backend/stores/share/ShareStore.js @@ -114,25 +114,34 @@ export class ShareStore extends PuterStore { * something the user sent); the legacy invite rows that name no node are * not. * + * `appUid` narrows to one app's grants; `null` asks for the ones no app + * issued, and omitting it asks for every app. + * * @param {number} userId - * @param {{ limit?: number; cursor?: string }} [opts] + * @param {{ limit?: number; cursor?: string; appUid?: string | null }} [opts] */ - async listOutbound(userId, { limit, cursor } = {}) { + async listOutbound(userId, { limit, cursor, appUid } = {}) { const size = this.#pageSize(limit); const afterId = this.#afterId(cursor); + const own = this.#appFilter(appUid, '`data`'); + const joined = this.#appFilter(appUid, '`share`.`data`'); const [issued, delegated] = await Promise.all([ this.clients.db.read( 'SELECT * FROM `share` WHERE `issuer_user_id` = ? AND ' + - '`fsentry_id` IS NOT NULL AND `id` > ? ORDER BY `id` LIMIT ?', - [userId, afterId, size + 1], + '`fsentry_id` IS NOT NULL AND `id` > ?' + + own.sql + + ' ORDER BY `id` LIMIT ?', + [userId, afterId, ...own.params, size + 1], ), this.clients.db.read( 'SELECT `share`.* FROM `share` JOIN `fsentries` ON ' + '`fsentries`.`id` = `share`.`fsentry_id` WHERE ' + '`fsentries`.`user_id` = ? AND `share`.`issuer_user_id` <> ? ' + - 'AND `share`.`id` > ? ORDER BY `share`.`id` LIMIT ?', - [userId, userId, afterId, size + 1], + 'AND `share`.`id` > ?' + + joined.sql + + ' ORDER BY `share`.`id` LIMIT ?', + [userId, userId, afterId, ...joined.params, size + 1], ), ]); @@ -151,24 +160,99 @@ export class ShareStore extends PuterStore { }; } - /** How many rows `listOutbound` walks, both halves counted. */ - async countOutbound(userId) { + /** + * How many rows `listOutbound` walks, both halves counted, under the same + * `appUid` scope. + * + * @param {number} userId + * @param {{ appUid?: string | null }} [opts] + */ + async countOutbound(userId, { appUid } = {}) { + const own = this.#appFilter(appUid, '`data`'); + const joined = this.#appFilter(appUid, '`share`.`data`'); const [issued, delegated] = await Promise.all([ this.clients.db.read( 'SELECT COUNT(*) AS `count` FROM `share` WHERE ' + - '`issuer_user_id` = ? AND `fsentry_id` IS NOT NULL', - [userId], + '`issuer_user_id` = ? AND `fsentry_id` IS NOT NULL' + + own.sql, + [userId, ...own.params], ), this.clients.db.read( 'SELECT COUNT(*) AS `count` FROM `share` JOIN `fsentries` ON ' + '`fsentries`.`id` = `share`.`fsentry_id` WHERE ' + - '`fsentries`.`user_id` = ? AND `share`.`issuer_user_id` <> ?', - [userId, userId], + '`fsentries`.`user_id` = ? AND `share`.`issuer_user_id` <> ?' + + joined.sql, + [userId, userId, ...joined.params], ), ]); return Number(issued[0]?.count ?? 0) + Number(delegated[0]?.count ?? 0); } + /** + * The same outbound set folded onto the app that issued each row, keyset- + * paginated on the app uid. Rows no app issued group under the empty + * string, which sorts first. + * + * Counts are index rows, as `countOutbound` is: a grant withdrawn outside + * the share path is still counted until its row goes. + * + * @param {number} userId + * @param {{ limit?: number; cursor?: string }} [opts] + */ + async listOutboundApps(userId, { limit, cursor } = {}) { + const size = this.#pageSize(limit); + const decoded = decodeCursor(cursor, 'share app cursor'); + // A cursor that decodes but names no appUid — another listing's, say — + // is refused rather than read as page one. The no-app group is the + // empty string, so a first page cannot seek past it. + if (decoded !== undefined && typeof decoded.appUid !== 'string') { + throw new HttpError(400, 'invalid share app cursor', { + legacyCode: 'bad_request', + }); + } + const after = decoded === undefined ? null : String(decoded.appUid); + + const rows = await this.clients.db.read( + 'SELECT `app_uid`, COUNT(*) AS `count` FROM (' + + this.#outboundAppsSql() + + ') AS `outbound`' + + (after === null ? '' : ' WHERE `app_uid` > ?') + + ' GROUP BY `app_uid` ORDER BY `app_uid` LIMIT ?', + [ + userId, + userId, + userId, + ...(after === null ? [] : [after]), + size + 1, + ], + ); + + const hasMore = rows.length > size; + const items = rows.slice(0, size).map((row) => ({ + appUid: row.app_uid === '' ? null : String(row.app_uid), + count: Number(row.count), + })); + const last = items[items.length - 1]; + return { + items, + cursor: + hasMore && last + ? encodeCursor({ appUid: last.appUid ?? '' }) + : undefined, + }; + } + + /** How many apps `listOutboundApps` walks. */ + async countOutboundApps(userId) { + const rows = await this.clients.db.read( + 'SELECT COUNT(*) AS `count` FROM (SELECT DISTINCT `app_uid` FROM (' + + this.#outboundAppsSql() + + ') AS `outbound`) AS `apps`', + [userId, userId, userId], + ); + return Number(rows[0]?.count ?? 0); + } + /** Everyone with an active share on one node, whoever issued it. */ async listByFsentry(fsentryId) { return this.listByFsentries([fsentryId]); @@ -655,6 +739,63 @@ export class ShareStore extends PuterStore { return id; } + /** + * The app recorded on a row, as a SQL expression over its `data`. Two + * spellings in the wild — pending rows were written with `issuerAppUid` + * before the keys were unified on `issuedByApp`, and claiming carries + * `data` forward — so both are read, matching the service-side reader. + * + * The column is typed JSON on mysql and postgres, but sqlite stores + * whatever it was handed and legacy rows carry plain strings — extracting + * from one of those aborts the whole query, so there the read is guarded. + */ + #issuedByAppExpr(dataColumn) { + const extracts = ['issuedByApp', 'issuerAppUid'].map((key) => + this.clients.db.jsonTextExtract(dataColumn, [key]), + ); + const coalesced = `COALESCE(${extracts.join(', ')})`; + return this.clients.db.case({ + sqlite: `CASE WHEN json_valid(${dataColumn}) THEN ${coalesced} END`, + otherwise: coalesced, + }); + } + + /** + * `WHERE` fragment scoping a listing to one app. `undefined` means every + * app, `null` the rows no app issued. + * + * @param {string | null | undefined} appUid + * @param {string} dataColumn + */ + #appFilter(appUid, dataColumn) { + if (appUid === undefined) return { sql: '', params: [] }; + const expr = this.#issuedByAppExpr(dataColumn); + if (appUid === null) return { sql: ` AND ${expr} IS NULL`, params: [] }; + return { sql: ` AND ${expr} = ?`, params: [appUid] }; + } + + /** + * Both outbound halves projected onto their issuing app. Takes the same + * three bound user ids as `listOutbound`, in that order. + */ + #outboundAppsSql() { + const own = this.clients.db.nullCoalesce( + this.#issuedByAppExpr('`data`'), + "''", + ); + const joined = this.clients.db.nullCoalesce( + this.#issuedByAppExpr('`share`.`data`'), + "''", + ); + return ( + `SELECT ${own} AS \`app_uid\` FROM \`share\` WHERE ` + + '`issuer_user_id` = ? AND `fsentry_id` IS NOT NULL UNION ALL ' + + `SELECT ${joined} AS \`app_uid\` FROM \`share\` JOIN \`fsentries\` ` + + 'ON `fsentries`.`id` = `share`.`fsentry_id` WHERE ' + + '`fsentries`.`user_id` = ? AND `share`.`issuer_user_id` <> ?' + ); + } + #normalizeRow(row) { if (!row) return null; if (typeof row.data === 'string') { diff --git a/src/backend/stores/share/ShareStore.test.js b/src/backend/stores/share/ShareStore.test.js index bb0e18798..3d5539852 100644 --- a/src/backend/stores/share/ShareStore.test.js +++ b/src/backend/stores/share/ShareStore.test.js @@ -589,6 +589,168 @@ describe('ShareStore', () => { expect(cursor).toBeUndefined(); }); + it('narrows the outbound listing to one app, or to no app at all', async () => { + const owner = await makeUser(); + const recipient = await makeUser(); + const first = uuidv4(); + const second = uuidv4(); + + const rows = {}; + for (const [label, appUid] of [ + ['first', first], + ['second', second], + ['manual', null], + ]) { + const entry = await makeEntry(owner); + rows[label] = await store.upsertActive({ + issuerUserId: owner.id, + holderUserId: recipient.id, + fsentryId: entry.id, + mode: 'read', + issuerAppUid: appUid, + }); + } + + const uids = async (appUid) => + (await store.listOutbound(owner.id, { appUid })).items + .map((r) => r.uid) + .sort(); + + expect(await uids(first)).toEqual([rows.first.uid]); + expect(await store.countOutbound(owner.id, { appUid: first })).toBe( + 1, + ); + expect(await uids(null)).toEqual([rows.manual.uid]); + expect(await store.countOutbound(owner.id, { appUid: null })).toBe( + 1, + ); + expect(await uids(uuidv4())).toEqual([]); + expect(await uids(undefined)).toEqual( + [rows.first.uid, rows.second.uid, rows.manual.uid].sort(), + ); + }); + + it('groups the outbound listing by the app that issued each row', async () => { + const owner = await makeUser(); + const delegate = await makeUser(); + const recipient = await makeUser(); + const appUid = uuidv4(); + + // Two through the app (one of them issued by a delegate on the + // owner's node), one the owner made themselves. + for (const issuerUserId of [owner.id, delegate.id]) { + const entry = await makeEntry(owner); + await store.upsertActive({ + issuerUserId, + holderUserId: recipient.id, + fsentryId: entry.id, + mode: 'read', + issuerAppUid: appUid, + }); + } + const manual = await makeEntry(owner); + await store.upsertActive({ + issuerUserId: owner.id, + holderUserId: recipient.id, + fsentryId: manual.id, + mode: 'read', + }); + + const page = await store.listOutboundApps(owner.id); + expect(page.items).toEqual([ + { appUid: null, count: 1 }, + { appUid, count: 2 }, + ]); + expect(await store.countOutboundApps(owner.id)).toBe(2); + }); + + it('pages the grouped view and stops once the apps run out', async () => { + const owner = await makeUser(); + const recipient = await makeUser(); + const appUids = [uuidv4(), uuidv4(), uuidv4()].sort(); + for (const appUid of appUids) { + const entry = await makeEntry(owner); + await store.upsertActive({ + issuerUserId: owner.id, + holderUserId: recipient.id, + fsentryId: entry.id, + mode: 'read', + issuerAppUid: appUid, + }); + } + + const seen = []; + let cursor; + for (let guard = 0; guard < 10; guard++) { + const page = await store.listOutboundApps(owner.id, { + limit: 1, + cursor, + }); + seen.push(...page.items.map((r) => r.appUid)); + cursor = page.cursor; + if (!cursor) break; + } + + expect(seen).toEqual(appUids); + expect(cursor).toBeUndefined(); + }); + + it('returns the no-app group on the first page and resumes past it', async () => { + const owner = await makeUser(); + const recipient = await makeUser(); + const appUid = uuidv4(); + + const manual = await makeEntry(owner); + await store.upsertActive({ + issuerUserId: owner.id, + holderUserId: recipient.id, + fsentryId: manual.id, + mode: 'read', + }); + const viaApp = await makeEntry(owner); + await store.upsertActive({ + issuerUserId: owner.id, + holderUserId: recipient.id, + fsentryId: viaApp.id, + mode: 'read', + issuerAppUid: appUid, + }); + + // No cursor at all — the empty-string group sorts first, and a + // first page has nothing to seek past. + const first = await store.listOutboundApps(owner.id, { limit: 1 }); + expect(first.items).toEqual([{ appUid: null, count: 1 }]); + expect(first.cursor).toBeDefined(); + + const second = await store.listOutboundApps(owner.id, { + limit: 1, + cursor: first.cursor, + }); + expect(second.items).toEqual([{ appUid, count: 1 }]); + expect(second.cursor).toBeUndefined(); + }); + + it('records an invite app under the key an active share uses', async () => { + const owner = await makeUser(); + const entry = await makeEntry(owner); + const appUid = uuidv4(); + + const { row } = await store.upsertPending({ + issuerUserId: owner.id, + recipientEmail: `invite-${uuidv4()}@test.local`, + fsentryId: entry.id, + mode: 'read', + issuerAppUid: appUid, + }); + + expect(row.data.issuedByApp).toBe(appUid); + expect( + (await store.listOutbound(owner.id, { appUid })).items.map( + (r) => r.uid, + ), + ).toEqual([row.uid]); + }); + it('never returns another holder rows', async () => { const stranger = await makeUser(); const entry = await makeEntry(issuer); @@ -693,6 +855,47 @@ describe('ShareStore', () => { expect(second.row.data.issuedByApp).toBeUndefined(); }); + it('filters and groups a legacy-keyed app row like a current one', async () => { + const entry = await makeEntry(issuer); + const legacyHolder = await makeUser(); + const created = await store.upsertActive({ + issuerUserId: issuer.id, + holderUserId: legacyHolder.id, + fsentryId: entry.id, + mode: 'read', + }); + // A row written before the keys were unified on `issuedByApp`. + await server.clients.db.write( + 'UPDATE `share` SET `data` = ? WHERE `uid` = ?', + [JSON.stringify({ issuerAppUid: 'app-legacy' }), created.uid], + ); + + const scoped = await store.listOutbound(issuer.id, { + appUid: 'app-legacy', + }); + expect(scoped.items.map((r) => r.uid)).toEqual([created.uid]); + expect( + await store.countOutbound(issuer.id, { appUid: 'app-legacy' }), + ).toBe(1); + + const grouped = await store.listOutboundApps(issuer.id, { + limit: 200, + }); + expect( + grouped.items.find((g) => g.appUid === 'app-legacy')?.count, + ).toBe(1); + }); + + it('refuses an apps cursor that decodes but names no appUid', async () => { + await expect( + store.listOutboundApps(issuer.id, { + cursor: Buffer.from(JSON.stringify({ id: 4 })).toString( + 'base64', + ), + }), + ).rejects.toThrow('invalid share app cursor'); + }); + it('drops only one issuer unclaimed invites under a subtree', async () => { const entry = await makeEntry(issuer); const mine = await store.upsertPending({ diff --git a/src/docs/src/FS/listSharedByMe.md b/src/docs/src/FS/listSharedByMe.md index cecc2ebaf..e5e3904f3 100644 --- a/src/docs/src/FS/listSharedByMe.md +++ b/src/docs/src/FS/listSharedByMe.md @@ -27,6 +27,7 @@ An object with the following properties: - `limit` (Number) - Maximum shares per page. - `cursor` (String) - Continuation token from a previous page. - `includeTotal` (Boolean) - Include the total count in the response. Defaults to `false`. +- `appUid` (String) - Narrow the listing to the shares one app issued in your name, or pass `'none'` for the ones you made yourself. An app calling this is bound to its own grants whatever it asks for. ## Return value diff --git a/src/puter-js/index.d.ts b/src/puter-js/index.d.ts index 39be88a7a..48df4dae8 100644 --- a/src/puter-js/index.d.ts +++ b/src/puter-js/index.d.ts @@ -99,6 +99,7 @@ export type { FSItemRead, FSItemWithShares, GetSharesOptions, + ListSharedByMeOptions, ListSharedOptions, MkdirOptions, MoveOptions, diff --git a/src/puter-js/src/modules/FileSystem/operations/listSharedByMe.js b/src/puter-js/src/modules/FileSystem/operations/listSharedByMe.js index fc9547cbb..b722110fb 100644 --- a/src/puter-js/src/modules/FileSystem/operations/listSharedByMe.js +++ b/src/puter-js/src/modules/FileSystem/operations/listSharedByMe.js @@ -1,7 +1,7 @@ import { defineOperation, firstDefined } from './scaffold.js'; import { toShare } from './shareUtil.js'; -/** @typedef {import('../types.js').ListSharedOptions} ListSharedOptions */ +/** @typedef {import('../types.js').ListSharedByMeOptions} ListSharedByMeOptions */ /** @typedef {import('../types.js').SharePage} SharePage */ /** @@ -9,12 +9,15 @@ import { toShare } from './shareUtil.js'; * without naming one. Includes invites nobody has claimed yet (`pending`), * and, for items you own, shares a delegate with `manage` access issued. * + * `appUid` narrows to what one app issued in your name, or `'none'` for what + * you shared yourself. An app is bound to its own grants either way. + * * `cursor` comes back only while more pages remain, so iterate until it is * absent rather than comparing `items.length` to `limit` — a page can be short * once items the caller can no longer see are filtered out. * * @type {{ - * (options?: ListSharedOptions): Promise, + * (options?: ListSharedByMeOptions): Promise, * ( * success?: (value: SharePage) => void, * error?: (reason: unknown) => void, @@ -26,6 +29,7 @@ const listSharedByMe = defineOperation({ const query = new URLSearchParams(); if ( options.limit !== undefined ) query.set('limit', String(options.limit)); if ( options.cursor !== undefined ) query.set('cursor', String(options.cursor)); + if ( options.appUid !== undefined ) query.set('appUid', String(options.appUid)); if ( firstDefined(options, 'includeTotal', 'include_total') ) { query.set('includeTotal', 'true'); } diff --git a/src/puter-js/src/modules/FileSystem/types.js b/src/puter-js/src/modules/FileSystem/types.js index 5dc69aa52..6540c595f 100644 --- a/src/puter-js/src/modules/FileSystem/types.js +++ b/src/puter-js/src/modules/FileSystem/types.js @@ -372,6 +372,17 @@ * @typedef {ListSharedOptionsOwn & RequestCallbacks} ListSharedOptions */ +/** + * @typedef {Object} ListSharedByMeOptionsOwn + * @property {string} [appUid] Narrow to the shares one app issued in your + * name, or `'none'` for the ones you made yourself. + */ + +/** + * @typedef {ListSharedOptionsOwn & ListSharedByMeOptionsOwn & + * RequestCallbacks} ListSharedByMeOptions + */ + /** * A page of shares. `cursor` is present only while more pages remain, so * iterate until it is absent rather than counting items.