Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 8 additions & 5 deletions packages/cli/src/commands/publish.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -205,18 +210,16 @@ 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) {
this.error(`You're not an employee, so you can't publish someone else's app.`);
}
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 ?? '<unknown>'}).`
);
this.error(getAppAuthorizationErrorMessage(appInfo, this.project.name, 'publish'));
}
}

Expand Down
8 changes: 6 additions & 2 deletions packages/cli/src/commands/upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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']) {
Expand Down
62 changes: 62 additions & 0 deletions packages/cli/src/util/authorization/appAuthorization.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
55 changes: 55 additions & 0 deletions packages/cli/src/util/authorization/appAuthorization.ts
Original file line number Diff line number Diff line change
@@ -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<FullAppInfo['app']> & {
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 ?? '<unknown>'}) or as a designated maintainer.`;
}