From 105788afa020e37dc3cfb39e7acbaf4ef7109bf5 Mon Sep 17 00:00:00 2001 From: CR29-22-2805 <266851988+CR29-22-2805@users.noreply.github.com> Date: Thu, 9 Jul 2026 10:06:22 -0500 Subject: [PATCH] Add maintainer role for Devvit apps --- packages/cli/src/commands/publish.ts | 13 ++-- packages/cli/src/commands/upload.ts | 8 ++- .../authorization/appAuthorization.test.ts | 62 +++++++++++++++++++ .../util/authorization/appAuthorization.ts | 55 ++++++++++++++++ 4 files changed, 131 insertions(+), 7 deletions(-) create mode 100644 packages/cli/src/util/authorization/appAuthorization.test.ts create mode 100644 packages/cli/src/util/authorization/appAuthorization.ts diff --git a/packages/cli/src/commands/publish.ts b/packages/cli/src/commands/publish.ts index 9bd424df..713a48cb 100644 --- a/packages/cli/src/commands/publish.ts +++ b/packages/cli/src/commands/publish.ts @@ -55,6 +55,11 @@ import { DevvitCommand } from '../util/commands/DevvitCommand.js'; import { installOnSubreddit } from '../util/common-actions/installOnSubreddit.js'; import { waitUntilVersionBuildComplete } from '../util/common-actions/waitUntilVersionBuildComplete.js'; import { DEVVIT_PORTAL_URL } from '../util/config.js'; +import { + canWriteAppVersion, + getAppAuthorizationErrorMessage, + getAppAuthorizationRole, +} from '../util/authorization/appAuthorization.js'; import { getAppBySlug } from '../util/getAppBySlug.js'; import { getAppSourceZip } from '../util/getAppSourceZip.js'; import { readLine } from '../util/input-util.js'; @@ -205,8 +210,8 @@ export default class Publish extends DevvitCommand { const shouldCreatePlaytestSubreddit = !this.project.getSubreddit('Dev'); - const isOwner = appInfo.app.owner?.displayName === username; - if (!isOwner) { + const appAuthorizationRole = getAppAuthorizationRole(appInfo, username); + if (!canWriteAppVersion(appAuthorizationRole)) { if (flags['employee-update']) { const isEmployee = await isCurrentUserEmployee(token); if (!isEmployee) { @@ -214,9 +219,7 @@ export default class Publish extends DevvitCommand { } this.warn(`Overriding ownership check because you're an employee and told me to!`); } else { - this.error( - `You are not the owner of the app "${this.project.name}". Please check that you are logged in as the correct user (${appInfo.app.owner?.displayName ?? ''}).` - ); + this.error(getAppAuthorizationErrorMessage(appInfo, this.project.name, 'publish')); } } diff --git a/packages/cli/src/commands/upload.ts b/packages/cli/src/commands/upload.ts index 244eb32e..61bf6d59 100644 --- a/packages/cli/src/commands/upload.ts +++ b/packages/cli/src/commands/upload.ts @@ -38,6 +38,10 @@ import { import { DevvitCommand } from '../util/commands/DevvitCommand.js'; import { installOnSubreddit } from '../util/common-actions/installOnSubreddit.js'; import { DEVVIT_PORTAL_URL } from '../util/config.js'; +import { + canWriteAppVersion, + getAppAuthorizationRole, +} from '../util/authorization/appAuthorization.js'; import { getAppBySlug } from '../util/getAppBySlug.js'; import { sendEvent } from '../util/metrics.js'; import { @@ -143,8 +147,8 @@ export default class Upload extends DevvitCommand { let shouldCreateNewApp = false; let shouldCreatePlaytestSubreddit = !this.project.getSubreddit('Dev'); - const isOwner = appInfo?.app?.owner?.displayName === username; - if (!isOwner) { + const appAuthorizationRole = getAppAuthorizationRole(appInfo, username); + if (!canWriteAppVersion(appAuthorizationRole)) { shouldCreateNewApp = true; // Unless... if (flags['employee-update'] || flags['just-do-it']) { diff --git a/packages/cli/src/util/authorization/appAuthorization.test.ts b/packages/cli/src/util/authorization/appAuthorization.test.ts new file mode 100644 index 00000000..703fd62f --- /dev/null +++ b/packages/cli/src/util/authorization/appAuthorization.test.ts @@ -0,0 +1,62 @@ +// eslint-disable-next-line no-restricted-imports +import type { FullAppInfo } from '@devvit/protos/types/devvit/dev_portal/app/app.js'; +import { describe, expect, test } from 'vitest'; + +import { + canWriteAppVersion, + getAppAuthorizationErrorMessage, + getAppAuthorizationRole, +} from './appAuthorization.js'; + +describe('appAuthorization', () => { + test('allows the app owner to write app versions', () => { + const appInfo = { + app: { + owner: { displayName: 'OwnerName' }, + }, + }; + + const role = getAppAuthorizationRole(appInfo as FullAppInfo, 'ownername'); + + expect(role).toBe('owner'); + expect(canWriteAppVersion(role)).toBe(true); + }); + + test('allows a designated app maintainer to write app versions', () => { + const appInfo = { + app: { + owner: { displayName: 'OwnerName' }, + maintainers: [{ displayName: 'MaintainerName' }], + }, + }; + + const role = getAppAuthorizationRole(appInfo as FullAppInfo, 'maintainername'); + + expect(role).toBe('maintainer'); + expect(canWriteAppVersion(role)).toBe(true); + }); + + test('does not allow unrelated developers to write app versions', () => { + const appInfo = { + app: { + owner: { displayName: 'OwnerName' }, + maintainers: [{ displayName: 'MaintainerName' }], + }, + }; + + const role = getAppAuthorizationRole(appInfo as FullAppInfo, 'OtherDeveloper'); + + expect(role).toBe('unauthorized'); + expect(canWriteAppVersion(role)).toBe(false); + }); + + test('uses a maintainer-aware error message', () => { + expect( + getAppAuthorizationErrorMessage( + { app: { owner: { displayName: 'OwnerName' } } } as FullAppInfo, + 'example-app', + 'publish' + ) + ).toContain('owner (OwnerName) or as a designated maintainer'); + }); +}); diff --git a/packages/cli/src/util/authorization/appAuthorization.ts b/packages/cli/src/util/authorization/appAuthorization.ts new file mode 100644 index 00000000..8975f42d --- /dev/null +++ b/packages/cli/src/util/authorization/appAuthorization.ts @@ -0,0 +1,55 @@ +// eslint-disable-next-line no-restricted-imports +import type { FullAppInfo } from '@devvit/protos/types/devvit/dev_portal/app/app.js'; + +export type AppAuthorizationRole = 'owner' | 'maintainer' | 'unauthorized'; +export type AppWriteAction = 'upload' | 'publish'; + +type DeveloperDisplayName = { + displayName?: string; +}; + +type AppWithMaintainers = NonNullable & { + maintainers?: readonly DeveloperDisplayName[]; +}; + +function normalizeDisplayName(displayName: string | undefined): string | undefined { + return displayName?.trim().toLowerCase(); +} + +export function getAppAuthorizationRole( + appInfo: FullAppInfo | undefined, + username: string +): AppAuthorizationRole { + const app = appInfo?.app as AppWithMaintainers | undefined; + const normalizedUsername = normalizeDisplayName(username); + + if (!app || !normalizedUsername) { + return 'unauthorized'; + } + + if (normalizeDisplayName(app.owner?.displayName) === normalizedUsername) { + return 'owner'; + } + + if ( + app.maintainers?.some( + (maintainer) => normalizeDisplayName(maintainer.displayName) === normalizedUsername + ) + ) { + return 'maintainer'; + } + + return 'unauthorized'; +} + +export function canWriteAppVersion(role: AppAuthorizationRole): boolean { + return role === 'owner' || role === 'maintainer'; +} + +export function getAppAuthorizationErrorMessage( + appInfo: FullAppInfo, + appName: string, + action: AppWriteAction +): string { + return `You are not authorized to ${action} the app "${appName}". Please check that you are logged in as the owner (${appInfo.app?.owner?.displayName ?? ''}) or as a designated maintainer.`; +}