diff --git a/assets/openapi.json b/assets/openapi.json
index 346355025..509181b40 100644
Binary files a/assets/openapi.json and b/assets/openapi.json differ
diff --git a/assets/schemas.json b/assets/schemas.json
index c7d164718..760fed831 100644
Binary files a/assets/schemas.json and b/assets/schemas.json differ
diff --git a/src/api/routes/oauth2/applications/#id/allowlist.ts b/src/api/routes/oauth2/applications/#id/allowlist.ts
new file mode 100644
index 000000000..10698b74b
--- /dev/null
+++ b/src/api/routes/oauth2/applications/#id/allowlist.ts
@@ -0,0 +1,114 @@
+/*
+ Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
+ Copyright (C) 2023 Spacebar and Spacebar Contributors
+
+ This program 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 { Request, Response, Router } from "express";
+import { route } from "@spacebar/api/middlewares";
+import { Application, User } from "@spacebar/database";
+import { Config, DiscordApiErrors } from "@spacebar/util";
+import { AllowlistAddRequestSchema, AllowlistedUser, ApplicationAllowlistResponse, ApplicationMembershipState, PublicUserProjection } from "@spacebar/schemas";
+
+const router: Router = Router({ mergeParams: true });
+
+router.get(
+ "/",
+ route({
+ responses: {
+ 200: {
+ body: "ApplicationAllowlistResponse",
+ },
+ },
+ }),
+ async (req: Request, res: Response) => {
+ // TODO: stub
+ const app = await Application.findOneOrFail({
+ where: { id: req.params.id as string },
+ relations: { bot: true, owner: true },
+ select: {
+ owner: { id: true },
+ },
+ });
+
+ if (req.user_id != app.owner.id) throw DiscordApiErrors.ACTION_NOT_AUTHORIZED_ON_APPLICATION;
+
+ return res.json([] satisfies ApplicationAllowlistResponse);
+ },
+);
+
+router.post(
+ "/",
+ route({
+ requestBody: "AllowlistAddRequestSchema",
+ responses: {
+ 200: {
+ body: "AllowlistedUser",
+ },
+ },
+ }),
+ async (req: Request, res: Response) => {
+ // TODO: stub
+ const app = await Application.findOneOrFail({
+ where: { id: req.params.id as string },
+ relations: { bot: true, owner: true },
+ select: {
+ owner: { id: true },
+ },
+ });
+
+ if (req.user_id != app.owner.id) throw DiscordApiErrors.ACTION_NOT_AUTHORIZED_ON_APPLICATION;
+ if (Number(1) >= Config.get().limits.application.maxTesters) throw DiscordApiErrors.MAXIMUM_OAUTH2_APPLICATIONS; // TODO: correct error
+
+ const body = req.body as AllowlistAddRequestSchema;
+
+ const targetUser = await User.findOneOrFail({
+ where: {
+ username: body.username,
+ discriminator: body.discriminator ?? "0",
+ },
+ });
+
+ return res.json({
+ user: targetUser.toPartialUser(),
+ state: ApplicationMembershipState.INVITED,
+ } satisfies AllowlistedUser);
+ },
+);
+
+router.delete(
+ "/:user_id",
+ route({
+ responses: {
+ 204: {},
+ },
+ }),
+ async (req: Request, res: Response) => {
+ // TODO: stub
+ const app = await Application.findOneOrFail({
+ where: { id: req.params.id as string },
+ relations: { bot: true, owner: true },
+ select: {
+ owner: Object.fromEntries(PublicUserProjection.map((x) => [x, true])),
+ },
+ });
+
+ if (req.user_id != app.owner.id) throw DiscordApiErrors.ACTION_NOT_AUTHORIZED_ON_APPLICATION;
+
+ return res.status(204).json({});
+ },
+);
+
+export default router;
diff --git a/src/api/routes/oauth2/applications/@me.ts b/src/api/routes/oauth2/applications/@me.ts
index 872cb5b27..732b74ef0 100644
--- a/src/api/routes/oauth2/applications/@me.ts
+++ b/src/api/routes/oauth2/applications/@me.ts
@@ -35,7 +35,7 @@ router.get(
}),
async (req: Request, res: Response) => {
const app = await Application.findOneOrFail({
- where: { id: req.params.id as string }, // ...huh? there's no ID in the path...
+ where: { id: req.user_id as string },
relations: { bot: true, owner: true },
select: {
owner: Object.fromEntries(PublicUserProjection.map((x) => [x, true])),
diff --git a/src/api/routes/oauth2/applications/allowlist.ts b/src/api/routes/oauth2/applications/allowlist.ts
new file mode 100644
index 000000000..43d07d903
--- /dev/null
+++ b/src/api/routes/oauth2/applications/allowlist.ts
@@ -0,0 +1,62 @@
+/*
+ Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
+ Copyright (C) 2023 Spacebar and Spacebar Contributors
+
+ This program 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 { Request, Response, Router } from "express";
+import { route } from "@spacebar/api/middlewares";
+import { Application, User } from "@spacebar/database";
+import { Config, DiscordApiErrors } from "@spacebar/util";
+import {
+ AllowlistAcceptSchema,
+ AllowlistAddRequestSchema,
+ AllowlistedUser,
+ ApplicationAllowlistResponse,
+ ApplicationMembershipState,
+ PublicUserProjection,
+} from "@spacebar/schemas";
+
+const router: Router = Router({ mergeParams: true });
+
+router.post(
+ "/accept",
+ route({
+ requestBody: "AllowlistAcceptSchema",
+ responses: {
+ 200: {
+ body: "AllowlistedUser",
+ },
+ },
+ }),
+ async (req: Request, res: Response) => {
+ // TODO: stub
+ const app = await Application.findOneOrFail({
+ where: { id: req.params.id as string },
+ relations: { bot: true, owner: true },
+ select: {
+ owner: { id: true },
+ },
+ });
+
+ if (req.user_id != app.owner.id) throw DiscordApiErrors.ACTION_NOT_AUTHORIZED_ON_APPLICATION;
+
+ const body = req.body as AllowlistAcceptSchema;
+
+ return res.status(204).json({});
+ },
+);
+
+export default router;
diff --git a/src/schemas/api/developers/Application.ts b/src/schemas/api/developers/Application.ts
index b2ccd1e46..62fc818b8 100644
--- a/src/schemas/api/developers/Application.ts
+++ b/src/schemas/api/developers/Application.ts
@@ -344,3 +344,20 @@ export enum EmbeddedActivityReleasePhase {
SOFT_LAUNCH_MULTI_GEO = "soft_launch_multi_geo",
GLOBAL_LAUNCH = "global_launch",
}
+
+export type ApplicationAllowlistResponse = AllowlistedUser[];
+export interface AllowlistedUser {
+ user: PartialUser;
+ state: ApplicationMembershipState;
+}
+export enum ApplicationMembershipState {
+ INVITED = 1,
+ ACCEPTED = 2,
+}
+export interface AllowlistAddRequestSchema {
+ username: string;
+ discriminator?: string | null;
+}
+export interface AllowlistAcceptSchema {
+ token: string;
+}
diff --git a/src/util/config/types/subconfigurations/limits/ApplicationLimits.ts b/src/util/config/types/subconfigurations/limits/ApplicationLimits.ts
index 52d492607..40d692ba9 100644
--- a/src/util/config/types/subconfigurations/limits/ApplicationLimits.ts
+++ b/src/util/config/types/subconfigurations/limits/ApplicationLimits.ts
@@ -18,6 +18,7 @@
export class ApplicationLimits {
maxEmojis: number = 1000;
+ maxTesters: number = 50;
commandLimits: CommandLimits = new CommandLimits();
}