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
19 changes: 14 additions & 5 deletions packages/cli/src/commands/playtest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ import { toLowerCaseArgParser } from '../util/commands/DevvitCommand.js';
import { DevvitCommand } from '../util/commands/DevvitCommand.js';
import { getSubredditNameWithoutPrefix } from '../util/common-actions/getSubredditNameWithoutPrefix.js';
import { installOnSubreddit } from '../util/common-actions/installOnSubreddit.js';
import {
canWriteAppVersion,
getAppAuthorizationRole,
} from '../util/authorization/appAuthorization.js';
import { waitUntilVersionBuildComplete } from '../util/common-actions/waitUntilVersionBuildComplete.js';
import { getAppBySlug } from '../util/getAppBySlug.js';
import { killProcessTree, killProcessTreeSync } from '../util/kill-process-tree.js';
Expand Down Expand Up @@ -433,10 +437,13 @@ export default class Playtest extends DevvitCommand {
}
this.project.app.defaultPlaytestSubredditId = this.#appInfo.app.defaultPlaytestSubredditId;

const isOwner = this.#appInfo?.app?.owner?.id === userInfo.id;
const appAuthorizationRole = getAppAuthorizationRole(this.#appInfo, {
id: userInfo.id,
displayName: userInfo.username,
});
await this.#checkIfUserAllowedToPlaytestThisApp(
this.project.name,
isOwner,
canWriteAppVersion(appAuthorizationRole),
token,
flags['employee-update']
);
Expand Down Expand Up @@ -869,11 +876,11 @@ export default class Playtest extends DevvitCommand {

async #checkIfUserAllowedToPlaytestThisApp(
appName: string,
isOwner: boolean,
isAuthorizedToWriteApp: boolean,
token: StoredToken,
employeeUpdateFlag: boolean
): Promise<void> {
if (isOwner) {
if (isAuthorizedToWriteApp) {
return;
}

Expand All @@ -888,7 +895,9 @@ export default class Playtest extends DevvitCommand {
// Check if the app name is available, implying this is a first run
const appExists = await checkAppNameAvailability(this.#appClient, appName);
if (appExists.exists) {
this.error(`That app already exists, and you can't playtest someone else's app!`);
this.error(
`That app already exists, and you are not its owner or a designated maintainer.`
);
}

// App doesn't exist - tell the user to run `devvit upload` first
Expand Down
19 changes: 13 additions & 6 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 @@ -178,7 +183,8 @@ export default class Publish extends DevvitCommand {
const token = await getAccessTokenAndLoginIfNeeded(
flags['copy-paste'] ? 'CopyPaste' : 'LocalSocket'
);
const username = await this.getUserDisplayName(token);
const userInfo = await this.getUserInfo(token);
const username = userInfo.username;

await this.checkDeveloperAccount();

Expand All @@ -205,18 +211,19 @@ 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, {
id: userInfo.id,
displayName: 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
20 changes: 15 additions & 5 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 All @@ -49,7 +53,7 @@ import {
} from './init.js';

export default class Upload extends DevvitCommand {
static override description = `Upload the app to the App Directory. Uploaded apps are only visible to you (the app owner) and can only be installed to a small test subreddit with less than ${MAX_ALLOWED_SUBSCRIBER_COUNT} subscribers`;
static override description = `Upload the app to the App Directory. Uploaded apps are visible to the app owner and designated maintainers and can only be installed to a small test subreddit with less than ${MAX_ALLOWED_SUBSCRIBER_COUNT} subscribers`;

static override flags = {
bump: Flags.custom<VersionBumpType>({
Expand Down Expand Up @@ -125,7 +129,8 @@ export default class Upload extends DevvitCommand {
const token = await getAccessTokenAndLoginIfNeeded(
flags['copy-paste'] ? 'CopyPaste' : 'LocalSocket'
);
const username = await this.getUserDisplayName(token);
const userInfo = await this.getUserInfo(token);
const username = userInfo.username;

await this.checkDeveloperAccount();

Expand All @@ -143,14 +148,19 @@ 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, {
id: userInfo.id,
displayName: username,
});
if (!canWriteAppVersion(appAuthorizationRole)) {
shouldCreateNewApp = true;
// Unless...
if (flags['employee-update'] || flags['just-do-it']) {
const isEmployee = await isCurrentUserEmployee(token);
if (!isEmployee) {
this.error(`You're not an employee, so you can't playtest someone else's app.`);
this.error(
`You're not an employee, so you can't upload a version of someone else's app.`
);
}
// Else, we're an employee, so we can update someone else's app
this.warn(`Overriding ownership check because you're an employee and told me to!`);
Expand Down
124 changes: 124 additions & 0 deletions packages/cli/src/util/authorization/appAuthorization.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
import { describe, expect, test } from 'vitest';

import {
canWriteAppVersion,
getAppAuthorizationErrorMessage,
getAppAuthorizationRole,
} from './appAuthorization.js';

describe('appAuthorization', () => {
test('recognizes the app owner by stable account ID', () => {
const appInfo = {
app: {
owner: { id: 't2_owner', displayName: 'OriginalOwnerName' },
},
};

const role = getAppAuthorizationRole(appInfo, {
id: 't2_owner',
displayName: 'RenamedOwner',
});

expect(role).toBe('owner');
expect(canWriteAppVersion(role)).toBe(true);
});

test('does not fall back to a matching display name when stable IDs disagree', () => {
const appInfo = {
app: {
owner: { id: 't2_owner', displayName: 'SharedName' },
},
};

expect(
getAppAuthorizationRole(appInfo, {
id: 't2_other',
displayName: 'SharedName',
})
).toBe('unauthorized');
});

test('recognizes a maintainer from typed authorization metadata by account ID', () => {
const appInfo = {
app: {
owner: { id: 't2_owner', displayName: 'OwnerName' },
authorization: {
maintainers: [{ id: 't2_maintainer', displayName: 'MaintainerName' }],
},
},
};

const role = getAppAuthorizationRole(appInfo, {
id: 't2_maintainer',
displayName: 'RenamedMaintainer',
});

expect(role).toBe('maintainer');
expect(canWriteAppVersion(role)).toBe(true);
});

test('accepts a backend-computed current-user role', () => {
const appInfo = {
app: {
authorization: {
currentUserRole: 'maintainer',
},
},
};

expect(getAppAuthorizationRole(appInfo, '')).toBe('maintainer');
});

test('temporarily supports the prototype maintainer list by display name', () => {
const appInfo = {
app: {
owner: { displayName: 'OwnerName' },
maintainers: [{ displayName: 'MaintainerName' }],
},
};

expect(getAppAuthorizationRole(appInfo, ' maintainername ')).toBe('maintainer');
});

test('does not authorize an unrelated developer', () => {
const appInfo = {
app: {
owner: { id: 't2_owner', displayName: 'OwnerName' },
authorization: {
maintainers: [{ id: 't2_maintainer', displayName: 'MaintainerName' }],
},
},
};

const role = getAppAuthorizationRole(appInfo, {
id: 't2_other',
displayName: 'OtherDeveloper',
});

expect(role).toBe('unauthorized');
expect(canWriteAppVersion(role)).toBe(false);
});

test.each(['owner', 'maintainer'] as const)(
'allows the %s role to upload, publish, and playtest app versions',
(role) => {
expect(canWriteAppVersion(role)).toBe(true);
}
);

test('denies app-version writes when authorization metadata is missing', () => {
expect(getAppAuthorizationRole(undefined, 'OwnerName')).toBe('unauthorized');
expect(getAppAuthorizationRole({ app: null }, 'OwnerName')).toBe('unauthorized');
expect(getAppAuthorizationRole({ app: {} }, '')).toBe('unauthorized');
});

test('uses a maintainer-aware error message', () => {
expect(
getAppAuthorizationErrorMessage(
{ app: { owner: { displayName: 'OwnerName' } } },
'example-app',
'publish'
)
).toContain('owner (OwnerName) or as a designated maintainer');
});
});
Loading