mirror of
https://github.com/HeyPuter/puter.git
synced 2026-08-23 22:47:19 +00:00
add just app telementry (#3387)
This commit is contained in:
@@ -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<Record<string, unknown>>;
|
||||
|
||||
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<Record<string, unknown>>;
|
||||
|
||||
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<Record<string, unknown>>;
|
||||
|
||||
expect(Object.prototype.hasOwnProperty.call(row, 'user_email')).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('appTelemetry driver — user_count', () => {
|
||||
|
||||
@@ -70,7 +70,9 @@ export class AppTelemetryDriver extends PuterDriver {
|
||||
app_uuid?: string;
|
||||
limit?: unknown;
|
||||
offset?: unknown;
|
||||
} = {}): Promise<Array<{ user: string; user_uuid: string }>> {
|
||||
} = {}): 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:<their-uuid>: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<number>();
|
||||
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. */
|
||||
|
||||
Vendored
+7
@@ -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:<uuid>: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()`. */
|
||||
|
||||
Reference in New Issue
Block a user