diff --git a/assets/openapi.json b/assets/openapi.json
index 9e9513185..e9c892ee2 100644
Binary files a/assets/openapi.json and b/assets/openapi.json differ
diff --git a/assets/schemas.json b/assets/schemas.json
index 5a8563684..2ac0c2d16 100644
Binary files a/assets/schemas.json and b/assets/schemas.json differ
diff --git a/src/api/routes_toplevel/_spacebar/api/v1/avatar_decorations/#id/index.ts b/src/api/routes_toplevel/_spacebar/api/v1/avatar_decorations/#id/index.ts
new file mode 100644
index 000000000..bf36ea792
--- /dev/null
+++ b/src/api/routes_toplevel/_spacebar/api/v1/avatar_decorations/#id/index.ts
@@ -0,0 +1,65 @@
+/*
+ Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
+ Copyright (C) 2026 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 { Router, Response, Request } from "express";
+import { route } from "@spacebar/api/middlewares";
+import { AvatarDecorations } from "@spacebar/database";
+import { PublicAvatarDecorationResponse, UpdateAvatarDecorationSchema } from "@spacebar/schemas/api/spacebar/AvatarDecorations";
+import { ApiError } from "@spacebar/util";
+
+const router = Router({ mergeParams: true });
+
+router.patch(
+ "/",
+ route({
+ spacebarOnly: true,
+ description: "Get available avatar decorations",
+ requestBody: "UpdateAvatarDecorationSchema",
+ responses: {
+ 200: {
+ body: "PublicAvatarDecorationListResponse",
+ },
+ },
+ }),
+ async (req: Request, res: Response) => {
+ const changes = req.body as UpdateAvatarDecorationSchema;
+ const deco = await AvatarDecorations.findOneOrFail({ where: { id: req.params.id as string } });
+
+ if (deco.uploader_id !== req.user_id) throw new ApiError("You do not have permission to update this avatar decoration", 0, 403);
+
+ if (changes.public !== undefined) deco.public = changes.public;
+ if (changes.allowed_user_ids) {
+ if (changes.allowed_user_ids.add) deco.allowed_user_ids.push(...changes.allowed_user_ids.add);
+ if (changes.allowed_user_ids.remove) deco.allowed_user_ids = deco.allowed_user_ids.filter((x) => !changes.allowed_user_ids!.remove!.includes(x));
+ }
+ if (changes.allowed_guild_ids) {
+ if (changes.allowed_guild_ids.add) deco.allowed_user_ids.push(...changes.allowed_guild_ids.add);
+ if (changes.allowed_guild_ids.remove) deco.allowed_user_ids = deco.allowed_user_ids.filter((x) => !changes.allowed_guild_ids!.remove!.includes(x));
+ }
+ if (changes.allowed_role_ids) {
+ if (changes.allowed_role_ids.add) deco.allowed_user_ids.push(...changes.allowed_role_ids.add);
+ if (changes.allowed_role_ids.remove) deco.allowed_user_ids = deco.allowed_user_ids.filter((x) => !changes.allowed_role_ids!.remove!.includes(x));
+ }
+
+ // TODO: remove avatar from users that no longer have access to them
+
+ res.json(deco.toPublicAvatarDecoration() satisfies PublicAvatarDecorationResponse);
+ },
+);
+
+export default router;
diff --git a/src/api/routes_toplevel/_spacebar/api/v1/avatar_decorations/index.ts b/src/api/routes_toplevel/_spacebar/api/v1/avatar_decorations/index.ts
new file mode 100644
index 000000000..e403ef6e9
--- /dev/null
+++ b/src/api/routes_toplevel/_spacebar/api/v1/avatar_decorations/index.ts
@@ -0,0 +1,64 @@
+/*
+ Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
+ Copyright (C) 2026 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 { Router, Response, Request } from "express";
+import { Raw } from "typeorm";
+import { route } from "@spacebar/api/middlewares";
+import { AvatarDecorations, Member } from "@spacebar/database";
+import { PublicAvatarDecorationListResponse } from "@spacebar/schemas/api/spacebar/AvatarDecorations";
+import { arrayDistinctBy } from "@spacebar/extensions";
+
+const router = Router({ mergeParams: true });
+
+router.get(
+ "/",
+ route({
+ spacebarOnly: true,
+ description: "Get available avatar decorations",
+ responses: {
+ 200: {
+ body: "PublicAvatarDecorationListResponse",
+ },
+ },
+ }),
+ async (req: Request, res: Response) => {
+ const memberships = await Member.find({ select: { guild_id: true, roles: { id: true } }, relations: { roles: true }, where: { id: req.user_id } });
+
+ const decos = (
+ await AvatarDecorations.find({
+ where: [
+ { approved: true, public: true },
+ { approved: true, uploader_id: req.user_id },
+ { approved: true, allowed_user_ids: Raw((columnAlias) => `${columnAlias} && ARRAY[:req_uid]::int8[]`, { req_uid: req.user_id }) },
+ { approved: true, allowed_guild_ids: Raw((columnAlias) => `${columnAlias} && :guild_ids`, { guild_ids: memberships.map((x) => x.guild_id) }) },
+ { approved: true, allowed_role_ids: Raw((columnAlias) => `${columnAlias} && :role_ids`, { role_ids: memberships.flatMap((x) => x.roles.map((r) => r.id)) }) },
+ ],
+ relations: {
+ uploader: true,
+ },
+ order: {
+ id: "DESC",
+ },
+ })
+ ).map((x) => x.toPublicAvatarDecoration({ available: true }));
+
+ res.json(arrayDistinctBy(decos, (d) => d.id) satisfies PublicAvatarDecorationListResponse);
+ },
+);
+
+export default router;
diff --git a/src/database/entities/AvatarDecoration.ts b/src/database/entities/AvatarDecoration.ts
index dedc8f3e6..695de8850 100644
--- a/src/database/entities/AvatarDecoration.ts
+++ b/src/database/entities/AvatarDecoration.ts
@@ -18,6 +18,7 @@
import { Column, Entity, Index, JoinColumn, ManyToOne, RelationId } from "typeorm";
import { AvatarDecorationData } from "@spacebar/schemas";
+import { PublicAvatarDecoration } from "@spacebar/schemas/api/spacebar/AvatarDecorations";
import { BaseClass } from "./BaseClass";
import { User } from "./User";
@@ -60,4 +61,14 @@ export class AvatarDecorations extends BaseClass {
expires_at: null,
} satisfies AvatarDecorationData;
}
+
+ toPublicAvatarDecoration(opts?: {available: boolean}): PublicAvatarDecoration {
+ return {
+ id: this.id,
+ approved: this.approved,
+ uploader: this.uploader.toPartialUser(),
+ public: this.public,
+ available: opts?.available ?? this.public
+ } satisfies PublicAvatarDecoration;
+ }
}
diff --git a/src/database/entities/index.ts b/src/database/entities/index.ts
index 79fceecca..429efb0a3 100644
--- a/src/database/entities/index.ts
+++ b/src/database/entities/index.ts
@@ -21,6 +21,7 @@ export * from "./ApplicationCommand";
export * from "./Attachment";
export * from "./AuditLog";
export * from "./AutomodRule";
+export * from "./AvatarDecoration";
export * from "./BackupCodes";
export * from "./Badge";
export * from "./Ban";
diff --git a/src/schemas/api/spacebar/AvatarDecorations.ts b/src/schemas/api/spacebar/AvatarDecorations.ts
new file mode 100644
index 000000000..5c3992744
--- /dev/null
+++ b/src/schemas/api/spacebar/AvatarDecorations.ts
@@ -0,0 +1,42 @@
+/*
+ Spacebar: A FOSS re-implementation and extension of the Discord.com backend.
+ Copyright (C) 2026 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 { PartialUser, Snowflake } from "@spacebar/schemas";
+
+export type PublicAvatarDecorationListResponse = PublicAvatarDecorationResponse[];
+export type PrivateAvatarDecorationListResponse = PrivateAvatarDecorationResponse[];
+export interface PublicAvatarDecorationResponse {
+ id: Snowflake;
+ approved: boolean;
+ uploader: PartialUser;
+ public: boolean;
+ available: boolean;
+}
+
+export interface PrivateAvatarDecorationResponse extends PublicAvatarDecorationResponse {
+ allowed_user_ids: Snowflake[];
+ allowed_guild_ids: Snowflake[];
+ allowed_role_ids: Snowflake[];
+}
+
+export interface UpdateAvatarDecorationSchema {
+ public?: boolean;
+ allowed_user_ids?: { add?: Snowflake[]; remove?: Snowflake[] };
+ allowed_guild_ids?: { add?: Snowflake[]; remove?: Snowflake[] };
+ allowed_role_ids?: { add?: Snowflake[]; remove?: Snowflake[] };
+}