diff --git a/extensions/appTelemetry.test.ts b/extensions/appTelemetry.test.ts index 6c0021fe2..bc72068da 100644 --- a/extensions/appTelemetry.test.ts +++ b/extensions/appTelemetry.test.ts @@ -50,6 +50,32 @@ const seedOwnedApp = async (prefix: string) => { return { owner, app: app! }; }; +const seedUser = async (prefix: string, email: string | null = null) => + server.stores.user.create({ + username: `${prefix}_${Math.random().toString(36).slice(2, 8)}`, + uuid: uuidv4(), + password: 'x', + email, + }); + +const grantAuthenticated = async (appId: number, userId: number) => { + await server.clients.db.write( + `INSERT INTO user_to_app_permissions (user_id, app_id, permission, extra) VALUES (?, ?, ?, ?)`, + [userId, appId, 'flag:app-is-authenticated', null], + ); +}; + +const grantEmailRead = async ( + appId: number, + userId: number, + userUuid: string, +) => { + await server.clients.db.write( + `INSERT INTO user_to_app_permissions (user_id, app_id, permission, extra) VALUES (?, ?, ?, ?)`, + [userId, appId, `user:${userUuid}:email:read`, null], + ); +}; + describe('appTelemetry driver — get_users', () => { it('throws HttpError(400) when app_uuid is missing', async () => { await expect(driver.get_users({})).rejects.toMatchObject({ @@ -117,6 +143,66 @@ describe('appTelemetry driver — get_users', () => { expect(result).toEqual([]); }); + + it('omits user_email for a user who did not grant email:read', async () => { + const { owner, app } = await seedOwnedApp('noemail'); + const member = await seedUser('member', 'secret@example.com'); + await grantAuthenticated(app.id as number, member.id as number); + + const [row] = (await callWithActor( + { user: { uuid: owner.uuid, id: owner.id as number } }, + () => driver.get_users({ app_uuid: app.uid }), + )) as Array>; + + expect(row.user).toBe(member.username); + expect(row.user_uuid).toBe(member.uuid); + // No grant → the field must be absent (not just null), so the email + // never leaks to the app owner. + expect(Object.prototype.hasOwnProperty.call(row, 'user_email')).toBe( + false, + ); + }); + + it('returns user_email when the user granted email:read to the app', async () => { + const { owner, app } = await seedOwnedApp('withemail'); + const member = await seedUser('member', 'shared@example.com'); + await grantAuthenticated(app.id as number, member.id as number); + await grantEmailRead( + app.id as number, + member.id as number, + member.uuid, + ); + + const [row] = (await callWithActor( + { user: { uuid: owner.uuid, id: owner.id as number } }, + () => driver.get_users({ app_uuid: app.uid }), + )) as Array>; + + expect(row.user_uuid).toBe(member.uuid); + expect(row.user_email).toBe('shared@example.com'); + }); + + it('does not leak email granted to a *different* app', async () => { + const { owner, app } = await seedOwnedApp('appA'); + const { app: otherApp } = await seedOwnedApp('appB'); + const member = await seedUser('member', 'crossapp@example.com'); + await grantAuthenticated(app.id as number, member.id as number); + // Grant email:read against the OTHER app only. + await grantEmailRead( + otherApp.id as number, + member.id as number, + member.uuid, + ); + + const [row] = (await callWithActor( + { user: { uuid: owner.uuid, id: owner.id as number } }, + () => driver.get_users({ app_uuid: app.uid }), + )) as Array>; + + expect(Object.prototype.hasOwnProperty.call(row, 'user_email')).toBe( + false, + ); + }); }); describe('appTelemetry driver — user_count', () => { diff --git a/extensions/appTelemetry.ts b/extensions/appTelemetry.ts index 00eec4c19..07b7a4e85 100644 --- a/extensions/appTelemetry.ts +++ b/extensions/appTelemetry.ts @@ -70,7 +70,9 @@ export class AppTelemetryDriver extends PuterDriver { app_uuid?: string; limit?: unknown; offset?: unknown; - } = {}): Promise> { + } = {}): Promise< + Array<{ user: string; user_uuid: string; user_email?: string | null }> + > { if (!app_uuid) throw new HttpError(400, 'Missing `app_uuid`'); const safeLimit = parseIntParam(limit, { @@ -105,16 +107,45 @@ export class AppTelemetryDriver extends PuterDriver { .catch(() => false); if (!ownsApp) throw new HttpError(403, 'Permission denied'); + const appId = (app as { id: number }).id; + const users = (await this.clients.db.read( - `SELECT u.username, u.uuid FROM user_to_app_permissions p + `SELECT u.id, u.username, u.uuid, u.email FROM user_to_app_permissions p INNER JOIN ${this.clients.db.quoteIdentifier('user')} u ON p.user_id = u.id WHERE p.permission = 'flag:app-is-authenticated' AND p.app_id = ? ORDER BY (p.dt IS NOT NULL), p.dt, p.user_id LIMIT ? OFFSET ?`, - [(app as { id: number }).id, safeLimit, safeOffset], - )) as Array<{ username: string; uuid: string }>; + [appId, safeLimit, safeOffset], + )) as Array<{ + id: number; + username: string; + uuid: string; + email: string | null; + }>; - return users.map((e) => ({ user: e.username, user_uuid: e.uuid })); + // Only surface a user's email if *that user* granted this app the + // `user::email:read` permission — the same grant + // `puter.perms.requestEmail()` obtains and `whoami` honours. This is a + // per-user check keyed on the app (not the calling owner-actor): a + // user may have authenticated into the app without sharing their + // email. Resolve the whole page in one query. + const emailPermitted = new Set(); + if (users.length > 0) { + const permStrings = users.map((u) => `user:${u.uuid}:email:read`); + const placeholders = permStrings.map(() => '?').join(', '); + const grants = (await this.clients.db.read( + `SELECT user_id FROM user_to_app_permissions + WHERE app_id = ? AND permission IN (${placeholders})`, + [appId, ...permStrings], + )) as Array<{ user_id: number }>; + for (const g of grants) emailPermitted.add(g.user_id); + } + + return users.map((e) => + emailPermitted.has(e.id) + ? { user: e.username, user_uuid: e.uuid, user_email: e.email } + : { user: e.username, user_uuid: e.uuid }, + ); } /** Count of users who have authenticated into the given app. */ diff --git a/src/puter-js/types/modules/apps.d.ts b/src/puter-js/types/modules/apps.d.ts index 32cb2ced3..5527eaa26 100644 --- a/src/puter-js/types/modules/apps.d.ts +++ b/src/puter-js/types/modules/apps.d.ts @@ -6,6 +6,13 @@ export interface AppUser { username: string; /** The user's unique identifier. */ user_uuid: string; + /** + * The user's email address. Only present when the user granted this app + * the `user::email:read` permission (e.g. via + * `puter.perms.requestEmail()`); omitted otherwise. May be `null` if the + * user granted access but has no email on file. + */ + user_email?: string | null; } /** Pagination options for `App.getUsers()`. */