diff --git a/src/backend/controllers/auth/AuthController.test.ts b/src/backend/controllers/auth/AuthController.test.ts index ba047ccb7..50f4eb287 100644 --- a/src/backend/controllers/auth/AuthController.test.ts +++ b/src/backend/controllers/auth/AuthController.test.ts @@ -50,12 +50,10 @@ import { import { PuterServer } from '../../server.js'; import { createSecret as otpCreateSecret } from '../../services/auth/OTPUtil.js'; import { kvSharePermission } from '../../services/kvShare/kvShares.js'; -import { FULL_API_ACCESS } from '../../services/permission/consts.js'; import { - hasPushConsent, + FULL_API_ACCESS, NOTIFICATIONS_PUSH_PERMISSION, - type PushConsentDeps, -} from '../../services/push/pushConsent.js'; +} from '../../services/permission/consts.js'; import { setupTestServer } from '../../testUtil.js'; import { resetCardVerificationStatusCache } from '../../util/cardFallback.js'; import { FS_READ_LIMIT } from '../fs/limits.js'; @@ -2184,7 +2182,7 @@ describe('AuthController grant flows', () => { ).toContain(permission); }); - it('grant-user-app: grants an app push consent with notifications:push', async () => { + it('grant-user-app: grants and revokes notifications:push for an app', async () => { const app = await server.stores.app.create( { name: `tgp-${uuidv4()}`, @@ -2193,11 +2191,17 @@ describe('AuthController grant flows', () => { }, { ownerUserId: issuer.id }, ); - const deps = { - stores: server.stores, - services: { permission: server.services.permission }, - } as unknown as PushConsentDeps; - expect(await hasPushConsent(deps, issuer.id, app.uid)).toBe(false); + const user = await server.stores.user.getById(issuer.id); + const appActor = makeActor({ + user: user!, + app: { uid: app.uid, id: app.id }, + }); + const hasPush = () => + server.services.permission.check( + appActor, + NOTIFICATIONS_PUSH_PERMISSION, + ); + expect(await hasPush()).toBe(false); // Anything past the bare permission names nothing a prompt describes. await expect( @@ -2217,7 +2221,7 @@ describe('AuthController grant flows', () => { statusCode: 400, legacyCode: 'permission_not_grantable', }); - expect(await hasPushConsent(deps, issuer.id, app.uid)).toBe(false); + expect(await hasPush()).toBe(false); const res = makeRes(); await inCtx(issuerActor, () => @@ -2233,7 +2237,21 @@ describe('AuthController grant flows', () => { ), ); expect(res.body).toEqual({}); - expect(await hasPushConsent(deps, issuer.id, app.uid)).toBe(true); + expect(await hasPush()).toBe(true); + + await inCtx(issuerActor, () => + controller.handleRevokeUserApp( + makeReq( + { + app_uid: app.uid, + permission: NOTIFICATIONS_PUSH_PERMISSION, + }, + { actor: issuerActor }, + ), + makeRes(), + ), + ); + expect(await hasPush()).toBe(false); }); it('grant-user-app: resolves `origin` to the app when app_uid is omitted', async () => { diff --git a/src/backend/services/permission/appGrantable.ts b/src/backend/services/permission/appGrantable.ts index b952bcb93..b9399b03d 100644 --- a/src/backend/services/permission/appGrantable.ts +++ b/src/backend/services/permission/appGrantable.ts @@ -24,14 +24,13 @@ import { type KvShareAccess, kvShareRoot, } from '../kvShare/kvShares.js'; -import { NOTIFICATIONS_PUSH_PERMISSION } from '../push/pushConsent.js'; import { APP_DATA_CLASSES, APP_DATA_KV_OPS, APP_DATA_PERMISSION_PREFIX, type AppDataStore, } from './appDataScopes.js'; -import { MANAGE_PERM_PREFIX } from './consts.js'; +import { MANAGE_PERM_PREFIX, NOTIFICATIONS_PUSH_PERMISSION } from './consts.js'; import { PermissionUtil } from './permissionUtil.js'; const ACCESS_LEVELS = ['read', 'write'] as const; diff --git a/src/backend/services/permission/consts.ts b/src/backend/services/permission/consts.ts index f5fc9990c..d8f366535 100644 --- a/src/backend/services/permission/consts.ts +++ b/src/backend/services/permission/consts.ts @@ -20,6 +20,9 @@ export const MANAGE_PERM_PREFIX = 'manage'; export const PERM_KEY_PREFIX = 'perm'; +/** Per-app consent for an app's notifications to go out as pushes. */ +export const NOTIFICATIONS_PUSH_PERMISSION = 'notifications:push'; + /** * Width of the `permission` column in the permission tables, which every * dialect declares as `varchar(255)`. Anything building a permission string diff --git a/src/backend/services/push/pushConsent.test.ts b/src/backend/services/push/pushConsent.test.ts deleted file mode 100644 index d2bb78ed5..000000000 --- a/src/backend/services/push/pushConsent.test.ts +++ /dev/null @@ -1,206 +0,0 @@ -/* - * Copyright (C) 2024-present Puter Technologies Inc. - * - * This file is part of Puter. - * - * Puter is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published - * by the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -import { v4 as uuidv4 } from 'uuid'; -import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; -import { makeActor, type Actor } from '../../core/actor.js'; -import { PuterServer } from '../../server.js'; -import { setupTestServer } from '../../testUtil.js'; -import { EVENTS_BACKGROUND_PERMISSION } from '../events/authorization.js'; -import type { PermissionService } from '../permission/PermissionService.js'; -import { - hasPushConsent, - NOTIFICATIONS_PUSH_PERMISSION, - type PushConsentDeps, -} from './pushConsent.js'; - -interface TestUser { - id: number; - uuid: string; - username: string; -} - -describe('hasPushConsent', () => { - let server: PuterServer; - let permission: PermissionService; - let deps: PushConsentDeps; - - const makeUser = async (): Promise => - (await server.stores.user.create({ - username: `push${Math.random().toString(36).slice(2, 10)}`, - uuid: uuidv4(), - password: null, - email: null, - requires_email_confirmation: false, - })) as TestUser; - - const makeApp = async (owner: TestUser): Promise => { - const create = server.stores.app.create.bind( - server.stores.app, - ) as unknown as ( - fields: Record, - opts: { ownerUserId: number }, - ) => Promise<{ uid: string }>; - const app = await create( - { - name: `push-${uuidv4()}`, - title: 'Push app', - index_url: `https://push-${uuidv4()}.test/`, - }, - { ownerUserId: owner.id }, - ); - return app.uid; - }; - - const userActor = (user: TestUser): Actor => makeActor({ user }); - - const grant = (user: TestUser, appUid: string, perm: string) => - permission.grantUserAppPermission(userActor(user), appUid, perm); - - beforeAll(async () => { - server = await setupTestServer(); - permission = server.services.permission as unknown as PermissionService; - deps = { - stores: server.stores, - services: { permission }, - } as unknown as PushConsentDeps; - }, 60_000); - - afterAll(async () => { - await server?.shutdown(); - }, 60_000); - - it('is false for an app the user never granted, though the user holds the root', async () => { - const user = await makeUser(); - const appUid = await makeApp(user); - - expect( - await permission.check( - userActor(user), - NOTIFICATIONS_PUSH_PERMISSION, - ), - ).toBe(true); - expect(await hasPushConsent(deps, user.id, appUid)).toBe(false); - }); - - it('is true only for the app and the user the grant names', async () => { - const user = await makeUser(); - const other = await makeUser(); - const appUid = await makeApp(user); - const otherAppUid = await makeApp(user); - - await grant(user, appUid, NOTIFICATIONS_PUSH_PERMISSION); - - expect(await hasPushConsent(deps, user.id, appUid)).toBe(true); - expect(await hasPushConsent(deps, user.id, otherAppUid)).toBe(false); - expect(await hasPushConsent(deps, other.id, appUid)).toBe(false); - }); - - it('turns false on the next check after the permission is revoked', async () => { - const user = await makeUser(); - const appUid = await makeApp(user); - await grant(user, appUid, NOTIFICATIONS_PUSH_PERMISSION); - expect(await hasPushConsent(deps, user.id, appUid)).toBe(true); - - await permission.revokeUserAppPermission( - userActor(user), - appUid, - NOTIFICATIONS_PUSH_PERMISSION, - ); - - expect(await hasPushConsent(deps, user.id, appUid)).toBe(false); - }); - - it('turns false on the next check after all of the app’s access is revoked', async () => { - const user = await makeUser(); - const appUid = await makeApp(user); - await grant(user, appUid, NOTIFICATIONS_PUSH_PERMISSION); - expect(await hasPushConsent(deps, user.id, appUid)).toBe(true); - - await permission.revokeUserAppAll(userActor(user), appUid); - - expect(await hasPushConsent(deps, user.id, appUid)).toBe(false); - }); - - it('is not granted by events:background, nor grants it', async () => { - const user = await makeUser(); - const backgroundApp = await makeApp(user); - const pushApp = await makeApp(user); - await grant(user, backgroundApp, EVENTS_BACKGROUND_PERMISSION); - await grant(user, pushApp, NOTIFICATIONS_PUSH_PERMISSION); - - expect(await hasPushConsent(deps, user.id, backgroundApp)).toBe(false); - const pushAppRow = await server.stores.app.getByUid(pushApp); - expect( - await permission.check( - makeActor({ user, app: { uid: pushApp, id: pushAppRow.id } }), - EVENTS_BACKGROUND_PERMISSION, - ), - ).toBe(false); - }); - - it('builds the actor from the stored app uid', async () => { - const check = vi.fn(async () => true); - const stub = { - stores: { - user: { - getById: async () => ({ id: 1, uuid: 'u', username: 'u' }), - }, - app: { - getByUid: async () => ({ id: 7, uid: 'app-canonical' }), - }, - }, - services: { permission: { check } }, - } as unknown as PushConsentDeps; - - expect(await hasPushConsent(stub, 1, 'APP-CANONICAL')).toBe(true); - expect(check).toHaveBeenCalledWith( - expect.objectContaining({ app: { uid: 'app-canonical', id: 7 } }), - NOTIFICATIONS_PUSH_PERMISSION, - ); - }); - - it('is false for a user or an app that does not exist', async () => { - const user = await makeUser(); - const appUid = await makeApp(user); - await grant(user, appUid, NOTIFICATIONS_PUSH_PERMISSION); - - expect(await hasPushConsent(deps, user.id, `app-${uuidv4()}`)).toBe( - false, - ); - expect(await hasPushConsent(deps, 2_000_000_000, appUid)).toBe(false); - }); - - it('is false when the check itself fails', async () => { - const user = await makeUser(); - const appUid = await makeApp(user); - const failing = { - ...deps, - services: { - permission: { - check: async () => { - throw new Error('unavailable'); - }, - }, - }, - } as PushConsentDeps; - - expect(await hasPushConsent(failing, user.id, appUid)).toBe(false); - }); -}); diff --git a/src/backend/services/push/pushConsent.ts b/src/backend/services/push/pushConsent.ts deleted file mode 100644 index 61b41d1a3..000000000 --- a/src/backend/services/push/pushConsent.ts +++ /dev/null @@ -1,63 +0,0 @@ -/* - * Copyright (C) 2024-present Puter Technologies Inc. - * - * This file is part of Puter. - * - * Puter is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as published - * by the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - */ - -import { makeActor } from '../../core/actor.js'; -import type { UserRow } from '../../stores/user/UserStore.js'; -import type { PermissionService } from '../permission/PermissionService.js'; - -/** Per-app consent for an app's notifications to go out as pushes. */ -export const NOTIFICATIONS_PUSH_PERMISSION = 'notifications:push'; - -export interface PushConsentDeps { - stores: { - user: { getById: (id: number) => Promise }; - app: { - getByUid: ( - uid: string, - ) => Promise< - { id?: number | null; uid?: string | null } | null | undefined - >; - }; - }; - services: { permission: Pick }; -} - -/** Read on every send for notifications an app itself sends, not Puter's. */ -export const hasPushConsent = async ( - deps: PushConsentDeps, - userId: number, - appUid: string, -): Promise => { - try { - const [user, app] = await Promise.all([ - deps.stores.user.getById(userId), - deps.stores.app.getByUid(appUid), - ]); - if (!user?.id || !app?.id || !app.uid) return false; - // As the app for that user, keyed by the row's uid that revokes bump. - const actor = makeActor({ user, app: { uid: app.uid, id: app.id } }); - return await deps.services.permission.check( - actor, - NOTIFICATIONS_PUSH_PERMISSION, - ); - } catch (err) { - console.warn('[push] consent check failed', err); - return false; - } -};