Skip to content

Commit a7b5504

Browse files
committed
Add maintainer role for Devvit apps
1 parent 204274b commit a7b5504

4 files changed

Lines changed: 131 additions & 7 deletions

File tree

packages/cli/src/commands/publish.ts

Lines changed: 8 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,11 @@ import { DevvitCommand } from '../util/commands/DevvitCommand.js';
5555
import { installOnSubreddit } from '../util/common-actions/installOnSubreddit.js';
5656
import { waitUntilVersionBuildComplete } from '../util/common-actions/waitUntilVersionBuildComplete.js';
5757
import { DEVVIT_PORTAL_URL } from '../util/config.js';
58+
import {
59+
canWriteAppVersion,
60+
getAppAuthorizationErrorMessage,
61+
getAppAuthorizationRole,
62+
} from '../util/authorization/appAuthorization.js';
5863
import { getAppBySlug } from '../util/getAppBySlug.js';
5964
import { getAppSourceZip } from '../util/getAppSourceZip.js';
6065
import { readLine } from '../util/input-util.js';
@@ -205,18 +210,16 @@ export default class Publish extends DevvitCommand {
205210

206211
const shouldCreatePlaytestSubreddit = !this.project.getSubreddit('Dev');
207212

208-
const isOwner = appInfo.app.owner?.displayName === username;
209-
if (!isOwner) {
213+
const appAuthorizationRole = getAppAuthorizationRole(appInfo, username);
214+
if (!canWriteAppVersion(appAuthorizationRole)) {
210215
if (flags['employee-update']) {
211216
const isEmployee = await isCurrentUserEmployee(token);
212217
if (!isEmployee) {
213218
this.error(`You're not an employee, so you can't publish someone else's app.`);
214219
}
215220
this.warn(`Overriding ownership check because you're an employee and told me to!`);
216221
} else {
217-
this.error(
218-
`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>'}).`
219-
);
222+
this.error(getAppAuthorizationErrorMessage(appInfo, this.project.name, 'publish'));
220223
}
221224
}
222225

packages/cli/src/commands/upload.ts

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@ import {
3838
import { DevvitCommand } from '../util/commands/DevvitCommand.js';
3939
import { installOnSubreddit } from '../util/common-actions/installOnSubreddit.js';
4040
import { DEVVIT_PORTAL_URL } from '../util/config.js';
41+
import {
42+
canWriteAppVersion,
43+
getAppAuthorizationRole,
44+
} from '../util/authorization/appAuthorization.js';
4145
import { getAppBySlug } from '../util/getAppBySlug.js';
4246
import { sendEvent } from '../util/metrics.js';
4347
import {
@@ -143,8 +147,8 @@ export default class Upload extends DevvitCommand {
143147
let shouldCreateNewApp = false;
144148
let shouldCreatePlaytestSubreddit = !this.project.getSubreddit('Dev');
145149

146-
const isOwner = appInfo?.app?.owner?.displayName === username;
147-
if (!isOwner) {
150+
const appAuthorizationRole = getAppAuthorizationRole(appInfo, username);
151+
if (!canWriteAppVersion(appAuthorizationRole)) {
148152
shouldCreateNewApp = true;
149153
// Unless...
150154
if (flags['employee-update'] || flags['just-do-it']) {
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
// eslint-disable-next-line no-restricted-imports
2+
import type { FullAppInfo } from '@devvit/protos/types/devvit/dev_portal/app/app.js';
3+
import { describe, expect, test } from 'vitest';
4+
5+
import {
6+
canWriteAppVersion,
7+
getAppAuthorizationErrorMessage,
8+
getAppAuthorizationRole,
9+
} from './appAuthorization.js';
10+
11+
describe('appAuthorization', () => {
12+
test('allows the app owner to write app versions', () => {
13+
const appInfo = {
14+
app: {
15+
owner: { displayName: 'OwnerName' },
16+
},
17+
};
18+
19+
const role = getAppAuthorizationRole(appInfo as FullAppInfo, 'ownername');
20+
21+
expect(role).toBe('owner');
22+
expect(canWriteAppVersion(role)).toBe(true);
23+
});
24+
25+
test('allows a designated app maintainer to write app versions', () => {
26+
const appInfo = {
27+
app: {
28+
owner: { displayName: 'OwnerName' },
29+
maintainers: [{ displayName: 'MaintainerName' }],
30+
},
31+
};
32+
33+
const role = getAppAuthorizationRole(appInfo as FullAppInfo, 'maintainername');
34+
35+
expect(role).toBe('maintainer');
36+
expect(canWriteAppVersion(role)).toBe(true);
37+
});
38+
39+
test('does not allow unrelated developers to write app versions', () => {
40+
const appInfo = {
41+
app: {
42+
owner: { displayName: 'OwnerName' },
43+
maintainers: [{ displayName: 'MaintainerName' }],
44+
},
45+
};
46+
47+
const role = getAppAuthorizationRole(appInfo as FullAppInfo, 'OtherDeveloper');
48+
49+
expect(role).toBe('unauthorized');
50+
expect(canWriteAppVersion(role)).toBe(false);
51+
});
52+
53+
test('uses a maintainer-aware error message', () => {
54+
expect(
55+
getAppAuthorizationErrorMessage(
56+
{ app: { owner: { displayName: 'OwnerName' } } } as FullAppInfo,
57+
'example-app',
58+
'publish'
59+
)
60+
).toContain('owner (OwnerName) or as a designated maintainer');
61+
});
62+
});
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
// eslint-disable-next-line no-restricted-imports
2+
import type { FullAppInfo } from '@devvit/protos/types/devvit/dev_portal/app/app.js';
3+
4+
export type AppAuthorizationRole = 'owner' | 'maintainer' | 'unauthorized';
5+
export type AppWriteAction = 'upload' | 'publish';
6+
7+
type DeveloperDisplayName = {
8+
displayName?: string;
9+
};
10+
11+
type AppWithMaintainers = NonNullable<FullAppInfo['app']> & {
12+
maintainers?: readonly DeveloperDisplayName[];
13+
};
14+
15+
function normalizeDisplayName(displayName: string | undefined): string | undefined {
16+
return displayName?.trim().toLowerCase();
17+
}
18+
19+
export function getAppAuthorizationRole(
20+
appInfo: FullAppInfo | undefined,
21+
username: string
22+
): AppAuthorizationRole {
23+
const app = appInfo?.app as AppWithMaintainers | undefined;
24+
const normalizedUsername = normalizeDisplayName(username);
25+
26+
if (!app || !normalizedUsername) {
27+
return 'unauthorized';
28+
}
29+
30+
if (normalizeDisplayName(app.owner?.displayName) === normalizedUsername) {
31+
return 'owner';
32+
}
33+
34+
if (
35+
app.maintainers?.some(
36+
(maintainer) => normalizeDisplayName(maintainer.displayName) === normalizedUsername
37+
)
38+
) {
39+
return 'maintainer';
40+
}
41+
42+
return 'unauthorized';
43+
}
44+
45+
export function canWriteAppVersion(role: AppAuthorizationRole): boolean {
46+
return role === 'owner' || role === 'maintainer';
47+
}
48+
49+
export function getAppAuthorizationErrorMessage(
50+
appInfo: FullAppInfo,
51+
appName: string,
52+
action: AppWriteAction
53+
): string {
54+
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.`;
55+
}

0 commit comments

Comments
 (0)