diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index a95a39082..85f498e56 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -72,6 +72,7 @@ jobs:
package:
- types
- backend
+ - backend-nest
- frontend
steps:
- uses: actions/checkout@v6
@@ -87,6 +88,7 @@ jobs:
- name: Restore Next.js cache
uses: actions/cache@v5
+ if: ${{ matrix.package == 'frontend' }}
with:
path: ${{ github.workspace }}/packages/frontend/.next/cache
# Generate a new cache whenever packages or source files change
diff --git a/.gitignore b/.gitignore
index 831b3ad0c..315a031d8 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,45 +3,138 @@
**/backend/static/snapshots
**/backend/static/timelapse
**/backend/data/snapshots
+**/backend-nest/static/canvas
-# Generated code
-**/node_modules/
-**/.pnp
-.pnp.js
-.yarn/install-state.gz
-
-# Testing
-**/coverage
-
-# Next.js
-.next/
-out/
-
-# Production
-build/
+# Prisma generated files
+**/backend/src/client/core/generated/*
+**/backend/src/client/snapshots/generated/*
+**/backend/src/client/core/kysely/*
+**/backend-nest/src/common/database/generated/*
+**/backend-nest/src/common/database/kysely/*
-# Debug
+# Logs
+logs
+*.log
npm-debug.log*
+pnpm-debug.log*
yarn-debug.log*
yarn-error.log*
+lerna-debug.log*
-# Vercel
-.vercel
+# Diagnostic reports (https://nodejs.org/api/report.html)
+report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
+
+# Runtime data
+pids
+*.pid
+*.seed
+*.pid.lock
+
+# Directory for instrumented libs generated by jscoverage/JSCover
+lib-cov
+
+# Coverage directory used by tools like istanbul
+coverage
+*.lcov
+.nyc_output
+
+# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
+.grunt
+
+# Bower dependency directory (https://bower.io/)
+bower_components
+
+# node-waf configuration
+.lock-wscript
+
+# Compiled binary addons (https://nodejs.org/api/addons.html)
+build/Release
-# TypeScript
+# Dependency directories
+node_modules/
+jspm_packages/
+web_modules/
+.pnpm-store
+
+# Package manager / dependency state
+.yarn-integrity
+.pnp.*
+.pnp.js
+/.pnp
+.yarn/*
+
+# Yarn exceptions
+!.yarn/patches
+!.yarn/plugins
+!.yarn/releases
+!.yarn/sdks
+!.yarn/versions
+
+# TypeScript cache
*.tsbuildinfo
next-env.d.ts
-# Environment variables
+# Optional npm cache directory
+.npm
+
+# Optional editor / tool caches
+.eslintcache
+.stylelintcache
+.node_repl_history
+.vscode-test
+
+# Output of 'npm pack'
+*.tgz
+
+# Environment files
.env
.env.*
+.env*.local
+.env.development
+.env.test
+.env.production
!.env.example
-!.env.*.example
-# Prisma generated files
-**/backend/src/client/core/generated/*
-**/backend/src/client/snapshots/generated/*
-**/backend/src/client/core/kysely/*
+# Parcel / build caches
+.cache
+.cache/
+.parcel-cache
+.vite/
+
+# Framework build outputs
+.next/
+out/
+.nuxt
+.output
+.dist
+dist/
+build/
+tmp/
+.tmp
+.temp
+.svelte-kit/
+.vuepress/dist
+**/.vitepress/dist
+**/.vitepress/cache
+.docusaurus
+.serverless/
+.fusebox/
+.dynamodb/
+.firebase/
+
+# Misc
+*.pem
+.tern-port
+
+# Vite
+vite.config.js.timestamp-*
+vite.config.ts.timestamp-*
+
+# Testing
+/coverage
+
+# Vercel
+.vercel
################################################################################
diff --git a/.vscode/settings.json b/.vscode/settings.json
index 3e4447244..c8c1cee65 100644
--- a/.vscode/settings.json
+++ b/.vscode/settings.json
@@ -112,5 +112,6 @@
},
"[python]": {
"editor.defaultFormatter": "ms-python.black-formatter"
- }
+ },
+ "typescript.tsdk": "node_modules/typescript/lib"
}
diff --git a/biome.json b/biome.json
index d331d99cb..09a8869d9 100644
--- a/biome.json
+++ b/biome.json
@@ -23,5 +23,24 @@
"enabled": true,
"clientKind": "git",
"useIgnoreFile": true
- }
+ },
+ "overrides": [
+ {
+ "includes": [
+ "packages/backend-nest/**"
+ ],
+ "javascript": {
+ "parser": {
+ "unsafeParameterDecoratorsEnabled": true
+ }
+ },
+ "linter": {
+ "rules": {
+ "style": {
+ "useImportType": "off"
+ }
+ }
+ }
+ }
+ ]
}
diff --git a/packages/backend-nest/.swcrc b/packages/backend-nest/.swcrc
new file mode 100644
index 000000000..ed1b0039b
--- /dev/null
+++ b/packages/backend-nest/.swcrc
@@ -0,0 +1,13 @@
+{
+ "$schema": "https://swc.rs/schema.json",
+ "sourceMaps": true,
+ "module": { "type": "commonjs" },
+ "jsc": {
+ "target": "esnext",
+ "parser": { "syntax": "typescript", "decorators": true },
+ "transform": { "legacyDecorator": true, "decoratorMetadata": true },
+ "baseUrl": ".",
+ "paths": { "@/*": ["./src/*"] }
+ },
+ "minify": false
+}
diff --git a/packages/backend-nest/README.md b/packages/backend-nest/README.md
new file mode 100644
index 000000000..b742bdf72
--- /dev/null
+++ b/packages/backend-nest/README.md
@@ -0,0 +1,114 @@
+
+
+
+
+[circleci-image]:
+ https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
+[circleci-url]: https://circleci.com/gh/nestjs/nest
+
+ A progressive Node.js framework for building efficient and scalable server-side applications.
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+## Description
+
+[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository.
+
+## Project setup
+
+```bash
+$ pnpm install
+```
+
+## Compile and run the project
+
+```bash
+# development
+$ pnpm run start
+
+# watch mode
+$ pnpm run start:dev
+
+# production mode
+$ pnpm run start:prod
+```
+
+## Run tests
+
+```bash
+# unit tests (watch mode)
+$ pnpm run test
+
+# e2e tests
+$ pnpm run test:e2e
+```
+
+## Deployment
+
+When you're ready to deploy your NestJS application to production, there are
+some key steps you can take to ensure it runs as efficiently as possible. Check
+out the [deployment documentation](https://docs.nestjs.com/deployment) for more
+information.
+
+If you are looking for a cloud-based platform to deploy your NestJS application,
+check out [Mau](https://mau.nestjs.com), our official platform for deploying
+NestJS applications on AWS. Mau makes deployment straightforward and fast,
+requiring just a few simple steps:
+
+```bash
+$ pnpm install -g @nestjs/mau
+$ mau deploy
+```
+
+With Mau, you can deploy your application in just a few clicks, allowing you to
+focus on building features rather than managing infrastructure.
+
+## Resources
+
+Check out a few resources that may come in handy when working with NestJS:
+
+- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about
+ the framework.
+- For questions and support, please visit our
+ [Discord channel](https://discord.gg/G7Qnnhy).
+- To dive deeper and get more hands-on experience, check out our official video
+ [courses](https://courses.nestjs.com/).
+- Deploy your application to AWS with the help of
+ [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
+- Visualize your application graph and interact with the NestJS application in
+ real-time using [NestJS Devtools](https://devtools.nestjs.com).
+- Need help with your project (part-time to full-time)? Check out our official
+ [enterprise support](https://enterprise.nestjs.com).
+- To stay in the loop and get updates, follow us on
+ [X](https://x.com/nestframework) and
+ [LinkedIn](https://linkedin.com/company/nestjs).
+- Looking for a job, or have a job to offer? Check out our official
+ [Jobs board](https://jobs.nestjs.com).
+
+## Support
+
+Nest is an MIT-licensed open source project. It can grow thanks to the sponsors
+and support by the amazing backers. If you'd like to join them, please
+[read more here](https://docs.nestjs.com/support).
+
+## Stay in touch
+
+- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
+- Website - [https://nestjs.com](https://nestjs.com/)
+- Twitter - [@nestframework](https://twitter.com/nestframework)
+
+## License
+
+Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).
diff --git a/packages/backend-nest/nest-cli.json b/packages/backend-nest/nest-cli.json
new file mode 100644
index 000000000..579ca3540
--- /dev/null
+++ b/packages/backend-nest/nest-cli.json
@@ -0,0 +1,10 @@
+{
+ "$schema": "https://json.schemastore.org/nest-cli",
+ "collection": "@nestjs/schematics",
+ "sourceRoot": "src",
+ "compilerOptions": {
+ "deleteOutDir": true,
+ "builder": "swc",
+ "typeCheck": true
+ }
+}
diff --git a/packages/backend-nest/package.json b/packages/backend-nest/package.json
new file mode 100644
index 000000000..1b22f2700
--- /dev/null
+++ b/packages/backend-nest/package.json
@@ -0,0 +1,105 @@
+{
+ "name": "@blurple-canvas-web/backend-nest",
+ "version": "1.0.0",
+ "description": "API server for Blurple Canvas using NestJS",
+ "author": "",
+ "private": true,
+ "contributors": [
+ "Aaron Guo ",
+ "Emily Zou ",
+ "Henry Wang (http://henryh.wang)",
+ "Jasper Lai (https://lai.nz)",
+ "Josh Jeffers (https://pumbas.net)",
+ "Samuel Ou (https://sjou.dev)",
+ "Stijn van der Kolk (https://stijnvdkolk.dev)"
+ ],
+ "repository": {
+ "type": "git",
+ "url": "https://github.com/project-blurple/Canvas-Web.git",
+ "directory": "packages/backend-nest"
+ },
+ "license": "SEE LICENSE IN LICENSE.md",
+ "bugs": {
+ "url": "https://github.com/project-blurple/Canvas-Web/issues"
+ },
+ "engines": {
+ "node": ">=25",
+ "pnpm": ">=10.33.0"
+ },
+ "scripts": {
+ "build": "nest build",
+ "start": "nest start",
+ "dev": "nest start --watch",
+ "start:dev": "nest start --watch",
+ "start:debug": "nest start --debug --watch",
+ "start:prod": "node dist/main",
+ "postinstall": "prisma generate",
+ "format": "prettier --check --cache . --ignore-path ../../.gitignore",
+ "format:fix": "prettier --write --cache . --ignore-path ../../.gitignore",
+ "lint": "biome check --formatter-enabled=false .",
+ "lint:fix": "biome check --formatter-enabled=false --write .",
+ "check": "pnpm run format && pnpm run lint",
+ "check:fix": "pnpm run format:fix && pnpm run lint:fix",
+ "prisma:seed": "prisma db seed --",
+ "prisma:migrate": "prisma migrate deploy",
+ "test": "vitest",
+ "test:e2e": "vitest run --config ./vitest.config.e2e.ts"
+ },
+ "dependencies": {
+ "@blurple-canvas-web/types": "workspace:*",
+ "@dotenvx/dotenvx": "^1.61.0",
+ "@nestjs/common": "^11.0.1",
+ "@nestjs/config": "^4.0.4",
+ "@nestjs/core": "^11.0.1",
+ "@nestjs/event-emitter": "^3.1.0",
+ "@nestjs/platform-express": "^11.0.1",
+ "@nestjs/platform-socket.io": "11.1.24",
+ "@nestjs/swagger": "^11.4.4",
+ "@nestjs/websockets": "11.1.24",
+ "@prisma/adapter-pg": "^7.7.0",
+ "@prisma/client": "^7.7.0",
+ "@quixo3/prisma-session-store": "^3.1.19",
+ "discord-strategy": "^2.5.0",
+ "express-session": "^1.19.0",
+ "kysely": "^0.29.2",
+ "nestjs-zod": "^5.4.0",
+ "passport": "^0.7.0",
+ "passport-oauth2-refresh": "^2.2.0",
+ "prisma-extension-kysely": "^4.0.0",
+ "reflect-metadata": "^0.2.2",
+ "rxjs": "^7.8.1",
+ "sharp": "^0.34.5",
+ "socket.io": "4.8.3",
+ "zod": "^4.3.6"
+ },
+ "devDependencies": {
+ "@chax-at/transactional-prisma-testing": "^1.5.0",
+ "@nestjs/cli": "^11.0.0",
+ "@nestjs/schematics": "^11.0.0",
+ "@nestjs/testing": "^11.0.1",
+ "@swc/cli": "^0.7.7",
+ "@swc/core": "^1.15.40",
+ "@testcontainers/postgresql": "^11.14.0",
+ "@types/express": "^5.0.0",
+ "@types/express-session": "^1.19.0",
+ "@types/node": "^24.0.0",
+ "@types/passport": "^1.0.17",
+ "@types/passport-oauth2": "^1.8.0",
+ "@types/supertest": "^7.0.0",
+ "@vitest/coverage-v8": "^4.1.8",
+ "msw": "^2.14.6",
+ "prettier": "^3.4.2",
+ "prisma": "^7.7.0",
+ "prisma-kysely": "^3.1.0",
+ "socket.io-client": "4.8.3",
+ "source-map-support": "^0.5.21",
+ "supertest": "^7.0.0",
+ "ts-loader": "^9.5.2",
+ "ts-node": "^10.9.2",
+ "tsconfig-paths": "^4.2.0",
+ "tsx": "^4.20.0",
+ "typescript": "^6.0.3",
+ "unplugin-swc": "^1.5.9",
+ "vitest": "^4.1.5"
+ }
+}
diff --git a/packages/backend-nest/prisma.config.ts b/packages/backend-nest/prisma.config.ts
new file mode 100644
index 000000000..4109fe507
--- /dev/null
+++ b/packages/backend-nest/prisma.config.ts
@@ -0,0 +1,18 @@
+import dotenvx from "@dotenvx/dotenvx";
+import { defineConfig } from "prisma/config";
+
+dotenvx.config({ ignore: ["MISSING_ENV_FILE"], quiet: true });
+
+export default defineConfig({
+ schema: "src/common/database/prisma/schema.prisma",
+ views: {
+ path: "src/common/database/prisma/views",
+ },
+ migrations: {
+ path: "src/common/database/prisma/migrations",
+ seed: "tsx src/seed/index.ts",
+ },
+ datasource: {
+ url: process.env.DATABASE_URL,
+ },
+});
diff --git a/packages/backend-nest/src/app.module.ts b/packages/backend-nest/src/app.module.ts
new file mode 100644
index 000000000..51c7a56ae
--- /dev/null
+++ b/packages/backend-nest/src/app.module.ts
@@ -0,0 +1,45 @@
+import { Module } from "@nestjs/common";
+import { APP_FILTER, APP_INTERCEPTOR, APP_PIPE } from "@nestjs/core";
+import { ZodSerializerInterceptor } from "nestjs-zod";
+
+import { AuditModule } from "@/audit/audit.module";
+import { AuthModule } from "@/auth/auth.module";
+import { BlocklistModule } from "@/blocklist/blocklist.module";
+import { CanvasModule } from "@/canvas/canvas.module";
+import { ApiExceptionFilter } from "@/common/api-exception.filter";
+import { DatabaseModule } from "@/common/database/database.module";
+import { ZodValidationPipe } from "@/common/zod-validation.pipe";
+import { AppConfigModule } from "@/config/config.module";
+import { EventModule } from "@/event/event.module";
+import { FrameModule } from "@/frame/frame.module";
+import { HistoryModule } from "@/history/history.module";
+import { NoticeModule } from "@/notice/notice.module";
+import { PaletteModule } from "@/palette/palette.module";
+import { PixelModule } from "@/pixel/pixel.module";
+import { RealtimeModule } from "@/realtime/realtime.module";
+import { StatisticsModule } from "@/statistics/statistics.module";
+
+@Module({
+ imports: [
+ AppConfigModule,
+ DatabaseModule,
+ AuthModule,
+ AuditModule,
+ RealtimeModule,
+ EventModule,
+ CanvasModule,
+ FrameModule,
+ PixelModule,
+ NoticeModule,
+ BlocklistModule,
+ PaletteModule,
+ HistoryModule,
+ StatisticsModule,
+ ],
+ providers: [
+ { provide: APP_PIPE, useClass: ZodValidationPipe },
+ { provide: APP_INTERCEPTOR, useClass: ZodSerializerInterceptor },
+ { provide: APP_FILTER, useClass: ApiExceptionFilter },
+ ],
+})
+export class AppModule {}
diff --git a/packages/backend-nest/src/app.setup.ts b/packages/backend-nest/src/app.setup.ts
new file mode 100644
index 000000000..06d123010
--- /dev/null
+++ b/packages/backend-nest/src/app.setup.ts
@@ -0,0 +1,31 @@
+import "@/common/bigint-json";
+
+import { VersioningType } from "@nestjs/common";
+import type { NestExpressApplication } from "@nestjs/platform-express";
+import { configureSession } from "@/auth/session.setup";
+import { type AppConfig, appConfig } from "@/config/app.config";
+import { RealtimeIoAdapter } from "@/realtime/realtime-io.adapter";
+
+/**
+ * Process-level Express settings shared by `main.ts` and the e2e test harness,
+ * mirroring the old backend's `createApp()`.
+ */
+export function configureApp(
+ app: NestExpressApplication,
+): NestExpressApplication {
+ const { frontendUrl } = app.get(appConfig.KEY);
+
+ // The app runs behind a single proxy hop (Caddy) and reads client IPs
+ // from X-Forwarded-For.
+ app.set("trust proxy", 1);
+ app.enableCors({ origin: frontendUrl, credentials: true });
+
+ app.setGlobalPrefix("api");
+ app.enableVersioning({ type: VersioningType.URI, defaultVersion: "1" });
+
+ app.useWebSocketAdapter(new RealtimeIoAdapter(app));
+
+ configureSession(app);
+
+ return app;
+}
diff --git a/packages/backend-nest/src/audit/audit.controller.ts b/packages/backend-nest/src/audit/audit.controller.ts
new file mode 100644
index 000000000..4babd0654
--- /dev/null
+++ b/packages/backend-nest/src/audit/audit.controller.ts
@@ -0,0 +1,38 @@
+import {
+ AuditLogPageSchema,
+ AuditLogQueryModel,
+} from "@blurple-canvas-web/types";
+import { Controller, Get, Query } from "@nestjs/common";
+import { ApiOperation } from "@nestjs/swagger";
+import { createZodDto, ZodResponse } from "nestjs-zod";
+
+import { RequiresCanvasAdmin } from "@/auth/require-auth.decorator";
+import { AuditService } from "./audit.service";
+
+class AuditLogQueryDto extends createZodDto(AuditLogQueryModel) {}
+
+class AuditLogPageResponseDto extends createZodDto(AuditLogPageSchema) {}
+
+@Controller("audit-log")
+export class AuditController {
+ constructor(private readonly auditService: AuditService) {}
+
+ @Get()
+ @RequiresCanvasAdmin()
+ @ApiOperation({
+ summary: "Keyset-paginated audit log with actor/action/resource filters",
+ })
+ @ZodResponse({ type: AuditLogPageResponseDto })
+ async getAuditLog(@Query() query: AuditLogQueryDto) {
+ return await this.auditService.getAuditLog({
+ actorId: query.actorId,
+ action: query.action,
+ resourceType: query.resourceType,
+ resourceId: query.resourceId,
+ from: query.from,
+ to: query.to,
+ limit: query.limit,
+ cursor: query.cursor,
+ });
+ }
+}
diff --git a/packages/backend-nest/src/audit/audit.decorator.ts b/packages/backend-nest/src/audit/audit.decorator.ts
new file mode 100644
index 000000000..5829519ee
--- /dev/null
+++ b/packages/backend-nest/src/audit/audit.decorator.ts
@@ -0,0 +1,49 @@
+import type { AuditAction, AuditActorRole } from "@blurple-canvas-web/types";
+import {
+ createParamDecorator,
+ type ExecutionContext,
+ SetMetadata,
+} from "@nestjs/common";
+import type { Request } from "express";
+
+export const AUDIT_ACTOR_ROLE = "audit:actorRole";
+export const STAGED_AUDIT_ENTRY = Symbol("stagedAuditEntry");
+
+export interface AuditEntryInput {
+ action: AuditAction;
+ resourceId?: string | number | bigint | null;
+ /**
+ * Type is set to unknown to allow for any type of metadata to be passed.
+ */
+ metadata?: unknown;
+}
+
+export interface Audit {
+ record(entry: AuditEntryInput): void;
+}
+
+interface AuditCapableRequest extends Request {
+ [STAGED_AUDIT_ENTRY]?: AuditEntryInput;
+}
+
+export const Audit = createParamDecorator(
+ (_data: unknown, context: ExecutionContext): Audit => {
+ const request = context.switchToHttp().getRequest();
+
+ return {
+ record(entry) {
+ request[STAGED_AUDIT_ENTRY] = entry;
+ },
+ };
+ },
+);
+
+export function getStagedAuditEntry(
+ request: Request,
+): AuditEntryInput | undefined {
+ return (request as AuditCapableRequest)[STAGED_AUDIT_ENTRY];
+}
+
+export function setActorRole(role: AuditActorRole) {
+ return SetMetadata(AUDIT_ACTOR_ROLE, role);
+}
diff --git a/packages/backend-nest/src/audit/audit.events.ts b/packages/backend-nest/src/audit/audit.events.ts
new file mode 100644
index 000000000..ee10988aa
--- /dev/null
+++ b/packages/backend-nest/src/audit/audit.events.ts
@@ -0,0 +1,11 @@
+import type { AuditAction, AuditActorRole } from "@blurple-canvas-web/types";
+
+export const AUDIT_EVENT = "audit.record";
+
+export interface AuditEventPayload {
+ actorId: string;
+ actorRole: AuditActorRole;
+ action: AuditAction;
+ resourceId: string | null;
+ metadata?: unknown;
+}
diff --git a/packages/backend-nest/src/audit/audit.interceptor.spec.ts b/packages/backend-nest/src/audit/audit.interceptor.spec.ts
new file mode 100644
index 000000000..5c65f7c11
--- /dev/null
+++ b/packages/backend-nest/src/audit/audit.interceptor.spec.ts
@@ -0,0 +1,123 @@
+import type { AuditActorRole } from "@blurple-canvas-web/types";
+import type { CallHandler, ExecutionContext } from "@nestjs/common";
+import type { Reflector } from "@nestjs/core";
+import type { EventEmitter2 } from "@nestjs/event-emitter";
+import type { Request } from "express";
+import { firstValueFrom, of } from "rxjs";
+
+import { type AuditEntryInput, STAGED_AUDIT_ENTRY } from "./audit.decorator";
+import { AUDIT_EVENT } from "./audit.events";
+import { AuditInterceptor } from "./audit.interceptor";
+
+interface HarnessOptions {
+ stagedEntry?: AuditEntryInput;
+ actorRole?: AuditActorRole;
+ user?: { id?: string };
+ result?: unknown;
+}
+
+function createHarness(options: HarnessOptions) {
+ const { stagedEntry, user = { id: "1" }, result } = options;
+ // Distinguish "no role" (explicit undefined) from "role omitted" (default).
+ const actorRole = "actorRole" in options ? options.actorRole : "admin";
+
+ const emit = vi.fn();
+ const eventEmitter = { emit } as unknown as EventEmitter2;
+ const reflector = {
+ getAllAndOverride: vi.fn().mockReturnValue(actorRole),
+ } as unknown as Reflector;
+
+ const request = { user } as unknown as Request;
+ if (stagedEntry) {
+ (request as unknown as Record)[STAGED_AUDIT_ENTRY] =
+ stagedEntry;
+ }
+
+ const context = {
+ getHandler: () => () => undefined,
+ getClass: () => class {},
+ switchToHttp: () => ({ getRequest: () => request }),
+ } as unknown as ExecutionContext;
+
+ const next: CallHandler = { handle: () => of(result) };
+ const interceptor = new AuditInterceptor(reflector, eventEmitter);
+
+ return { interceptor, context, next, emit };
+}
+
+async function run(harness: ReturnType) {
+ return await firstValueFrom(
+ harness.interceptor.intercept(harness.context, harness.next),
+ );
+}
+
+describe("AuditInterceptor", () => {
+ it("passes the response through unchanged", async () => {
+ const harness = createHarness({ result: { id: 7 } });
+
+ await expect(run(harness)).resolves.toEqual({ id: 7 });
+ });
+
+ it("does not emit when the handler stages nothing", async () => {
+ const harness = createHarness({ result: { id: 7 } });
+
+ await run(harness);
+
+ expect(harness.emit).not.toHaveBeenCalled();
+ });
+
+ it("enriches a staged entry with the actor and role", async () => {
+ const harness = createHarness({
+ stagedEntry: {
+ action: "notice.create",
+ resourceId: 42,
+ metadata: { header: "hi" },
+ },
+ });
+
+ await run(harness);
+
+ expect(harness.emit).toHaveBeenCalledWith(AUDIT_EVENT, {
+ actorId: "1",
+ actorRole: "admin",
+ action: "notice.create",
+ resourceId: "42",
+ metadata: { header: "hi" },
+ });
+ });
+
+ it("records a null resource id when the entry omits it", async () => {
+ const harness = createHarness({
+ stagedEntry: { action: "notice.create" },
+ });
+
+ await run(harness);
+
+ expect(harness.emit).toHaveBeenCalledWith(
+ AUDIT_EVENT,
+ expect.objectContaining({ resourceId: null, metadata: undefined }),
+ );
+ });
+
+ it("does not emit for an unauthenticated request", async () => {
+ const harness = createHarness({
+ stagedEntry: { action: "notice.create" },
+ user: { id: undefined },
+ });
+
+ await run(harness);
+
+ expect(harness.emit).not.toHaveBeenCalled();
+ });
+
+ it("does not emit when the route has no actor role", async () => {
+ const harness = createHarness({
+ stagedEntry: { action: "notice.create" },
+ actorRole: undefined,
+ });
+
+ await run(harness);
+
+ expect(harness.emit).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/backend-nest/src/audit/audit.interceptor.ts b/packages/backend-nest/src/audit/audit.interceptor.ts
new file mode 100644
index 000000000..d975655cd
--- /dev/null
+++ b/packages/backend-nest/src/audit/audit.interceptor.ts
@@ -0,0 +1,68 @@
+import type { AuditActorRole } from "@blurple-canvas-web/types";
+import {
+ type CallHandler,
+ type ExecutionContext,
+ Injectable,
+ Logger,
+ type NestInterceptor,
+} from "@nestjs/common";
+import { Reflector } from "@nestjs/core";
+import { EventEmitter2 } from "@nestjs/event-emitter";
+import type { Request } from "express";
+import { type Observable, tap } from "rxjs";
+
+import { AUDIT_ACTOR_ROLE, getStagedAuditEntry } from "./audit.decorator";
+import { AUDIT_EVENT, type AuditEventPayload } from "./audit.events";
+
+@Injectable()
+export class AuditInterceptor implements NestInterceptor {
+ private readonly logger = new Logger(AuditInterceptor.name);
+
+ constructor(
+ private readonly reflector: Reflector,
+ private readonly eventEmitter: EventEmitter2,
+ ) {}
+
+ intercept(context: ExecutionContext, next: CallHandler): Observable {
+ const request = context.switchToHttp().getRequest();
+
+ return next.handle().pipe(tap(() => this.flush(context, request)));
+ }
+
+ private flush(context: ExecutionContext, request: Request): void {
+ const entry = getStagedAuditEntry(request);
+ if (!entry) return;
+
+ const user = request.user as { id?: string } | undefined;
+ if (!user?.id) {
+ this.logger.error(
+ `Audit entry "${entry.action}" has no authenticated actor; skipping.`,
+ );
+ return;
+ }
+
+ const actorRole = this.reflector.getAllAndOverride<
+ AuditActorRole | undefined
+ >(AUDIT_ACTOR_ROLE, [context.getHandler(), context.getClass()]);
+ if (!actorRole) {
+ this.logger.error(
+ `Audit entry "${entry.action}" has no actor role; skipping. ` +
+ "Did you forget @RequiresCanvasAdmin()/@RequiresCanvasModerator()?",
+ );
+ return;
+ }
+
+ const payload: AuditEventPayload = {
+ actorId: user.id,
+ actorRole,
+ action: entry.action,
+ resourceId:
+ entry.resourceId === undefined || entry.resourceId === null ?
+ null
+ : String(entry.resourceId),
+ metadata: entry.metadata,
+ };
+
+ this.eventEmitter.emit(AUDIT_EVENT, payload);
+ }
+}
diff --git a/packages/backend-nest/src/audit/audit.module.ts b/packages/backend-nest/src/audit/audit.module.ts
new file mode 100644
index 000000000..3ef32d69e
--- /dev/null
+++ b/packages/backend-nest/src/audit/audit.module.ts
@@ -0,0 +1,21 @@
+import { Module } from "@nestjs/common";
+import { APP_INTERCEPTOR } from "@nestjs/core";
+import { EventEmitterModule } from "@nestjs/event-emitter";
+
+import { CanvasAdminGuard } from "@/auth/guards/canvas-admin.guard";
+import { DiscordModule } from "@/discord/discord.module";
+import { AuditController } from "./audit.controller";
+import { AuditInterceptor } from "./audit.interceptor";
+import { AuditService } from "./audit.service";
+
+@Module({
+ imports: [EventEmitterModule.forRoot(), DiscordModule],
+ controllers: [AuditController],
+ providers: [
+ AuditService,
+ CanvasAdminGuard,
+ { provide: APP_INTERCEPTOR, useClass: AuditInterceptor },
+ ],
+ exports: [AuditService],
+})
+export class AuditModule {}
diff --git a/packages/backend-nest/src/audit/audit.service.spec.ts b/packages/backend-nest/src/audit/audit.service.spec.ts
new file mode 100644
index 000000000..44b973369
--- /dev/null
+++ b/packages/backend-nest/src/audit/audit.service.spec.ts
@@ -0,0 +1,238 @@
+import { Test, type TestingModule } from "@nestjs/testing";
+
+import { DatabaseModule } from "@/common/database/database.module";
+import { AppConfigModule } from "@/config/config.module";
+import { testPrisma as prisma } from "@/test/database";
+import { seedUsers } from "@/test/seed/users";
+import { AuditService } from "./audit.service";
+
+async function insert(
+ actorId: bigint,
+ role: "admin" | "moderator",
+ action: string,
+ overrides: Partial<{
+ resourceType: string;
+ resourceId: string | number;
+ metadata: unknown;
+ createdAt: Date;
+ }> = {},
+) {
+ await prisma.auditLog.create({
+ data: {
+ actorId,
+ actorRole: role,
+ action,
+ resourceType: overrides.resourceType ?? null,
+ resourceId:
+ overrides.resourceId === undefined ?
+ null
+ : String(overrides.resourceId),
+ metadata: overrides.metadata as never,
+ createdAt: overrides.createdAt,
+ },
+ });
+}
+
+describe("AuditService", () => {
+ let moduleRef: TestingModule;
+ let service: AuditService;
+
+ beforeAll(async () => {
+ moduleRef = await Test.createTestingModule({
+ imports: [AppConfigModule, DatabaseModule],
+ providers: [AuditService],
+ }).compile();
+ await moduleRef.init();
+
+ service = moduleRef.get(AuditService);
+ });
+
+ afterAll(async () => {
+ await moduleRef.close();
+ });
+
+ beforeEach(async () => {
+ await seedUsers();
+ });
+
+ describe("handleAuditEvent", () => {
+ it("writes a row from the emitted payload", async () => {
+ await service.handleAuditEvent({
+ actorId: "1",
+ actorRole: "moderator",
+ action: "notice.create",
+ resourceId: "42",
+ metadata: { hello: "world" },
+ });
+
+ const entries = await prisma.auditLog.findMany({});
+ expect(entries).toHaveLength(1);
+ expect(entries[0]).toMatchObject({
+ actorId: 1n,
+ actorRole: "moderator",
+ action: "notice.create",
+ resourceType: "notice",
+ resourceId: "42",
+ metadata: { hello: "world" },
+ });
+ });
+
+ it("derives resourceType from the action prefix", async () => {
+ await service.handleAuditEvent({
+ actorId: "9",
+ actorRole: "admin",
+ action: "color.create",
+ resourceId: "abc",
+ });
+
+ const [entry] = await prisma.auditLog.findMany({});
+ expect(entry.resourceType).toBe("color");
+ });
+
+ it("swallows errors so the listener never throws", async () => {
+ await expect(
+ service.handleAuditEvent({
+ actorId: "not-a-number",
+ actorRole: "moderator",
+ action: "notice.create",
+ resourceId: null,
+ }),
+ ).resolves.toBeUndefined();
+ const entries = await prisma.auditLog.findMany({});
+ expect(entries).toEqual([]);
+ });
+ });
+
+ describe("getAuditLog", () => {
+ beforeEach(async () => {
+ await insert(1n, "moderator", "blocklist.add", {
+ resourceType: "blocklist",
+ createdAt: new Date("2026-05-22T10:00:00Z"),
+ });
+ await insert(1n, "moderator", "blocklist.remove", {
+ resourceType: "blocklist",
+ createdAt: new Date("2026-05-22T10:01:00Z"),
+ });
+ await insert(9n, "admin", "notice.create", {
+ resourceType: "notice",
+ resourceId: 1,
+ createdAt: new Date("2026-05-22T10:02:00Z"),
+ });
+ });
+
+ it("returns entries newest-first", async () => {
+ const page = await service.getAuditLog({});
+ expect(page.entries.map((entry) => entry.action)).toEqual([
+ "notice.create",
+ "blocklist.remove",
+ "blocklist.add",
+ ]);
+ expect(page.nextCursor).toBeNull();
+ });
+
+ it("filters by exact action", async () => {
+ const page = await service.getAuditLog({ action: "blocklist.add" });
+ expect(page.entries).toHaveLength(1);
+ expect(page.entries[0].action).toBe("blocklist.add");
+ });
+
+ it("filters by action prefix when the value ends with a dot", async () => {
+ const page = await service.getAuditLog({ action: "blocklist." });
+ expect(page.entries.map((entry) => entry.action).sort()).toEqual([
+ "blocklist.add",
+ "blocklist.remove",
+ ]);
+ });
+
+ it("filters by actor id", async () => {
+ const page = await service.getAuditLog({ actorId: "9" });
+ expect(page.entries).toHaveLength(1);
+ expect(page.entries[0].actorId).toBe("9");
+ });
+
+ it("filters by resource type and id", async () => {
+ const page = await service.getAuditLog({
+ resourceType: "notice",
+ resourceId: "1",
+ });
+ expect(page.entries).toHaveLength(1);
+ expect(page.entries[0].action).toBe("notice.create");
+ });
+
+ it("joins the actor's Discord profile when present", async () => {
+ await prisma.discordUserProfile.create({
+ data: {
+ userId: 9n,
+ username: "admin_user",
+ profilePictureUrl: "https://example.com/admin.png",
+ },
+ });
+
+ const page = await service.getAuditLog({ actorId: "9" });
+ expect(page.entries[0].actorUsername).toBe("admin_user");
+ expect(page.entries[0].actorProfilePictureUrl).toBe(
+ "https://example.com/admin.png",
+ );
+ });
+
+ it("returns an empty page for an unparseable actor id", async () => {
+ const page = await service.getAuditLog({ actorId: "abc" });
+ expect(page.entries).toEqual([]);
+ expect(page.nextCursor).toBeNull();
+ });
+
+ it("paginates with a cursor", async () => {
+ const first = await service.getAuditLog({ limit: 2 });
+ expect(first.entries).toHaveLength(2);
+ expect(first.nextCursor).not.toBeNull();
+
+ const second = await service.getAuditLog({
+ limit: 2,
+ cursor: first.nextCursor ?? undefined,
+ });
+ expect(second.entries).toHaveLength(1);
+ expect(second.nextCursor).toBeNull();
+
+ const all = [...first.entries, ...second.entries].map(
+ (entry) => entry.action,
+ );
+ expect(all).toEqual([
+ "notice.create",
+ "blocklist.remove",
+ "blocklist.add",
+ ]);
+ });
+
+ it("returns an empty page for an invalid cursor", async () => {
+ const page = await service.getAuditLog({ cursor: "not-base64-json" });
+ expect(page.entries).toEqual([]);
+ expect(page.nextCursor).toBeNull();
+ });
+
+ it("paginates entries that share an identical timestamp without skipping", async () => {
+ // Two entries in the same instant plus an older one; the id tie-break
+ // must keep them all visible across single-row pages. Within the shared
+ // instant the later-inserted (larger id) row sorts first.
+ const sharedInstant = new Date("2026-06-01T12:00:00.000Z");
+ await prisma.auditLog.deleteMany({});
+ await insert(1n, "admin", "notice.create", { createdAt: sharedInstant });
+ await insert(1n, "admin", "notice.update", { createdAt: sharedInstant });
+ await insert(1n, "admin", "notice.delete", {
+ createdAt: new Date("2026-06-01T11:59:00.000Z"),
+ });
+
+ const seen: string[] = [];
+ let cursor: string | undefined;
+ for (let i = 0; i < 3; i++) {
+ const page = await service.getAuditLog({ limit: 1, cursor });
+ expect(page.entries).toHaveLength(1);
+ seen.push(page.entries[0].action);
+ if (!page.nextCursor) break;
+ cursor = page.nextCursor;
+ }
+
+ // Newest-first (id DESC tie-break), and every entry appears exactly once.
+ expect(seen).toEqual(["notice.update", "notice.create", "notice.delete"]);
+ });
+ });
+});
diff --git a/packages/backend-nest/src/audit/audit.service.ts b/packages/backend-nest/src/audit/audit.service.ts
new file mode 100644
index 000000000..f0c42bf9b
--- /dev/null
+++ b/packages/backend-nest/src/audit/audit.service.ts
@@ -0,0 +1,215 @@
+import type {
+ AuditActorRole,
+ AuditLogEntry,
+ AuditLogPage,
+} from "@blurple-canvas-web/types";
+import { Injectable, Logger } from "@nestjs/common";
+import { OnEvent } from "@nestjs/event-emitter";
+
+import { Prisma } from "@/common/database/prisma.client";
+import { PrismaService } from "@/common/database/prisma.service";
+import { AUDIT_EVENT, type AuditEventPayload } from "./audit.events";
+
+export interface GetAuditLogParams {
+ actorId?: string;
+ action?: string;
+ resourceType?: string;
+ resourceId?: string;
+ from?: Date;
+ to?: Date;
+ limit?: number;
+ cursor?: string;
+}
+
+interface CursorPayload {
+ createdAt: string;
+ id: string;
+}
+
+const DEFAULT_LIMIT = 50;
+const MAX_LIMIT = 200;
+
+@Injectable()
+export class AuditService {
+ private readonly logger = new Logger(AuditService.name);
+
+ constructor(private readonly prisma: PrismaService) {}
+
+ /**
+ * Runs separately from the request, so a logging failure never affects the user-facing operation.
+ * `resourceType` is derived from the action prefix.
+ */
+ @OnEvent(AUDIT_EVENT, { async: true })
+ async handleAuditEvent(payload: AuditEventPayload): Promise {
+ const resourceType = payload.action.split(".")[0];
+
+ try {
+ await this.prisma.auditLog.create({
+ data: {
+ actorId: BigInt(payload.actorId),
+ actorRole: payload.actorRole,
+ action: payload.action,
+ resourceType,
+ resourceId: payload.resourceId,
+ metadata: payload.metadata as Prisma.InputJsonValue | undefined,
+ },
+ });
+ } catch (error) {
+ this.logger.error("Audit log write failed", {
+ action: payload.action,
+ error,
+ });
+ }
+ }
+
+ async getAuditLog(params: GetAuditLogParams = {}): Promise {
+ const limit = Math.min(
+ Math.max(params.limit ?? DEFAULT_LIMIT, 1),
+ MAX_LIMIT,
+ );
+
+ const where = this.buildAuditLogWhere(params);
+ if (where === null) {
+ return { entries: [], nextCursor: null };
+ }
+
+ const rows = await this.prisma.auditLog.findMany({
+ where,
+ orderBy: [{ createdAt: "desc" }, { id: "desc" }],
+ take: limit + 1,
+ include: {
+ actor: {
+ select: {
+ discordUserProfile: {
+ select: {
+ username: true,
+ profilePictureUrl: true,
+ },
+ },
+ },
+ },
+ },
+ });
+
+ const hasMore = rows.length > limit;
+ const visible = hasMore ? rows.slice(0, limit) : rows;
+
+ const entries = visible.map((row) => ({
+ id: row.id.toString(),
+ createdAt: row.createdAt.toISOString(),
+ actorId: row.actorId.toString(),
+ actorRole: row.actorRole as AuditActorRole,
+ actorUsername: row.actor.discordUserProfile?.username ?? null,
+ actorProfilePictureUrl:
+ row.actor.discordUserProfile?.profilePictureUrl ?? null,
+ action: row.action,
+ resourceType: row.resourceType ?? null,
+ resourceId: row.resourceId ?? null,
+ metadata: row.metadata ?? null,
+ }));
+
+ const nextCursor =
+ hasMore ? this.encodeCursor(visible[visible.length - 1]) : null;
+
+ return { entries, nextCursor };
+ }
+
+ private encodeCursor(row: { createdAt: Date; id: bigint }): string {
+ const payload: CursorPayload = {
+ createdAt: row.createdAt.toISOString(),
+ id: row.id.toString(),
+ };
+ return Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
+ }
+
+ private decodeCursor(cursor: string): CursorPayload | null {
+ try {
+ const json = Buffer.from(cursor, "base64url").toString("utf8");
+ const parsed = JSON.parse(json) as CursorPayload;
+ if (
+ typeof parsed.createdAt !== "string" ||
+ typeof parsed.id !== "string"
+ ) {
+ return null;
+ }
+ return parsed;
+ } catch {
+ return null;
+ }
+ }
+
+ private applyActorFilter(
+ where: Prisma.AuditLogWhereInput,
+ actorId: string,
+ ): boolean {
+ try {
+ where.actorId = BigInt(actorId);
+ return true;
+ } catch {
+ return false;
+ }
+ }
+
+ private applyCursorFilter(
+ where: Prisma.AuditLogWhereInput,
+ cursor: string,
+ ): boolean {
+ const decoded = this.decodeCursor(cursor);
+ if (!decoded) return false;
+ let cursorId: bigint;
+ try {
+ cursorId = BigInt(decoded.id);
+ } catch {
+ return false;
+ }
+ const cursorDate = new Date(decoded.createdAt);
+ if (Number.isNaN(cursorDate.getTime())) return false;
+ // Keyset pagination over (created_at DESC, id DESC). `created_at` is stored
+ // at microsecond precision, but the cursor round-trips through a
+ // millisecond-precision JS Date. Matching the whole-millisecond bucket
+ // (instead of exact equality) avoids skipping rows that share the cursor's
+ // millisecond but differ in sub-millisecond microseconds.
+ const cursorUpperBound = new Date(cursorDate.getTime() + 1);
+ where.OR = [
+ { createdAt: { lt: cursorDate } },
+ {
+ createdAt: { gte: cursorDate, lt: cursorUpperBound },
+ id: { lt: cursorId },
+ },
+ ];
+ return true;
+ }
+
+ private buildAuditLogWhere(
+ params: GetAuditLogParams,
+ ): Prisma.AuditLogWhereInput | null {
+ const where: Prisma.AuditLogWhereInput = {};
+
+ if (params.actorId && !this.applyActorFilter(where, params.actorId)) {
+ return null;
+ }
+ if (params.action) {
+ where.action =
+ params.action.endsWith(".") ?
+ { startsWith: params.action }
+ : params.action;
+ }
+ if (params.resourceType) {
+ where.resourceType = params.resourceType;
+ }
+ if (params.resourceId) {
+ where.resourceId = params.resourceId;
+ }
+ if (params.from || params.to) {
+ where.createdAt = {
+ ...(params.from ? { gte: params.from } : {}),
+ ...(params.to ? { lte: params.to } : {}),
+ };
+ }
+ if (params.cursor && !this.applyCursorFilter(where, params.cursor)) {
+ return null;
+ }
+
+ return where;
+ }
+}
diff --git a/packages/backend-nest/src/auth/auth.module.ts b/packages/backend-nest/src/auth/auth.module.ts
new file mode 100644
index 000000000..95ef70268
--- /dev/null
+++ b/packages/backend-nest/src/auth/auth.module.ts
@@ -0,0 +1,14 @@
+import { Module } from "@nestjs/common";
+
+import { DiscordController } from "@/auth/discord.controller";
+import { DiscordStrategyService } from "@/auth/discord.strategy";
+import { SessionStoreService } from "@/auth/session-store.service";
+import { DiscordModule } from "@/discord/discord.module";
+
+@Module({
+ imports: [DiscordModule],
+ controllers: [DiscordController],
+ providers: [SessionStoreService, DiscordStrategyService],
+ exports: [SessionStoreService],
+})
+export class AuthModule {}
diff --git a/packages/backend-nest/src/auth/current-user.decorator.ts b/packages/backend-nest/src/auth/current-user.decorator.ts
new file mode 100644
index 000000000..c0391788e
--- /dev/null
+++ b/packages/backend-nest/src/auth/current-user.decorator.ts
@@ -0,0 +1,17 @@
+import { DiscordUserProfileSchema } from "@blurple-canvas-web/types";
+import { createParamDecorator, type ExecutionContext } from "@nestjs/common";
+import type { Request } from "express";
+import { createZodDto } from "nestjs-zod";
+
+/**
+ * Injects the authenticated `req.user`. Custom param decorators run through
+ * the global strict Zod pipe, so parameters must be typed as
+ * `CurrentUserDto`. Use behind `LoggedInGuard` (or another guard that
+ * guarantees `req.user`), since an absent user fails DTO validation.
+ */
+export const CurrentUser = createParamDecorator(
+ (_data: unknown, context: ExecutionContext) =>
+ context.switchToHttp().getRequest().user,
+);
+
+export class CurrentUserDto extends createZodDto(DiscordUserProfileSchema) {}
diff --git a/packages/backend-nest/src/auth/discord.controller.ts b/packages/backend-nest/src/auth/discord.controller.ts
new file mode 100644
index 000000000..c5bb7659a
--- /dev/null
+++ b/packages/backend-nest/src/auth/discord.controller.ts
@@ -0,0 +1,237 @@
+import {
+ DiscordSnowflakeSchema,
+ type DiscordUserProfile,
+ GuildDataSchema,
+} from "@blurple-canvas-web/types";
+import {
+ Controller,
+ Get,
+ HttpCode,
+ HttpStatus,
+ Inject,
+ Logger,
+ Param,
+ Post,
+ Req,
+ Res,
+} from "@nestjs/common";
+import {
+ ApiFoundResponse,
+ ApiNoContentResponse,
+ ApiOperation,
+} from "@nestjs/swagger";
+import type { Request, Response } from "express";
+import type { SessionData } from "express-session";
+import { createZodDto, ZodResponse } from "nestjs-zod";
+import passport from "passport";
+import { z } from "zod";
+
+import { RequiresLogin } from "@/auth/require-auth.decorator";
+import { UnauthorizedError } from "@/common/errors/unauthorized.error";
+import { type AppConfig, appConfig } from "@/config/app.config";
+import { type SessionConfig, sessionConfig } from "@/config/session.config";
+import { DISCORD_STRATEGY_NAME } from "@/discord/discord.constants";
+import { DiscordGuildService } from "@/discord/discord-guild.service";
+import { DiscordProfileService } from "@/discord/discord-profile.service";
+import { DiscordTokenService } from "@/discord/discord-token.service";
+
+class GuildIdParamsDto extends createZodDto(
+ z.object({ guildId: DiscordSnowflakeSchema }),
+) {}
+
+class GuildPermissionsResponseDto extends createZodDto(
+ z.object({ administrator: z.boolean(), manage_guild: z.boolean() }),
+) {}
+
+const GuildsResponseSchema = z.object({
+ guilds: z.record(z.string(), GuildDataSchema),
+});
+
+class GuildsResponseDto extends createZodDto(GuildsResponseSchema) {}
+
+@Controller("discord")
+export class DiscordController {
+ private readonly logger = new Logger(DiscordController.name);
+
+ constructor(
+ @Inject(appConfig.KEY) private readonly appCfg: AppConfig,
+ @Inject(sessionConfig.KEY) private readonly sessionCfg: SessionConfig,
+ private readonly discordProfileService: DiscordProfileService,
+ private readonly discordGuildService: DiscordGuildService,
+ private readonly discordTokenService: DiscordTokenService,
+ ) {}
+
+ @Get()
+ @HttpCode(HttpStatus.FOUND)
+ @ApiOperation({
+ summary: "Log in with Discord",
+ description:
+ "Starts the Discord OAuth flow. Open this URL in a browser (not via " +
+ "“Try it out”) to log in and receive a session cookie.",
+ })
+ @ApiFoundResponse({ description: "Redirect to Discord's consent screen" })
+ async login(@Req() req: Request, @Res() res: Response): Promise {
+ await this.authenticate(req, res);
+ }
+
+ @Get("callback")
+ @HttpCode(HttpStatus.FOUND)
+ @ApiOperation({
+ summary: "Discord OAuth callback",
+ description:
+ "Completes the OAuth flow: stores the Discord tokens in the session, " +
+ "sets the `connect.sid` and `profile` cookies, and redirects to the " +
+ "frontend. Called by Discord, not directly.",
+ })
+ @ApiFoundResponse({
+ description: "Redirect to the frontend (or its sign-in page on failure)",
+ })
+ async callback(@Req() req: Request, @Res() res: Response): Promise {
+ await this.authenticate(req, res, {
+ failureRedirect: `${this.appCfg.frontendUrl}/signin`,
+ });
+
+ // Passport already responded (e.g. redirected to the failure page).
+ if (res.headersSent) return;
+
+ if (!req.user) {
+ throw new UnauthorizedError("User is not authenticated");
+ }
+ const discordProfile: DiscordUserProfile = req.user;
+ const authInfo = req.authInfo as Partial | undefined;
+
+ if (authInfo?.discordAccessToken) {
+ req.session.discordAccessToken = authInfo.discordAccessToken;
+ req.session.discordRefreshToken = authInfo.discordRefreshToken;
+ req.session.discordTokenExpiresAt = authInfo.discordTokenExpiresAt;
+ req.session.discordTokenLifetimeMs = authInfo.discordTokenLifetimeMs;
+ req.session.discordGuildFlags = authInfo.discordGuildFlags;
+ req.session.discordGuildFlagsFetchedAt =
+ authInfo.discordGuildFlagsFetchedAt ?? Date.now();
+ }
+
+ res.cookie("profile", JSON.stringify(discordProfile), {
+ httpOnly: false, // Allow the frontend to read the cookie
+ secure: this.sessionCfg.secureCookies,
+ });
+
+ await this.discordProfileService.saveDiscordProfile(discordProfile);
+
+ res.redirect(this.appCfg.frontendUrl);
+
+ await this.discordGuildService.syncDiscordGuildRecords(
+ authInfo?.discordGuildFlags,
+ );
+ }
+
+ @Get("guilds/:guildId/permissions")
+ @RequiresLogin()
+ @ApiOperation({ summary: "Get the user's permissions for a guild" })
+ @ZodResponse({ type: GuildPermissionsResponseDto })
+ async guildPermissions(
+ @Param() params: GuildIdParamsDto,
+ @Req() req: Request,
+ ) {
+ return await this.discordTokenService.withDiscordAccessToken(
+ req.session,
+ (accessToken) =>
+ this.discordGuildService.getGuildPermissionsForUser(
+ params.guildId,
+ accessToken,
+ ),
+ );
+ }
+
+ @Get("guilds/permissions-map")
+ @RequiresLogin()
+ @ApiOperation({
+ summary: "Get the cached permission flags for all of the user's guilds",
+ })
+ @ZodResponse({ type: GuildsResponseDto })
+ async guildPermissionsMap(@Req() req: Request) {
+ const guildFlags = await this.discordTokenService.withDiscordAccessToken(
+ req.session,
+ (accessToken) =>
+ this.discordGuildService.getCachedUserGuildFlags(
+ req.session,
+ accessToken,
+ ),
+ );
+
+ req.session.discordGuildFlags = guildFlags;
+
+ return { guilds: guildFlags };
+ }
+
+ // TODO: ratelimiting
+ @Post("guilds/refresh")
+ @RequiresLogin()
+ @ApiOperation({
+ summary: "Refresh the cached guild permission flags from Discord",
+ })
+ @ZodResponse({ status: HttpStatus.OK, type: GuildsResponseDto })
+ async refreshGuilds(@Req() req: Request): Promise {
+ const guildFlags = await this.discordTokenService.withDiscordAccessToken(
+ req.session,
+ (accessToken) =>
+ this.discordGuildService.refreshCachedUserGuildFlags(
+ req.session,
+ accessToken,
+ ),
+ );
+
+ this.discordGuildService
+ .syncDiscordGuildRecords(guildFlags)
+ .catch((error) => {
+ this.logger.error(`Failed to sync guild records: ${error}`);
+ });
+
+ return { guilds: guildFlags };
+ }
+
+ /**
+ * Delete the active session associated with the user. This will invalidate
+ * the existing session cookie.
+ */
+ @Post("logout")
+ @HttpCode(HttpStatus.NO_CONTENT)
+ @ApiOperation({ summary: "Log out" })
+ @ApiNoContentResponse({ description: "Session invalidated" })
+ async logout(@Req() req: Request): Promise {
+ await new Promise((resolve, reject) => {
+ req.logout((error) => {
+ if (error) {
+ reject(error);
+ return;
+ }
+ resolve();
+ });
+ });
+ }
+
+ /**
+ * Runs `passport.authenticate` inside the Nest pipeline so strategy errors
+ * surface as exceptions to the global filter (same envelope as the old
+ * backend's `errorHandler`).
+ */
+ private authenticate(
+ req: Request,
+ res: Response,
+ options: passport.AuthenticateOptions = {},
+ ): Promise {
+ return new Promise((resolve, reject) => {
+ // Passport may end the response itself (OAuth redirect, failure
+ // redirect); `next` is never called in that case.
+ res.once("finish", () => resolve());
+
+ const middleware = passport.authenticate(DISCORD_STRATEGY_NAME, options);
+ middleware(req, res, (error?: unknown) => {
+ if (error) {
+ reject(error);
+ return;
+ }
+ resolve();
+ });
+ });
+ }
+}
diff --git a/packages/backend-nest/src/auth/discord.strategy.ts b/packages/backend-nest/src/auth/discord.strategy.ts
new file mode 100644
index 000000000..db425b901
--- /dev/null
+++ b/packages/backend-nest/src/auth/discord.strategy.ts
@@ -0,0 +1,87 @@
+import type { DiscordUserProfile } from "@blurple-canvas-web/types";
+import { Inject, Injectable } from "@nestjs/common";
+import type { ConsumableAPI, DiscordProfile } from "discord-strategy";
+import { DiscordScope, Strategy as DiscordStrategy } from "discord-strategy";
+import passport from "passport";
+import refresh from "passport-oauth2-refresh";
+
+import { type DiscordConfig, discordConfig } from "@/config/discord.config";
+import { DiscordGuildService } from "@/discord/discord-guild.service";
+import { DiscordProfileService } from "@/discord/discord-profile.service";
+
+@Injectable()
+export class DiscordStrategyService {
+ readonly strategy: DiscordStrategy;
+
+ constructor(
+ @Inject(discordConfig.KEY) config: DiscordConfig,
+ discordGuildService: DiscordGuildService,
+ discordProfileService: DiscordProfileService,
+ ) {
+ this.strategy = new DiscordStrategy(
+ {
+ clientID: config.clientId,
+ clientSecret: config.clientSecret,
+ authorizationURL: "https://discord.com/api/oauth2/authorize",
+ callbackURL: "/api/v1/discord/callback",
+ tokenURL: "https://discord.com/api/oauth2/token",
+ scope: [
+ DiscordScope.Identify,
+ DiscordScope.Guilds,
+ DiscordScope.GuildsMembersRead,
+ ],
+ },
+ async (
+ accessToken: string,
+ refreshToken: string,
+ profile: DiscordProfile,
+ done: (
+ error: Error | null,
+ user?: DiscordUserProfile,
+ info?: {
+ discordAccessToken: string;
+ discordRefreshToken: string;
+ discordGuildFlags: Awaited<
+ ReturnType
+ >;
+ discordGuildFlagsFetchedAt: number;
+ },
+ ) => void,
+ _consume: ConsumableAPI,
+ ) => {
+ try {
+ const userGuildFlags =
+ await discordGuildService.getCurrentUserGuildFlags(accessToken);
+ const [userIsCanvasAdmin, userIsCanvasModerator] = await Promise.all([
+ discordGuildService.isCanvasAdmin(accessToken),
+ discordGuildService.isCanvasModerator(accessToken),
+ ]);
+
+ const user: DiscordUserProfile = {
+ id: profile.id,
+ username: profile.username,
+ profilePictureUrl:
+ discordProfileService.getProfilePictureUrlFromHash(
+ BigInt(profile.id),
+ profile.avatar ?? null,
+ ),
+ isCanvasAdmin: userIsCanvasAdmin,
+ isCanvasModerator: userIsCanvasAdmin || userIsCanvasModerator,
+ };
+
+ done(null, user, {
+ discordAccessToken: accessToken,
+ discordRefreshToken: refreshToken,
+ discordGuildFlags: userGuildFlags,
+ discordGuildFlagsFetchedAt: Date.now(),
+ });
+ } catch (error) {
+ done(error as Error, undefined);
+ }
+ },
+ );
+
+ passport.use(this.strategy);
+ refresh.use(this.strategy as never);
+ }
+}
diff --git a/packages/backend-nest/src/auth/guards/bot-api-key.guard.spec.ts b/packages/backend-nest/src/auth/guards/bot-api-key.guard.spec.ts
new file mode 100644
index 000000000..37aafdab9
--- /dev/null
+++ b/packages/backend-nest/src/auth/guards/bot-api-key.guard.spec.ts
@@ -0,0 +1,95 @@
+import type { ExecutionContext } from "@nestjs/common";
+import { Test, type TestingModule } from "@nestjs/testing";
+import type { Request } from "express";
+
+import { BotApiKeyGuard } from "@/auth/guards/bot-api-key.guard";
+import { UnauthorizedError } from "@/common/errors/unauthorized.error";
+import {
+ type PlacementConfig,
+ placementConfig,
+} from "@/config/placement.config";
+
+const testPlacementConfig: PlacementConfig = {
+ webGuildId: 0,
+ webPlacingEnabled: true,
+ botPlacingEnabled: true,
+ botApiKey: "secret-key",
+};
+
+function makeContext(request: Request): ExecutionContext {
+ return {
+ switchToHttp: () => ({ getRequest: () => request }),
+ } as unknown as ExecutionContext;
+}
+
+function makeApiRequest(apiKey?: string): Request {
+ return {
+ header: (name: string) => (name === "x-api-key" ? apiKey : undefined),
+ } as unknown as Request;
+}
+
+describe("BotApiKeyGuard", () => {
+ let moduleRef: TestingModule;
+ let guard: BotApiKeyGuard;
+
+ beforeAll(async () => {
+ moduleRef = await Test.createTestingModule({
+ providers: [
+ BotApiKeyGuard,
+ { provide: placementConfig.KEY, useValue: testPlacementConfig },
+ ],
+ }).compile();
+ guard = moduleRef.get(BotApiKeyGuard);
+ });
+
+ afterAll(async () => {
+ await moduleRef.close();
+ });
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("activates when the api key matches", () => {
+ expect(guard.canActivate(makeContext(makeApiRequest("secret-key")))).toBe(
+ true,
+ );
+ });
+
+ it("throws UnauthorizedError when the header is missing", () => {
+ expect(() => guard.canActivate(makeContext(makeApiRequest()))).toThrow(
+ UnauthorizedError,
+ );
+ });
+
+ it("throws UnauthorizedError when the key does not match", () => {
+ expect(() =>
+ guard.canActivate(makeContext(makeApiRequest("wrong-key"))),
+ ).toThrow(UnauthorizedError);
+ });
+
+ it("throws UnauthorizedError when no key is configured", async () => {
+ const keylessModuleRef = await Test.createTestingModule({
+ providers: [
+ BotApiKeyGuard,
+ {
+ provide: placementConfig.KEY,
+ useValue: { ...testPlacementConfig, botApiKey: undefined },
+ },
+ ],
+ }).compile();
+ const keylessGuard = keylessModuleRef.get(BotApiKeyGuard);
+
+ expect(() =>
+ keylessGuard.canActivate(makeContext(makeApiRequest("secret-key"))),
+ ).toThrow(UnauthorizedError);
+
+ await keylessModuleRef.close();
+ });
+
+ it("throws with the parity error message", () => {
+ expect(() => guard.canActivate(makeContext(makeApiRequest()))).toThrow(
+ "Invalid API key",
+ );
+ });
+});
diff --git a/packages/backend-nest/src/auth/guards/bot-api-key.guard.ts b/packages/backend-nest/src/auth/guards/bot-api-key.guard.ts
new file mode 100644
index 000000000..6d8e571ab
--- /dev/null
+++ b/packages/backend-nest/src/auth/guards/bot-api-key.guard.ts
@@ -0,0 +1,31 @@
+import {
+ type CanActivate,
+ type ExecutionContext,
+ Inject,
+ Injectable,
+} from "@nestjs/common";
+import type { Request } from "express";
+
+import { UnauthorizedError } from "@/common/errors/unauthorized.error";
+import {
+ type PlacementConfig,
+ placementConfig,
+} from "@/config/placement.config";
+
+@Injectable()
+export class BotApiKeyGuard implements CanActivate {
+ constructor(
+ @Inject(placementConfig.KEY) private readonly config: PlacementConfig,
+ ) {}
+
+ canActivate(context: ExecutionContext): boolean {
+ const request = context.switchToHttp().getRequest();
+
+ const apiKey = request.header("x-api-key");
+ if (!apiKey || !this.config.botApiKey || apiKey !== this.config.botApiKey) {
+ throw new UnauthorizedError("Invalid API key");
+ }
+
+ return true;
+ }
+}
diff --git a/packages/backend-nest/src/auth/guards/canvas-admin.guard.spec.ts b/packages/backend-nest/src/auth/guards/canvas-admin.guard.spec.ts
new file mode 100644
index 000000000..2544a5cc3
--- /dev/null
+++ b/packages/backend-nest/src/auth/guards/canvas-admin.guard.spec.ts
@@ -0,0 +1,100 @@
+import type { ExecutionContext } from "@nestjs/common";
+import { Test, type TestingModule } from "@nestjs/testing";
+import type { Request } from "express";
+
+import { CanvasAdminGuard } from "@/auth/guards/canvas-admin.guard";
+import { LoggedInGuard } from "@/auth/guards/logged-in.guard";
+import { ForbiddenError } from "@/common/errors/forbidden.error";
+import { UnauthorizedError } from "@/common/errors/unauthorized.error";
+import { DiscordGuildService } from "@/discord/discord-guild.service";
+import { DiscordTokenService } from "@/discord/discord-token.service";
+import { mockDiscordUser as mockUser } from "@/test/fixtures/users";
+
+const mockGuildService = {
+ isCanvasAdmin: vi.fn(),
+ isCanvasModerator: vi.fn(),
+} satisfies Partial>;
+
+function makeRequest(overrides: Partial = {}): Request {
+ return {
+ user: mockUser,
+ session: {
+ discordAccessToken: "test-token",
+ },
+ ...overrides,
+ } as unknown as Request;
+}
+
+function makeContext(request: Request): ExecutionContext {
+ return {
+ switchToHttp: () => ({ getRequest: () => request }),
+ } as unknown as ExecutionContext;
+}
+
+describe("CanvasAdminGuard", () => {
+ let moduleRef: TestingModule;
+ let guard: CanvasAdminGuard;
+
+ beforeAll(async () => {
+ moduleRef = await Test.createTestingModule({
+ providers: [
+ LoggedInGuard,
+ CanvasAdminGuard,
+ DiscordTokenService,
+ { provide: DiscordGuildService, useValue: mockGuildService },
+ ],
+ }).compile();
+ guard = moduleRef.get(CanvasAdminGuard);
+ });
+
+ afterAll(async () => {
+ await moduleRef.close();
+ });
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("activates when the user is an admin", async () => {
+ mockGuildService.isCanvasAdmin.mockResolvedValueOnce(true);
+
+ await expect(guard.canActivate(makeContext(makeRequest()))).resolves.toBe(
+ true,
+ );
+ });
+
+ it("throws ForbiddenError when the user is not an admin", async () => {
+ mockGuildService.isCanvasAdmin.mockResolvedValueOnce(false);
+
+ await expect(
+ guard.canActivate(makeContext(makeRequest())),
+ ).rejects.toBeInstanceOf(ForbiddenError);
+ });
+
+ it("throws UnauthorizedError when the user is not logged in", async () => {
+ const context = makeContext(makeRequest({ user: undefined }));
+
+ await expect(guard.canActivate(context)).rejects.toBeInstanceOf(
+ UnauthorizedError,
+ );
+ expect(mockGuildService.isCanvasAdmin).not.toHaveBeenCalled();
+ });
+
+ it("checks the admin role with the session access token", async () => {
+ mockGuildService.isCanvasAdmin.mockResolvedValueOnce(true);
+
+ await guard.canActivate(makeContext(makeRequest()));
+
+ expect(mockGuildService.isCanvasAdmin).toHaveBeenCalledWith("test-token");
+ });
+
+ it("throws with a message describing the missing permission", async () => {
+ mockGuildService.isCanvasAdmin.mockResolvedValueOnce(false);
+
+ await expect(
+ guard.canActivate(makeContext(makeRequest())),
+ ).rejects.toMatchObject({
+ message: "You do not have permission to perform this action",
+ });
+ });
+});
diff --git a/packages/backend-nest/src/auth/guards/canvas-admin.guard.ts b/packages/backend-nest/src/auth/guards/canvas-admin.guard.ts
new file mode 100644
index 000000000..b957aeccd
--- /dev/null
+++ b/packages/backend-nest/src/auth/guards/canvas-admin.guard.ts
@@ -0,0 +1,40 @@
+import {
+ type CanActivate,
+ type ExecutionContext,
+ Injectable,
+} from "@nestjs/common";
+import type { Request } from "express";
+
+import { LoggedInGuard } from "@/auth/guards/logged-in.guard";
+import { ForbiddenError } from "@/common/errors/forbidden.error";
+import { DiscordGuildService } from "@/discord/discord-guild.service";
+import { DiscordTokenService } from "@/discord/discord-token.service";
+
+@Injectable()
+export class CanvasAdminGuard extends LoggedInGuard implements CanActivate {
+ constructor(
+ private readonly discordTokenService: DiscordTokenService,
+ private readonly discordGuildService: DiscordGuildService,
+ ) {
+ super();
+ }
+
+ async canActivate(context: ExecutionContext): Promise {
+ await super.canActivate(context);
+ const request = context.switchToHttp().getRequest();
+
+ const userIsCanvasAdmin =
+ await this.discordTokenService.withDiscordAccessToken(
+ request.session,
+ (accessToken) => this.discordGuildService.isCanvasAdmin(accessToken),
+ );
+
+ if (!userIsCanvasAdmin) {
+ throw new ForbiddenError(
+ "You do not have permission to perform this action",
+ );
+ }
+
+ return true;
+ }
+}
diff --git a/packages/backend-nest/src/auth/guards/canvas-moderator.guard.spec.ts b/packages/backend-nest/src/auth/guards/canvas-moderator.guard.spec.ts
new file mode 100644
index 000000000..a1fe05503
--- /dev/null
+++ b/packages/backend-nest/src/auth/guards/canvas-moderator.guard.spec.ts
@@ -0,0 +1,101 @@
+import type { ExecutionContext } from "@nestjs/common";
+import { Test, type TestingModule } from "@nestjs/testing";
+import type { Request } from "express";
+import { CanvasModeratorGuard } from "@/auth/guards/canvas-moderator.guard";
+import { LoggedInGuard } from "@/auth/guards/logged-in.guard";
+import { ForbiddenError } from "@/common/errors/forbidden.error";
+import { UnauthorizedError } from "@/common/errors/unauthorized.error";
+import { DiscordGuildService } from "@/discord/discord-guild.service";
+import { DiscordTokenService } from "@/discord/discord-token.service";
+import { mockDiscordUser as mockUser } from "@/test/fixtures/users";
+
+const mockGuildService = {
+ isCanvasAdmin: vi.fn(),
+ isCanvasModerator: vi.fn(),
+} satisfies Partial>;
+
+function makeRequest(overrides: Partial = {}): Request {
+ return {
+ user: mockUser,
+ session: {
+ discordAccessToken: "test-token",
+ },
+ ...overrides,
+ } as unknown as Request;
+}
+
+function makeContext(request: Request): ExecutionContext {
+ return {
+ switchToHttp: () => ({ getRequest: () => request }),
+ } as unknown as ExecutionContext;
+}
+
+describe("CanvasModeratorGuard", () => {
+ let moduleRef: TestingModule;
+ let guard: CanvasModeratorGuard;
+
+ beforeAll(async () => {
+ moduleRef = await Test.createTestingModule({
+ providers: [
+ LoggedInGuard,
+ CanvasModeratorGuard,
+ DiscordTokenService,
+ { provide: DiscordGuildService, useValue: mockGuildService },
+ ],
+ }).compile();
+ guard = moduleRef.get(CanvasModeratorGuard);
+ });
+
+ afterAll(async () => {
+ await moduleRef.close();
+ });
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("activates when the user is a moderator", async () => {
+ mockGuildService.isCanvasModerator.mockResolvedValueOnce(true);
+
+ await expect(guard.canActivate(makeContext(makeRequest()))).resolves.toBe(
+ true,
+ );
+ });
+
+ it("throws ForbiddenError when the user is not a moderator", async () => {
+ mockGuildService.isCanvasModerator.mockResolvedValueOnce(false);
+
+ await expect(
+ guard.canActivate(makeContext(makeRequest())),
+ ).rejects.toBeInstanceOf(ForbiddenError);
+ });
+
+ it("throws UnauthorizedError when the user is not logged in", async () => {
+ const context = makeContext(makeRequest({ user: undefined }));
+
+ await expect(guard.canActivate(context)).rejects.toBeInstanceOf(
+ UnauthorizedError,
+ );
+ expect(mockGuildService.isCanvasModerator).not.toHaveBeenCalled();
+ });
+
+ it("checks the moderator role with the session access token", async () => {
+ mockGuildService.isCanvasModerator.mockResolvedValueOnce(true);
+
+ await guard.canActivate(makeContext(makeRequest()));
+
+ expect(mockGuildService.isCanvasModerator).toHaveBeenCalledWith(
+ "test-token",
+ );
+ });
+
+ it("throws with a message describing the missing permission", async () => {
+ mockGuildService.isCanvasModerator.mockResolvedValueOnce(false);
+
+ await expect(
+ guard.canActivate(makeContext(makeRequest())),
+ ).rejects.toMatchObject({
+ message: "You do not have permission to perform this action",
+ });
+ });
+});
diff --git a/packages/backend-nest/src/auth/guards/canvas-moderator.guard.ts b/packages/backend-nest/src/auth/guards/canvas-moderator.guard.ts
new file mode 100644
index 000000000..715a83cce
--- /dev/null
+++ b/packages/backend-nest/src/auth/guards/canvas-moderator.guard.ts
@@ -0,0 +1,41 @@
+import {
+ type CanActivate,
+ type ExecutionContext,
+ Injectable,
+} from "@nestjs/common";
+import type { Request } from "express";
+
+import { LoggedInGuard } from "@/auth/guards/logged-in.guard";
+import { ForbiddenError } from "@/common/errors/forbidden.error";
+import { DiscordGuildService } from "@/discord/discord-guild.service";
+import { DiscordTokenService } from "@/discord/discord-token.service";
+
+@Injectable()
+export class CanvasModeratorGuard extends LoggedInGuard implements CanActivate {
+ constructor(
+ private readonly discordTokenService: DiscordTokenService,
+ private readonly discordGuildService: DiscordGuildService,
+ ) {
+ super();
+ }
+
+ override async canActivate(context: ExecutionContext): Promise {
+ await super.canActivate(context);
+ const request = context.switchToHttp().getRequest();
+
+ const userIsCanvasModerator =
+ await this.discordTokenService.withDiscordAccessToken(
+ request.session,
+ (accessToken) =>
+ this.discordGuildService.isCanvasModerator(accessToken),
+ );
+
+ if (!userIsCanvasModerator) {
+ throw new ForbiddenError(
+ "You do not have permission to perform this action",
+ );
+ }
+
+ return true;
+ }
+}
diff --git a/packages/backend-nest/src/auth/guards/logged-in.guard.spec.ts b/packages/backend-nest/src/auth/guards/logged-in.guard.spec.ts
new file mode 100644
index 000000000..4d86da55e
--- /dev/null
+++ b/packages/backend-nest/src/auth/guards/logged-in.guard.spec.ts
@@ -0,0 +1,77 @@
+import type { ExecutionContext } from "@nestjs/common";
+import { Test, type TestingModule } from "@nestjs/testing";
+import type { Request } from "express";
+
+import { LoggedInGuard } from "@/auth/guards/logged-in.guard";
+import { UnauthorizedError } from "@/common/errors/unauthorized.error";
+import { mockDiscordUser as mockUser } from "@/test/fixtures/users";
+
+function makeRequest(overrides: Partial = {}): Request {
+ return {
+ user: mockUser,
+ session: {
+ discordAccessToken: "test-token",
+ },
+ ...overrides,
+ } as unknown as Request;
+}
+
+function makeContext(request: Request): ExecutionContext {
+ return {
+ switchToHttp: () => ({ getRequest: () => request }),
+ } as unknown as ExecutionContext;
+}
+
+describe("LoggedInGuard", () => {
+ let guard: LoggedInGuard;
+ let moduleRef: TestingModule;
+
+ beforeAll(async () => {
+ moduleRef = await Test.createTestingModule({
+ providers: [LoggedInGuard],
+ }).compile();
+ guard = moduleRef.get(LoggedInGuard);
+ });
+
+ afterAll(async () => {
+ await moduleRef.close();
+ });
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("activates when the user is authenticated", () => {
+ expect(guard.canActivate(makeContext(makeRequest()))).resolves.toBe(true);
+ });
+
+ it("activates for refresh-token-only sessions", () => {
+ const request = makeRequest({
+ session: { discordRefreshToken: "refresh-token" },
+ } as unknown as Partial);
+
+ expect(guard.canActivate(makeContext(request))).resolves.toBe(true);
+ });
+
+ it("throws UnauthorizedError when the user is missing", () => {
+ const context = makeContext(makeRequest({ user: undefined }));
+
+ expect(() => guard.canActivate(context)).toThrow(UnauthorizedError);
+ });
+
+ it("throws UnauthorizedError when tokens are missing", () => {
+ const context = makeContext(
+ makeRequest({ session: {} } as unknown as Partial),
+ );
+
+ expect(() => guard.canActivate(context)).toThrow(UnauthorizedError);
+ });
+
+ it("throws with the parity error message", () => {
+ const context = makeContext(makeRequest({ user: undefined }));
+
+ expect(() => guard.canActivate(context)).toThrow(
+ "User is not authenticated",
+ );
+ });
+});
diff --git a/packages/backend-nest/src/auth/guards/logged-in.guard.ts b/packages/backend-nest/src/auth/guards/logged-in.guard.ts
new file mode 100644
index 000000000..79313f153
--- /dev/null
+++ b/packages/backend-nest/src/auth/guards/logged-in.guard.ts
@@ -0,0 +1,27 @@
+import {
+ type CanActivate,
+ type ExecutionContext,
+ Injectable,
+} from "@nestjs/common";
+import { type Request } from "express";
+
+import { UnauthorizedError } from "@/common/errors/unauthorized.error";
+
+@Injectable()
+export class LoggedInGuard implements CanActivate {
+ canActivate(context: ExecutionContext): Promise {
+ const request = context.switchToHttp().getRequest();
+
+ if (
+ !request.user ||
+ !(
+ request.session.discordAccessToken ||
+ request.session.discordRefreshToken
+ )
+ ) {
+ throw new UnauthorizedError("User is not authenticated");
+ }
+
+ return Promise.resolve(true);
+ }
+}
diff --git a/packages/backend-nest/src/auth/require-auth.decorator.ts b/packages/backend-nest/src/auth/require-auth.decorator.ts
new file mode 100644
index 000000000..0e8d05bed
--- /dev/null
+++ b/packages/backend-nest/src/auth/require-auth.decorator.ts
@@ -0,0 +1,94 @@
+import { applyDecorators, UseGuards } from "@nestjs/common";
+import {
+ ApiCookieAuth,
+ ApiExtension,
+ ApiForbiddenResponse,
+ ApiSecurity,
+ ApiUnauthorizedResponse,
+} from "@nestjs/swagger";
+
+import { setActorRole } from "@/audit/audit.decorator";
+import { BotApiKeyGuard } from "@/auth/guards/bot-api-key.guard";
+import { CanvasAdminGuard } from "@/auth/guards/canvas-admin.guard";
+import { CanvasModeratorGuard } from "@/auth/guards/canvas-moderator.guard";
+import { LoggedInGuard } from "@/auth/guards/logged-in.guard";
+import { ErrorResponseDto } from "@/common/error-response.dto";
+
+/** Swagger security scheme names, registered in `setupSwagger`. */
+export const SESSION_SECURITY = "session";
+export const BOT_API_KEY_SECURITY = "bot-api-key";
+
+/**
+ * Internal marker listing the guards protecting an operation. `setupSwagger`
+ * rewrites it into the operation's description and strips it from the
+ * published document.
+ */
+export const GUARDS_EXTENSION = "x-guards";
+
+/**
+ * Routes that need a logged-in user. Applies `LoggedInGuard` and documents
+ * the session-cookie requirement in Swagger (lock icon + 401 response).
+ */
+export function RequiresLogin() {
+ return applyDecorators(
+ UseGuards(LoggedInGuard),
+ ApiCookieAuth(SESSION_SECURITY),
+ ApiExtension(GUARDS_EXTENSION, [LoggedInGuard.name]),
+ ApiUnauthorizedResponse({
+ type: ErrorResponseDto,
+ description: `${LoggedInGuard.name}: there is no authenticated session with Discord tokens`,
+ }),
+ );
+}
+
+/** Routes restricted to canvas admins. */
+export function RequiresCanvasAdmin() {
+ return applyDecorators(
+ setActorRole("admin"),
+ UseGuards(CanvasAdminGuard),
+ ApiCookieAuth(SESSION_SECURITY),
+ ApiExtension(GUARDS_EXTENSION, [CanvasAdminGuard.name]),
+ ApiUnauthorizedResponse({
+ type: ErrorResponseDto,
+ description: `${CanvasAdminGuard.name}: there is no authenticated session with Discord tokens`,
+ }),
+ ApiForbiddenResponse({
+ type: ErrorResponseDto,
+ description: `${CanvasAdminGuard.name}: the user is not a canvas admin`,
+ }),
+ );
+}
+
+/** Routes restricted to canvas moderators. */
+export function RequiresCanvasModerator() {
+ return applyDecorators(
+ setActorRole("moderator"),
+ UseGuards(CanvasModeratorGuard),
+ ApiCookieAuth(SESSION_SECURITY),
+ ApiExtension(GUARDS_EXTENSION, [CanvasModeratorGuard.name]),
+ ApiUnauthorizedResponse({
+ type: ErrorResponseDto,
+ description: `${CanvasModeratorGuard.name}: there is no authenticated session with Discord tokens`,
+ }),
+ ApiForbiddenResponse({
+ type: ErrorResponseDto,
+ description: `${CanvasModeratorGuard.name}: the user is not a canvas moderator`,
+ }),
+ );
+}
+
+/**
+ * Routes reserved for the Discord bot, authenticated with the `x-api-key`
+ * header (configurable through the Authorize dialog in Swagger UI).
+ */
+export function RequiresBotApiKey() {
+ return applyDecorators(
+ UseGuards(BotApiKeyGuard),
+ ApiSecurity(BOT_API_KEY_SECURITY),
+ ApiExtension(GUARDS_EXTENSION, [BotApiKeyGuard.name]),
+ ApiUnauthorizedResponse({
+ type: ErrorResponseDto,
+ description: `${BotApiKeyGuard.name}: the \`x-api-key\` header is missing or invalid`,
+ }),
+ );
+}
diff --git a/packages/backend-nest/src/auth/session-store.service.ts b/packages/backend-nest/src/auth/session-store.service.ts
new file mode 100644
index 000000000..b27572438
--- /dev/null
+++ b/packages/backend-nest/src/auth/session-store.service.ts
@@ -0,0 +1,25 @@
+import { Injectable, type OnApplicationShutdown } from "@nestjs/common";
+import { PrismaSessionStore } from "@quixo3/prisma-session-store";
+
+import { PrismaService } from "@/common/database/prisma.service";
+
+/** How often the store prunes expired sessions from the database. */
+const SESSION_PRUNE_INTERVAL_MS = 2 * 60 * 1000;
+
+@Injectable()
+export class SessionStoreService implements OnApplicationShutdown {
+ readonly store: PrismaSessionStore;
+
+ constructor(prisma: PrismaService) {
+ this.store = new PrismaSessionStore(prisma, {
+ checkPeriod: SESSION_PRUNE_INTERVAL_MS,
+ dbRecordIdIsSessionId: true,
+ dbRecordIdFunction: undefined,
+ });
+ }
+
+ onApplicationShutdown(): void {
+ // Stop the prune timer so the process (and the test runner) can exit.
+ this.store.stopInterval();
+ }
+}
diff --git a/packages/backend-nest/src/auth/session.setup.ts b/packages/backend-nest/src/auth/session.setup.ts
new file mode 100644
index 000000000..98b569503
--- /dev/null
+++ b/packages/backend-nest/src/auth/session.setup.ts
@@ -0,0 +1,38 @@
+import type { NestExpressApplication } from "@nestjs/platform-express";
+import session from "express-session";
+import passport from "passport";
+
+import { SessionStoreService } from "@/auth/session-store.service";
+import { type SessionConfig, sessionConfig } from "@/config/session.config";
+
+const SESSION_MAX_AGE_MS = 24 * 60 * 60 * 1000; // 1 day
+
+export function configureSession(app: NestExpressApplication): void {
+ const { secret } = app.get(sessionConfig.KEY);
+ const { store } = app.get(SessionStoreService);
+
+ app.use(
+ session({
+ cookie: {
+ maxAge: SESSION_MAX_AGE_MS,
+ },
+ // having a random secret would mess with persistent sessions
+ secret,
+ resave: true,
+ saveUninitialized: false,
+ store,
+ }),
+ );
+
+ app.use(passport.initialize());
+ app.use(passport.session());
+
+ // The whole profile lives in the session; deserializing never hits the DB.
+ passport.serializeUser((user, done) => {
+ done(null, user);
+ });
+
+ passport.deserializeUser((user, done) => {
+ done(null, user);
+ });
+}
diff --git a/packages/backend-nest/src/blocklist/blocklist.controller.ts b/packages/backend-nest/src/blocklist/blocklist.controller.ts
new file mode 100644
index 000000000..145f16bc4
--- /dev/null
+++ b/packages/backend-nest/src/blocklist/blocklist.controller.ts
@@ -0,0 +1,85 @@
+import {
+ BlocklistBodyModel,
+ BlocklistDeleteBodyModel,
+ BlocklistEntrySchema,
+} from "@blurple-canvas-web/types";
+import {
+ Body,
+ Controller,
+ Delete,
+ Get,
+ HttpCode,
+ HttpStatus,
+ Put,
+} from "@nestjs/common";
+import { ApiNoContentResponse, ApiOperation } from "@nestjs/swagger";
+import { createZodDto, ZodResponse } from "nestjs-zod";
+
+import { Audit } from "@/audit/audit.decorator";
+import { RequiresCanvasModerator } from "@/auth/require-auth.decorator";
+import { BlocklistService } from "./blocklist.service";
+
+class BlocklistBodyDto extends createZodDto(BlocklistBodyModel) {}
+
+class BlocklistDeleteBodyDto extends createZodDto(BlocklistDeleteBodyModel) {}
+
+class BlocklistEntryResponseDto extends createZodDto(BlocklistEntrySchema) {}
+
+@Controller("blocklist")
+export class BlocklistController {
+ constructor(private readonly blocklistService: BlocklistService) {}
+
+ @Get()
+ @RequiresCanvasModerator()
+ @ApiOperation({ summary: "List every blocklisted user" })
+ @ZodResponse({ type: [BlocklistEntryResponseDto] })
+ async getBlocklist() {
+ return await this.blocklistService.getBlocklist();
+ }
+
+ @Put()
+ @RequiresCanvasModerator()
+ @HttpCode(HttpStatus.CREATED)
+ @ApiOperation({ summary: "Add one or more users to the blocklist" })
+ async addToBlocklist(@Body() body: BlocklistBodyDto, @Audit() audit: Audit) {
+ const addedUsers = await this.blocklistService.addUsersToBlocklist(body);
+
+ audit.record({
+ action: "blocklist.add",
+ metadata: {
+ userIds: body.map((id) => id.toString()),
+ addedCount: addedUsers.length,
+ },
+ });
+
+ return addedUsers;
+ }
+
+ @Delete()
+ @RequiresCanvasModerator()
+ @HttpCode(HttpStatus.NO_CONTENT)
+ @ApiOperation({
+ summary:
+ "Remove users from the blocklist, optionally restoring their history",
+ })
+ @ApiNoContentResponse({ description: "Users removed from the blocklist" })
+ async removeFromBlocklist(
+ @Body() body: BlocklistDeleteBodyDto,
+ @Audit() audit: Audit,
+ ): Promise {
+ const { userIds, shouldRestoreHistoryForCanvasId } = body;
+
+ await this.blocklistService.removeUsersFromBlocklist(
+ userIds,
+ shouldRestoreHistoryForCanvasId ?? [],
+ );
+
+ audit.record({
+ action: "blocklist.remove",
+ metadata: {
+ userIds: userIds.map((id) => id.toString()),
+ shouldRestoreHistoryForCanvasId,
+ },
+ });
+ }
+}
diff --git a/packages/backend-nest/src/blocklist/blocklist.module.ts b/packages/backend-nest/src/blocklist/blocklist.module.ts
new file mode 100644
index 000000000..07802ab8d
--- /dev/null
+++ b/packages/backend-nest/src/blocklist/blocklist.module.ts
@@ -0,0 +1,15 @@
+import { Module } from "@nestjs/common";
+
+import { CanvasModeratorGuard } from "@/auth/guards/canvas-moderator.guard";
+import { CanvasModule } from "@/canvas/canvas.module";
+import { DiscordModule } from "@/discord/discord.module";
+import { BlocklistController } from "./blocklist.controller";
+import { BlocklistService } from "./blocklist.service";
+
+@Module({
+ imports: [CanvasModule, DiscordModule],
+ controllers: [BlocklistController],
+ providers: [BlocklistService, CanvasModeratorGuard],
+ exports: [BlocklistService],
+})
+export class BlocklistModule {}
diff --git a/packages/backend-nest/src/blocklist/blocklist.service.spec.ts b/packages/backend-nest/src/blocklist/blocklist.service.spec.ts
new file mode 100644
index 000000000..ce9a04561
--- /dev/null
+++ b/packages/backend-nest/src/blocklist/blocklist.service.spec.ts
@@ -0,0 +1,98 @@
+import { Test, type TestingModule } from "@nestjs/testing";
+
+import { PixelReconciliationService } from "@/canvas/pixel-reconciliation.service";
+import { DatabaseModule } from "@/common/database/database.module";
+import { AppConfigModule } from "@/config/config.module";
+import { testPrisma as prisma } from "@/test/database";
+import { seedBlacklist } from "@/test/seed/blacklist";
+import { seedUsers } from "@/test/seed/users";
+import { BlocklistService } from "./blocklist.service";
+
+const pixelReconciliationService = {
+ restoreErasedHistory: vi.fn(),
+};
+
+describe("BlocklistService", () => {
+ let moduleRef: TestingModule;
+ let service: BlocklistService;
+
+ beforeAll(async () => {
+ moduleRef = await Test.createTestingModule({
+ imports: [AppConfigModule, DatabaseModule],
+ providers: [
+ BlocklistService,
+ {
+ provide: PixelReconciliationService,
+ useValue: pixelReconciliationService,
+ },
+ ],
+ }).compile();
+ await moduleRef.init();
+
+ service = moduleRef.get(BlocklistService);
+ });
+
+ afterAll(async () => {
+ await moduleRef.close();
+ });
+
+ beforeEach(async () => {
+ vi.clearAllMocks();
+ await seedUsers();
+ await seedBlacklist();
+ });
+
+ describe("getBlocklist", () => {
+ it("returns the blocklist entries, newest first", async () => {
+ await expect(service.getBlocklist()).resolves.toStrictEqual([
+ {
+ userId: "9",
+ dateAdded: new Date(0).toISOString(),
+ username: null,
+ profilePictureUrl: null,
+ },
+ ]);
+ });
+ });
+
+ describe("userIsBlocklisted", () => {
+ it("returns true for a blocked user", async () => {
+ await expect(service.userIsBlocklisted(9n)).resolves.toBe(true);
+ });
+
+ it("returns false for an unblocked user", async () => {
+ await expect(service.userIsBlocklisted(1n)).resolves.toBe(false);
+ });
+ });
+
+ describe("addUsersToBlocklist", () => {
+ it("adds users from any iterable, skipping duplicates", async () => {
+ await expect(
+ service.addUsersToBlocklist(new Set([1n])),
+ ).resolves.toStrictEqual([{ userId: 1n, dateAdded: expect.any(Date) }]);
+
+ await expect(service.userIsBlocklisted(1n)).resolves.toBe(true);
+ });
+ });
+
+ describe("removeUsersFromBlocklist", () => {
+ it("removes users from the blocklist", async () => {
+ await prisma.blacklist.create({ data: { userId: 1n } });
+
+ await service.removeUsersFromBlocklist(new Set([1n]));
+
+ await expect(service.userIsBlocklisted(1n)).resolves.toBe(false);
+ expect(
+ pixelReconciliationService.restoreErasedHistory,
+ ).not.toHaveBeenCalled();
+ });
+
+ it("restores pixel history before removing when requested", async () => {
+ await service.removeUsersFromBlocklist(new Set([1n]), [1]);
+
+ expect(
+ pixelReconciliationService.restoreErasedHistory,
+ ).toHaveBeenCalledWith([1n], [1]);
+ });
+ });
+});
diff --git a/packages/backend-nest/src/blocklist/blocklist.service.ts b/packages/backend-nest/src/blocklist/blocklist.service.ts
new file mode 100644
index 000000000..74c73d61f
--- /dev/null
+++ b/packages/backend-nest/src/blocklist/blocklist.service.ts
@@ -0,0 +1,80 @@
+import type { BlocklistEntry } from "@blurple-canvas-web/types";
+import { Injectable } from "@nestjs/common";
+
+import { PixelReconciliationService } from "@/canvas/pixel-reconciliation.service";
+import { PrismaService } from "@/common/database/prisma.service";
+
+@Injectable()
+export class BlocklistService {
+ constructor(
+ private readonly prisma: PrismaService,
+ private readonly pixelReconciliationService: PixelReconciliationService,
+ ) {}
+
+ async userIsBlocklisted(userId: bigint): Promise {
+ const blocklistEntry = await this.prisma.blacklist.findFirst({
+ where: { userId },
+ });
+
+ return !!blocklistEntry;
+ }
+
+ /** Lists every blocklisted user, most recently added first. */
+ async getBlocklist(): Promise {
+ const blocklist = await this.prisma.$kysely
+ .selectFrom("blacklist")
+ .leftJoin(
+ "discordUserProfile",
+ "discordUserProfile.userId",
+ "blacklist.userId",
+ )
+ .select([
+ "blacklist.userId",
+ "blacklist.dateAdded",
+ "discordUserProfile.username",
+ "discordUserProfile.profilePictureUrl",
+ ])
+ .orderBy("blacklist.dateAdded", "desc")
+ .execute();
+
+ return blocklist.map((entry) => ({
+ userId: entry.userId.toString(),
+ dateAdded: entry.dateAdded.toISOString(),
+ username: entry.username,
+ profilePictureUrl: entry.profilePictureUrl,
+ }));
+ }
+
+ /** Adds the given users to the blocklist, skipping duplicates. */
+ async addUsersToBlocklist(userIds: Iterable) {
+ const userIdsArray = Array.isArray(userIds) ? userIds : Array.from(userIds);
+
+ return await this.prisma.blacklist.createManyAndReturn({
+ data: userIdsArray.map((userId) => ({ userId })),
+ skipDuplicates: true,
+ });
+ }
+
+ /**
+ * Removes the given users from the blocklist. When
+ * `shouldRestoreHistoryForCanvasId` is non-empty, their soft-erased history
+ * on those canvases is restored first (pixels rebuilt).
+ */
+ async removeUsersFromBlocklist(
+ userIds: Iterable,
+ shouldRestoreHistoryForCanvasId: number[] = [],
+ ): Promise {
+ const userIdsArray = Array.isArray(userIds) ? userIds : Array.from(userIds);
+
+ if (shouldRestoreHistoryForCanvasId.length > 0 && userIdsArray.length > 0) {
+ await this.pixelReconciliationService.restoreErasedHistory(
+ userIdsArray,
+ shouldRestoreHistoryForCanvasId,
+ );
+ }
+
+ await this.prisma.blacklist.deleteMany({
+ where: { userId: { in: userIdsArray } },
+ });
+ }
+}
diff --git a/packages/backend-nest/src/canvas/canvas-cache.service.spec.ts b/packages/backend-nest/src/canvas/canvas-cache.service.spec.ts
new file mode 100644
index 000000000..dfbf5a329
--- /dev/null
+++ b/packages/backend-nest/src/canvas/canvas-cache.service.spec.ts
@@ -0,0 +1,299 @@
+import fs from "node:fs/promises";
+import os from "node:os";
+import path from "node:path";
+import { CANVAS_EXPORT_SCALES } from "@blurple-canvas-web/types";
+import { Test, type TestingModule } from "@nestjs/testing";
+import sharp from "sharp";
+
+import { DatabaseModule } from "@/common/database/database.module";
+import { appConfig } from "@/config/app.config";
+import { AppConfigModule } from "@/config/config.module";
+import { testPrisma as prisma } from "@/test/database";
+import { seedCanvases } from "@/test/seed/canvases";
+import { seedColors } from "@/test/seed/colors";
+import { seedEvents } from "@/test/seed/events";
+import { seedPixels } from "@/test/seed/pixels";
+import { CanvasCacheService } from "./canvas-cache.service";
+
+function fileExists(filePath: string): Promise {
+ return fs.access(filePath).then(
+ () => true,
+ () => false,
+ );
+}
+
+describe("CanvasCacheService", () => {
+ let moduleRef: TestingModule;
+ let service: CanvasCacheService;
+ let canvasesPath: string;
+
+ beforeEach(async () => {
+ canvasesPath = await fs.mkdtemp(path.join(os.tmpdir(), "canvas-cache-"));
+
+ moduleRef = await Test.createTestingModule({
+ imports: [AppConfigModule, DatabaseModule],
+ providers: [CanvasCacheService],
+ })
+ .overrideProvider(appConfig.KEY)
+ .useValue({
+ environment: "test",
+ port: 3001,
+ frontendUrl: "http://localhost:3000",
+ paths: { root: canvasesPath, canvases: canvasesPath },
+ })
+ .compile();
+
+ service = moduleRef.get(CanvasCacheService);
+
+ await seedEvents();
+ await seedCanvases();
+ await seedColors();
+ await seedPixels();
+ });
+
+ afterEach(async () => {
+ await Promise.all([
+ moduleRef.close(),
+ fs.rm(canvasesPath, { recursive: true, force: true }),
+ ]);
+ });
+
+ /** Writes a locked canvas PNG into the canvases directory. */
+ async function writeLockedCanvasFile(
+ canvasId: number,
+ scale: 1 | 2 | 4,
+ ): Promise {
+ const filePath = path.join(
+ canvasesPath,
+ service.getCanvasFilename(canvasId, true, scale),
+ );
+
+ await sharp(Buffer.alloc(2 * scale * 2 * scale * 4), {
+ raw: { width: 2 * scale, height: 2 * scale, channels: 4 },
+ })
+ .png()
+ .toFile(filePath);
+
+ return filePath;
+ }
+
+ describe("getCanvasPng", () => {
+ it("only fetches a canvas from the database once when concurrent cache misses occur for the same canvas", async () => {
+ const getCanvasPixelsSpy = vi.spyOn(service, "getCanvasPixels");
+
+ const [firstCanvas, secondCanvas] = await Promise.all([
+ service.getCanvasPng(1),
+ service.getCanvasPng(1),
+ ]);
+
+ expect(firstCanvas).toStrictEqual(secondCanvas);
+ expect(firstCanvas.isLocked).toBe(false);
+ expect(getCanvasPixelsSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it("serves subsequent requests from the cache without fetching from the database", async () => {
+ await service.getCanvasPng(1);
+
+ const getCanvasPixelsSpy = vi.spyOn(service, "getCanvasPixels");
+ const cached = await service.getCanvasPng(1);
+
+ expect(cached.isLocked).toBe(false);
+ expect(getCanvasPixelsSpy).not.toHaveBeenCalled();
+ });
+
+ it("creates and reuses 1x, 2x, and 4x locked canvas files on disk", async () => {
+ await service.getCanvasPng(9);
+
+ const canvas = await service.getCanvasPng(9);
+
+ if (!canvas.isLocked) {
+ throw new Error("Expected locked canvas cache entries");
+ }
+
+ expect(canvas).toMatchObject({
+ isLocked: true,
+ canvasPaths: expect.objectContaining({
+ 1: expect.stringContaining(service.getCanvasFilename(9, true)),
+ 2: expect.stringContaining(service.getCanvasFilename(9, true, 2)),
+ 4: expect.stringContaining(service.getCanvasFilename(9, true, 4)),
+ }),
+ });
+
+ const canvas1xPath = canvas.canvasPaths[1];
+ const canvas2xPath = canvas.canvasPaths[2];
+ const canvas4xPath = canvas.canvasPaths[4];
+
+ if (!canvas1xPath || !canvas2xPath || !canvas4xPath) {
+ throw new Error("Expected locked canvas paths to exist");
+ }
+
+ expect(await sharp(canvas1xPath).metadata()).toMatchObject({
+ width: 2,
+ height: 2,
+ density: 72,
+ icc: expect.any(Buffer),
+ });
+
+ expect(await sharp(canvas2xPath).metadata()).toMatchObject({
+ width: 4,
+ height: 4,
+ density: 144,
+ });
+
+ expect(await sharp(canvas4xPath).metadata()).toMatchObject({
+ width: 8,
+ height: 8,
+ density: 288,
+ });
+
+ for (const scale of CANVAS_EXPORT_SCALES) {
+ expect(
+ await fileExists(
+ path.join(canvasesPath, service.getCanvasFilename(9, true, scale)),
+ ),
+ ).toBe(true);
+ }
+ });
+
+ it("evicts the in-memory entry and on-disk files when clearCachedCanvas is called", async () => {
+ await service.getCanvasPng(9);
+
+ await service.clearCachedCanvas(9);
+
+ for (const scale of CANVAS_EXPORT_SCALES) {
+ expect(
+ await fileExists(
+ path.join(canvasesPath, service.getCanvasFilename(9, true, scale)),
+ ),
+ ).toBe(false);
+ }
+
+ // The next access is a cache miss and regenerates the files.
+ const getCanvasPixelsSpy = vi.spyOn(service, "getCanvasPixels");
+ await service.getCanvasPng(9);
+ expect(getCanvasPixelsSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it("removes the on-disk files when a cached canvas is unlocked in the database", async () => {
+ await service.getCanvasPng(9);
+ const lockedCanvas = await service.getCanvasPng(9);
+ if (!lockedCanvas.isLocked) {
+ throw new Error("Expected locked canvas cache entries");
+ }
+
+ await prisma.canvas.update({
+ where: { id: 9 },
+ data: { locked: false },
+ });
+
+ const refreshed = await service.getCanvasPng(9);
+ expect(refreshed.isLocked).toBe(false);
+
+ for (const scale of CANVAS_EXPORT_SCALES) {
+ expect(
+ await fileExists(
+ path.join(canvasesPath, service.getCanvasFilename(9, true, scale)),
+ ),
+ ).toBe(false);
+ }
+ });
+
+ it("materialises the files when a cached canvas is locked in the database", async () => {
+ const unlockedCanvas = await service.getCanvasPng(1);
+ expect(unlockedCanvas.isLocked).toBe(false);
+
+ await prisma.canvas.update({
+ where: { id: 1 },
+ data: { locked: true },
+ });
+
+ await service.getCanvasPng(1);
+ const lockedCanvas = await service.getCanvasPng(1);
+
+ expect(lockedCanvas.isLocked).toBe(true);
+ for (const scale of CANVAS_EXPORT_SCALES) {
+ expect(
+ await fileExists(
+ path.join(canvasesPath, service.getCanvasFilename(1, true, scale)),
+ ),
+ ).toBe(true);
+ }
+ });
+
+ it("loads locked canvas files from the file system when initializeCache is called", async () => {
+ const paths = await Promise.all(
+ ([1, 2, 4] as const).map((scale) => writeLockedCanvasFile(9, scale)),
+ );
+
+ service.initializeCache();
+
+ const getCanvasPixelsSpy = vi.spyOn(service, "getCanvasPixels");
+ const canvas = await service.getCanvasPng(9);
+
+ expect(canvas).toStrictEqual({
+ isLocked: true,
+ canvasPaths: { 1: paths[0], 2: paths[1], 4: paths[2] },
+ });
+ expect(getCanvasPixelsSpy).not.toHaveBeenCalled();
+ });
+
+ it("regenerates all sizes when a cached locked canvas misses a scale", async () => {
+ // Only the 1x file is present, so the boot scan produces an incomplete
+ // cache entry.
+ await writeLockedCanvasFile(9, 1);
+ service.initializeCache();
+
+ const getCanvasPixelsSpy = vi.spyOn(service, "getCanvasPixels");
+ await service.getCanvasPng(9);
+ expect(getCanvasPixelsSpy).toHaveBeenCalledTimes(1);
+
+ const canvas = await service.getCanvasPng(9);
+ if (!canvas.isLocked) {
+ throw new Error("Expected locked canvas cache entries");
+ }
+
+ for (const scale of CANVAS_EXPORT_SCALES) {
+ const canvasPath = canvas.canvasPaths[scale];
+ expect(canvasPath).toBeDefined();
+ expect(await fileExists(canvasPath as string)).toBe(true);
+ }
+
+ expect(await sharp(canvas.canvasPaths[4]).metadata()).toMatchObject({
+ width: 8,
+ height: 8,
+ });
+ });
+ });
+
+ describe("updateManyCachedPixels", () => {
+ it("updates pixels of an unlocked cached canvas in place", async () => {
+ const cached = await service.getCanvasPng(1);
+ if (cached.isLocked) {
+ throw new Error("Expected an unlocked canvas");
+ }
+
+ service.updateCachedCanvasPixel(1, { x: 0, y: 0 }, [1, 2, 3, 4]);
+ expect(cached.pixels[0]).toStrictEqual([1, 2, 3, 4]);
+
+ service.updateManyCachedPixels(1, [
+ { x: 1, y: 0, rgba: [5, 6, 7, 8] },
+ { x: 0, y: 1, rgba: [9, 10, 11, 12] },
+ ]);
+ expect(cached.pixels[1]).toStrictEqual([5, 6, 7, 8]);
+ expect(cached.pixels[2]).toStrictEqual([9, 10, 11, 12]);
+ });
+ });
+
+ describe("updateCachedCanvasPixel", () => {
+ it("ignores pixel updates for locked or uncached canvases", async () => {
+ await service.getCanvasPng(9);
+ const locked = await service.getCanvasPng(9);
+ expect(locked.isLocked).toBe(true);
+
+ expect(() => {
+ service.updateCachedCanvasPixel(9, { x: 0, y: 0 }, [1, 2, 3, 4]);
+ service.updateCachedCanvasPixel(404, { x: 0, y: 0 }, [1, 2, 3, 4]);
+ }).not.toThrow();
+ });
+ });
+});
diff --git a/packages/backend-nest/src/canvas/canvas-cache.service.ts b/packages/backend-nest/src/canvas/canvas-cache.service.ts
new file mode 100644
index 000000000..5ae74b9fe
--- /dev/null
+++ b/packages/backend-nest/src/canvas/canvas-cache.service.ts
@@ -0,0 +1,407 @@
+import fs from "node:fs";
+import {
+ type BoundsInput,
+ CANVAS_EXPORT_SCALES,
+ type CanvasExportScale,
+ type CanvasInfo,
+ DEFAULT_CANVAS_EXPORT_SCALE,
+ type PixelColor,
+ type PlacePixelArray,
+ type Point,
+} from "@blurple-canvas-web/types";
+import {
+ Inject,
+ Injectable,
+ Logger,
+ type OnApplicationBootstrap,
+} from "@nestjs/common";
+import sharp from "sharp";
+
+import { PrismaService } from "@/common/database/prisma.service";
+import { NotFoundError } from "@/common/errors/not-found.error";
+import { type AppConfig, appConfig } from "@/config/app.config";
+
+/**
+ * A locked canvas cannot be edited by users. It is therefore, safe to store it
+ * as an image on the file system.
+ */
+export interface LockedCanvas {
+ isLocked: true;
+ canvasPaths: Partial>;
+}
+
+/**
+ * An unlocked canvas can be edited by users so the pixels are stored in
+ * memory. This allows for easy updating of the canvas, while also allowing it
+ * to be rapidly returned from requests (as most of the time to build a canvas
+ * image from scratch is fetching the pixels from the database).
+ */
+export interface UnlockedCanvas {
+ isLocked: false;
+ width: number;
+ height: number;
+ pixels: PixelColor[];
+}
+
+export type CachedCanvas = LockedCanvas | UnlockedCanvas;
+
+@Injectable()
+export class CanvasCacheService implements OnApplicationBootstrap {
+ private readonly logger = new Logger(CanvasCacheService.name);
+
+ private readonly canvasCache = new Map();
+ private readonly canvasLoads = new Map>();
+
+ constructor(
+ private readonly prisma: PrismaService,
+ @Inject(appConfig.KEY) private readonly appCfg: AppConfig,
+ ) {}
+
+ onApplicationBootstrap(): void {
+ this.initializeCache();
+ }
+
+ static withPngMetadata(
+ image: sharp.Sharp,
+ scale: CanvasExportScale,
+ ): sharp.Sharp {
+ return image.withMetadata({ density: 72 * scale }).withIccProfile("srgb");
+ }
+
+ /**
+ * Generates a filename for a canvas image. If the canvas is not locked (and
+ * therefore, can change) the filename will include the current timestamp.
+ */
+ getCanvasFilename(
+ canvasId: number,
+ isLocked = false,
+ scale: CanvasExportScale = DEFAULT_CANVAS_EXPORT_SCALE,
+ bounds?: BoundsInput,
+ ): string {
+ const scaleSuffix = scale === 1 ? "" : `@${scale}x`;
+ const boundsSuffix =
+ bounds ? `_${bounds.x0}x${bounds.y0}_${bounds.x1}x${bounds.y1}` : "";
+
+ return `blurple-canvas__${canvasId}__${isLocked ? "locked" : Date.now()}${boundsSuffix}${scaleSuffix}.png`;
+ }
+
+ pixelsToRgbaBuffer(
+ pixels: PixelColor[],
+ width: number,
+ height: number,
+ ): Buffer {
+ const expectedPixelCount = width * height;
+ const buffer = Buffer.alloc(expectedPixelCount * 4);
+
+ if (pixels.length !== expectedPixelCount) {
+ this.logger.warn(
+ `Pixel count mismatch when building RGBA buffer: expected ${expectedPixelCount} (${width}x${height}), got ${pixels.length}. The buffer will be padded/truncated to fit.`,
+ );
+ }
+
+ const pixelsToCopy = Math.min(pixels.length, expectedPixelCount);
+ for (let index = 0; index < pixelsToCopy; index += 1) {
+ const color = pixels[index];
+ if (!color) continue;
+
+ const imageIndex = index * 4;
+ buffer[imageIndex] = color[0] ?? 0;
+ buffer[imageIndex + 1] = color[1] ?? 0;
+ buffer[imageIndex + 2] = color[2] ?? 0;
+ buffer[imageIndex + 3] = color[3] ?? 0;
+ }
+
+ return buffer;
+ }
+
+ /**
+ * Warms the locked-canvas cache from the PNG files already present in the
+ * canvases directory.
+ */
+ initializeCache(): void {
+ const lockedCanvasPaths = new Map();
+
+ for (const filename of fs.readdirSync(this.appCfg.paths.canvases)) {
+ const match = new RegExp(
+ /^blurple-canvas__(\d+)__locked(?:@(\d+)x)?\.png$/,
+ ).exec(filename);
+
+ if (!match) {
+ continue;
+ }
+
+ const canvasId = Number.parseInt(match[1], 10);
+ const scale = (
+ match[2] ?
+ Number.parseInt(match[2], 10)
+ : 1) as CanvasExportScale;
+ const canvasPath = `${this.appCfg.paths.canvases}/${filename}`;
+
+ this.logger.log(`Loaded cached canvas ${canvasPath}`);
+
+ const canvasPaths = lockedCanvasPaths.get(canvasId) ?? {};
+ canvasPaths[scale] = canvasPath;
+ lockedCanvasPaths.set(canvasId, canvasPaths);
+ }
+
+ for (const [canvasId, canvasPaths] of lockedCanvasPaths) {
+ const canvasPath = canvasPaths[1];
+
+ if (!canvasPath) {
+ continue;
+ }
+
+ this.canvasCache.set(canvasId, {
+ isLocked: true,
+ canvasPaths,
+ });
+ }
+ }
+
+ /**
+ * Retrieves a canvas from the cache. If the canvas is not in the cache it
+ * will be fetched from the database and added to it.
+ */
+ async getCanvasPng(canvasId: number): Promise {
+ return this.getOrFetchCachedCanvas(canvasId);
+ }
+
+ /**
+ * Clears a canvas from the in-memory cache. If the canvas is locked, the
+ * cached image is also removed from the file system.
+ */
+ async clearCachedCanvas(canvasId: number): Promise {
+ await this.clearCanvasFromFileSystem(canvasId);
+ this.canvasCache.delete(canvasId);
+ this.logger.debug(`Cleared canvas ${canvasId} from cache`);
+ }
+
+ /**
+ * Updates many pixels in the canvas cache at once. If the canvas is not in
+ * the cache or the canvas is locked this will do nothing.
+ */
+ updateManyCachedPixels(canvasId: number, pixels: PlacePixelArray): void {
+ const cachedCanvas = this.canvasCache.get(canvasId);
+
+ if (!cachedCanvas || cachedCanvas.isLocked) {
+ return;
+ }
+
+ for (const pixel of pixels) {
+ const pixelIndex = pixel.y * cachedCanvas.width + pixel.x;
+ cachedCanvas.pixels[pixelIndex] = pixel.rgba;
+ }
+ }
+
+ /**
+ * Updates a pixel in the canvas cache. If the canvas is not in the cache,
+ * or the canvas is locked this will do nothing.
+ */
+ updateCachedCanvasPixel(
+ canvasId: CanvasInfo["id"],
+ coordinates: Point,
+ color: PixelColor,
+ ): void {
+ const cachedCanvas = this.canvasCache.get(canvasId);
+
+ if (!cachedCanvas || cachedCanvas.isLocked) {
+ return;
+ }
+
+ const pixelIndex = coordinates.y * cachedCanvas.width + coordinates.x;
+ cachedCanvas.pixels[pixelIndex] = color;
+ }
+
+ async getCanvasPixels(
+ canvasId: number,
+ width: number,
+ height: number,
+ ): Promise {
+ const pixels = (await this.prisma.pixel.findMany({
+ select: {
+ x: true,
+ y: true,
+ color: {
+ select: { rgba: true },
+ },
+ },
+ where: { canvasId },
+ })) as { x: number; y: number; color: { rgba: PixelColor } }[];
+
+ const flat: PixelColor[] = new Array(width * height);
+ for (const pixel of pixels) {
+ flat[pixel.y * width + pixel.x] = pixel.color.rgba;
+ }
+ return flat;
+ }
+
+ /**
+ * Materialises a canvas as PNG files at every export scale and returns the
+ * paths keyed by scale.
+ */
+ private async saveCanvasToFileSystem(
+ canvas: { id: number; width: number; height: number },
+ pixels: PixelColor[],
+ ): Promise {
+ const rawBuffer = this.pixelsToRgbaBuffer(
+ pixels,
+ canvas.width,
+ canvas.height,
+ );
+ const baseImage = sharp(rawBuffer, {
+ raw: {
+ width: canvas.width,
+ height: canvas.height,
+ channels: 4,
+ },
+ });
+
+ const files = await Promise.all(
+ CANVAS_EXPORT_SCALES.map(async (scale) => {
+ const path = `${this.appCfg.paths.canvases}/${this.getCanvasFilename(canvas.id, true, scale)}`;
+
+ await CanvasCacheService.withPngMetadata(
+ baseImage.clone().resize({
+ width: canvas.width * scale,
+ height: canvas.height * scale,
+ kernel: sharp.kernel.nearest,
+ }),
+ scale,
+ )
+ .png()
+ .toFile(path);
+
+ return [scale, path] as const;
+ }),
+ );
+
+ return Object.fromEntries(files) as LockedCanvas["canvasPaths"];
+ }
+
+ private async clearCanvasFromFileSystem(canvasId: number): Promise {
+ const cachedCanvas = this.canvasCache.get(canvasId);
+
+ try {
+ if (cachedCanvas?.isLocked) {
+ const uniquePaths = new Set(Object.values(cachedCanvas.canvasPaths));
+
+ await Promise.all(
+ [...uniquePaths].map(async (canvasPath) => {
+ await fs.promises.rm(canvasPath, { force: true });
+ }),
+ );
+
+ this.logger.debug(`Cleared canvas ${canvasId} from file system`);
+ }
+ } catch {
+ this.logger.warn(
+ `Failed to clear canvas ${canvasId} from file system. It may have already been removed.`,
+ );
+ }
+ }
+
+ private async getOrFetchCachedCanvas(
+ canvasId: number,
+ ): Promise {
+ const inFlightLoad = this.canvasLoads.get(canvasId);
+ if (inFlightLoad) {
+ return inFlightLoad;
+ }
+
+ const loadPromise = this.loadCanvas(canvasId);
+ this.canvasLoads.set(canvasId, loadPromise);
+
+ try {
+ return await loadPromise;
+ } finally {
+ this.canvasLoads.delete(canvasId);
+ }
+ }
+
+ private async loadCanvas(canvasId: number): Promise {
+ const canvas = await this.prisma.canvas.findFirst({
+ where: { id: canvasId },
+ });
+
+ if (!canvas) {
+ throw new NotFoundError(`There is no canvas with ID ${canvasId}`);
+ }
+
+ const cachedCanvas = this.canvasCache.get(canvasId);
+ if (cachedCanvas) {
+ if (cachedCanvas.isLocked === canvas.locked) {
+ this.logger.debug(`Cache hit for canvas ${canvasId}`);
+
+ // If this is a locked canvas, verify the cache is complete. If any
+ // expected export scale is missing, clear the cache and treat as a
+ // miss so we generate all sizes atomically via
+ // `saveCanvasToFileSystem` below.
+ if (cachedCanvas.isLocked) {
+ const missing = CANVAS_EXPORT_SCALES.some(
+ (scale) => !cachedCanvas.canvasPaths[scale],
+ );
+
+ if (missing) {
+ this.logger.debug(
+ `Cached locked canvas ${canvasId} is incomplete; clearing to regenerate all sizes.`,
+ );
+ await this.clearCanvasFromFileSystem(canvasId);
+ this.canvasCache.delete(canvasId);
+ } else {
+ return cachedCanvas;
+ }
+ } else {
+ return cachedCanvas;
+ }
+ } else {
+ this.logger.debug(
+ `Canvas ${canvasId} lock status has changed. Updating cache…`,
+ );
+ // Ensure on-disk files are removed and cache entry cleared so we
+ // regenerate below
+ await this.clearCanvasFromFileSystem(canvasId);
+ this.canvasCache.delete(canvasId);
+ }
+ } else {
+ this.logger.debug(`Cache miss for canvas ${canvasId}`);
+ }
+
+ const pixels = await this.getCanvasPixels(
+ canvasId,
+ canvas.width,
+ canvas.height,
+ );
+ const unlockedCanvas: UnlockedCanvas = {
+ isLocked: false,
+ width: canvas.width,
+ height: canvas.height,
+ pixels,
+ };
+
+ if (canvas.locked) {
+ const canvasPaths = await this.saveCanvasToFileSystem(canvas, pixels);
+ const canvasPath = canvasPaths[1];
+
+ if (!canvasPath) {
+ throw new Error(
+ `Failed to create locked canvas files for canvas ${canvasId}`,
+ );
+ }
+
+ this.canvasCache.set(canvasId, {
+ isLocked: true,
+ canvasPaths,
+ });
+
+ this.logger.debug(`Canvas ${canvasId} saved to ${canvasPath}`);
+ } else {
+ this.canvasCache.set(canvasId, unlockedCanvas);
+ this.logger.debug(`Canvas ${canvasId} cached in memory`);
+ }
+
+ // We always want to return the unlocked canvas, even if the image is
+ // locked as sometimes the image hasn’t finished being written to the file
+ // system when Express tries to send it in the response.
+ return unlockedCanvas;
+ }
+}
diff --git a/packages/backend-nest/src/canvas/canvas.controller.ts b/packages/backend-nest/src/canvas/canvas.controller.ts
new file mode 100644
index 000000000..db8451f56
--- /dev/null
+++ b/packages/backend-nest/src/canvas/canvas.controller.ts
@@ -0,0 +1,375 @@
+import {
+ type BoundsInput,
+ CanvasExportParamModel,
+ type CanvasExportScale,
+ CanvasIdParamModel,
+ CanvasInfoSchema,
+ CanvasPasteBodyModel,
+ CanvasSummarySchema,
+ CooldownSchema,
+ CreateCanvasBodyModel,
+ DEFAULT_CANVAS_EXPORT_SCALE,
+ EditCanvasBodyModel,
+ OptionalBoundsModel,
+} from "@blurple-canvas-web/types";
+import {
+ Body,
+ Controller,
+ Delete,
+ Get,
+ HttpCode,
+ HttpStatus,
+ Logger,
+ Param,
+ Post,
+ Put,
+ Query,
+ Res,
+} from "@nestjs/common";
+import {
+ ApiNoContentResponse,
+ ApiOkResponse,
+ ApiOperation,
+ ApiProduces,
+} from "@nestjs/swagger";
+import type { Response } from "express";
+import { createZodDto, ZodResponse } from "nestjs-zod";
+import { z } from "zod";
+
+import { Audit } from "@/audit/audit.decorator";
+import { CurrentUser, CurrentUserDto } from "@/auth/current-user.decorator";
+import {
+ RequiresCanvasAdmin,
+ RequiresLogin,
+} from "@/auth/require-auth.decorator";
+import { CanvasService } from "./canvas.service";
+import { type CachedCanvas, CanvasCacheService } from "./canvas-cache.service";
+import { ExportService } from "./export.service";
+
+class CanvasIdParamsDto extends createZodDto(CanvasIdParamModel) {}
+
+class CanvasExportParamsDto extends createZodDto(CanvasExportParamModel) {}
+
+// The schema transforms an absent crop to `undefined`, which a class cannot
+// extend; the cast narrows the static type while keeping the runtime schema
+// (handlers receive `BoundsInput`).
+class CanvasCropQueryDto extends createZodDto(
+ OptionalBoundsModel as z.ZodType>,
+) {}
+
+class CreateCanvasBodyDto extends createZodDto(CreateCanvasBodyModel) {}
+
+class EditCanvasBodyDto extends createZodDto(EditCanvasBodyModel) {}
+
+class CanvasPasteBodyDto extends createZodDto(CanvasPasteBodyModel) {}
+
+class CanvasSummaryResponseDto extends createZodDto(
+ CanvasSummarySchema.extend({
+ cooldownDuration: z.number().int().nonnegative().nullable(),
+ }),
+) {}
+
+class CanvasInfoResponseDto extends createZodDto(CanvasInfoSchema) {}
+
+class CooldownResponseDto extends createZodDto(CooldownSchema) {}
+
+/**
+ * The raw canvas row, serialised with the database column names — parity with
+ * the old backend, which returned the Prisma record directly.
+ */
+class CanvasRecordResponseDto extends createZodDto(
+ z.object({
+ id: z.number().int(),
+ name: z.string(),
+ locked: z.boolean(),
+ event_id: z.number().int().nullable(),
+ width: z.number().int(),
+ height: z.number().int(),
+ cooldown_length: z.number().int().nullable(),
+ start_coordinates: z.array(z.number().int()),
+ all_colors_global: z.boolean(),
+ }),
+) {}
+
+class CanvasPasteResponseDto extends createZodDto(
+ z.object({
+ message: z.string(),
+ count: z.number().int(),
+ }),
+) {}
+
+@Controller("canvas")
+export class CanvasController {
+ private readonly logger = new Logger(CanvasController.name);
+
+ constructor(
+ private readonly canvasService: CanvasService,
+ private readonly canvasCacheService: CanvasCacheService,
+ private readonly exportService: ExportService,
+ ) {}
+
+ @Get()
+ @ApiOperation({ summary: "Summary of all canvases" })
+ @ZodResponse({ type: [CanvasSummaryResponseDto] })
+ async listCanvases() {
+ return await this.canvasService.getCanvases();
+ }
+
+ @Get("current/info")
+ @ApiOperation({ summary: "Info for the default canvas" })
+ @ZodResponse({ type: CanvasInfoResponseDto })
+ async currentCanvasInfo() {
+ return await this.canvasService.getCurrentCanvasInfo();
+ }
+
+ @Get("current")
+ @ApiOperation({ summary: "PNG of the default canvas" })
+ @ApiProduces("image/png")
+ @ApiOkResponse({ description: "The canvas image" })
+ async currentCanvasPng(@Res() res: Response): Promise {
+ const canvasId = await this.canvasService.getDefaultCanvasId();
+ const cachedCanvas = await this.canvasCacheService.getCanvasPng(canvasId);
+
+ await this.sendCachedCanvas(res, canvasId, cachedCanvas);
+ }
+
+ @Get(":canvasId/info")
+ @ApiOperation({ summary: "Canvas metadata (size, lock, cooldown, etc.)" })
+ @ZodResponse({ type: CanvasInfoResponseDto })
+ async canvasInfo(@Param() params: CanvasIdParamsDto) {
+ return await this.canvasService.getCanvasInfo(params.canvasId);
+ }
+
+ @Get(":canvasId/cooldown/@me")
+ @RequiresLogin()
+ @ApiOperation({ summary: "The caller's remaining cooldown (ms)" })
+ @ZodResponse({ type: CooldownResponseDto })
+ async cooldown(
+ @Param() params: CanvasIdParamsDto,
+ @CurrentUser() user: CurrentUserDto,
+ ) {
+ const cooldownEndTime = await this.canvasService.getUserCanvasCooldown(
+ params.canvasId,
+ BigInt(user.id),
+ );
+
+ return {
+ cooldownEndTime: cooldownEndTime ?? undefined,
+ };
+ }
+
+ @Get(":canvasId@:scale.png")
+ @ApiOperation({
+ summary: "PNG of a canvas at scale (1/2/4×), with optional crop",
+ })
+ @ApiProduces("image/png")
+ @ApiOkResponse({ description: "The canvas image" })
+ async canvasPngAtScale(
+ @Param() params: CanvasExportParamsDto,
+ @Query() bounds: CanvasCropQueryDto,
+ @Res() res: Response,
+ ): Promise {
+ const cachedCanvas = await this.canvasCacheService.getCanvasPng(
+ params.canvasId,
+ );
+
+ await this.sendCachedCanvas(
+ res,
+ params.canvasId,
+ cachedCanvas,
+ params.scale,
+ // The schema transforms an absent crop to `undefined`.
+ bounds as BoundsInput,
+ );
+ }
+
+ @Get(":canvasId")
+ @ApiOperation({ summary: "PNG of a canvas" })
+ @ApiProduces("image/png")
+ @ApiOkResponse({ description: "The canvas image" })
+ async canvasPng(
+ @Param() params: CanvasIdParamsDto,
+ @Res() res: Response,
+ ): Promise {
+ const cachedCanvas = await this.canvasCacheService.getCanvasPng(
+ params.canvasId,
+ );
+
+ await this.sendCachedCanvas(res, params.canvasId, cachedCanvas);
+ }
+
+ @Post()
+ @RequiresCanvasAdmin()
+ @ApiOperation({
+ summary: "Create a canvas (locked, pixels initialised to blank)",
+ })
+ @ZodResponse({ status: HttpStatus.CREATED, type: CanvasRecordResponseDto })
+ async createCanvas(@Body() body: CreateCanvasBodyDto, @Audit() audit: Audit) {
+ const canvas = await this.canvasService.createCanvas(body);
+
+ audit.record({
+ action: "canvas.create",
+ resourceId: canvas.id,
+ metadata: body,
+ });
+
+ return this.toCanvasRecordResponse(canvas);
+ }
+
+ @Put(":canvasId")
+ @RequiresCanvasAdmin()
+ @ApiOperation({ summary: "Edit name/lock/cooldown/allColorsGlobal" })
+ @ZodResponse({ type: CanvasRecordResponseDto })
+ async editCanvas(
+ @Param() params: CanvasIdParamsDto,
+ @Body() body: EditCanvasBodyDto,
+ @Audit() audit: Audit,
+ ) {
+ const canvas = await this.canvasService.editCanvas({
+ canvasId: params.canvasId,
+ ...body,
+ });
+
+ audit.record({
+ action: "canvas.update",
+ resourceId: canvas.id,
+ metadata: body,
+ });
+
+ return this.toCanvasRecordResponse(canvas);
+ }
+
+ @Post(":canvasId/paste")
+ @RequiresCanvasAdmin()
+ @ApiOperation({
+ summary: "Bulk-paste [x, y, colorId] triples onto a canvas",
+ })
+ @ZodResponse({ type: CanvasPasteResponseDto })
+ async pasteCanvasData(
+ @Param() params: CanvasIdParamsDto,
+ @Body() body: CanvasPasteBodyDto,
+ @Audit() audit: Audit,
+ ) {
+ const { authorId, data } = body;
+
+ await this.canvasService.pasteCanvasData(
+ params.canvasId,
+ BigInt(authorId),
+ data,
+ );
+
+ audit.record({
+ action: "canvas.paste",
+ resourceId: params.canvasId,
+ metadata: {
+ authorId: authorId.toString(),
+ pixelCount: data.length,
+ area: CanvasService.computePasteArea(data),
+ },
+ });
+
+ return {
+ message: "Canvas data pasted",
+ count: data.length,
+ };
+ }
+
+ @Delete(":canvasId/cache")
+ @RequiresCanvasAdmin()
+ @HttpCode(HttpStatus.NO_CONTENT)
+ @ApiOperation({
+ summary: "Evict a canvas from the in-memory and on-disk cache",
+ })
+ @ApiNoContentResponse({ description: "Cache evicted" })
+ async clearCachedCanvas(
+ @Param() params: CanvasIdParamsDto,
+ @Audit() audit: Audit,
+ ): Promise {
+ await this.canvasCacheService.clearCachedCanvas(params.canvasId);
+
+ audit.record({
+ action: "canvas.clearCache",
+ resourceId: params.canvasId,
+ });
+ }
+
+ private toCanvasRecordResponse(canvas: {
+ id: number;
+ name: string;
+ locked: boolean;
+ eventId: number | null;
+ width: number;
+ height: number;
+ cooldownLength: number | null;
+ startCoordinates: number[];
+ allColorsGlobal: boolean;
+ }): CanvasRecordResponseDto {
+ return {
+ id: canvas.id,
+ name: canvas.name,
+ locked: canvas.locked,
+ event_id: canvas.eventId,
+ width: canvas.width,
+ height: canvas.height,
+ cooldown_length: canvas.cooldownLength,
+ start_coordinates: canvas.startCoordinates,
+ all_colors_global: canvas.allColorsGlobal,
+ };
+ }
+
+ private async sendCachedCanvas(
+ res: Response,
+ canvasId: number,
+ cachedCanvas: CachedCanvas,
+ scale: CanvasExportScale = DEFAULT_CANVAS_EXPORT_SCALE,
+ bounds?: BoundsInput,
+ ): Promise {
+ if (cachedCanvas.isLocked) {
+ const canvasPath = cachedCanvas.canvasPaths[scale];
+
+ if (!canvasPath) {
+ throw new Error(
+ `There is no cached canvas file for canvas ${canvasId} at ${scale}x`,
+ );
+ }
+
+ await new Promise((resolve, reject) => {
+ res.sendFile(canvasPath, (error) =>
+ error ? reject(error) : resolve(),
+ );
+ });
+ return;
+ }
+
+ const stream =
+ bounds ?
+ await this.exportService.exportCanvasBoundsAsStream({
+ canvasId,
+ ...bounds,
+ scale,
+ })
+ : this.exportService.unlockedCanvasToPngStream(cachedCanvas, scale);
+
+ stream.on("error", (error) => {
+ this.logger.error(`Error streaming canvas ${canvasId} PNG:`, error);
+ if (res.headersSent) {
+ res.destroy(error);
+ } else {
+ res.sendStatus(500);
+ }
+ });
+
+ stream.pipe(
+ res
+ .status(200)
+ .type("png")
+ .setHeader("Cache-Control", ["no-cache", "no-store"])
+ // Needed to force Safari to not cache the image
+ .setHeader("Vary", "*")
+ .setHeader(
+ "Content-Disposition",
+ `inline; filename="${this.canvasCacheService.getCanvasFilename(canvasId, false, scale, bounds)}"`,
+ ),
+ );
+ }
+}
diff --git a/packages/backend-nest/src/canvas/canvas.module.ts b/packages/backend-nest/src/canvas/canvas.module.ts
new file mode 100644
index 000000000..75dccc00b
--- /dev/null
+++ b/packages/backend-nest/src/canvas/canvas.module.ts
@@ -0,0 +1,29 @@
+import { Module } from "@nestjs/common";
+
+import { CanvasAdminGuard } from "@/auth/guards/canvas-admin.guard";
+import { DiscordModule } from "@/discord/discord.module";
+import { RealtimeModule } from "@/realtime/realtime.module";
+import { CanvasController } from "./canvas.controller";
+import { CanvasService } from "./canvas.service";
+import { CanvasCacheService } from "./canvas-cache.service";
+import { ExportService } from "./export.service";
+import { PixelReconciliationService } from "./pixel-reconciliation.service";
+
+@Module({
+ imports: [DiscordModule, RealtimeModule],
+ controllers: [CanvasController],
+ providers: [
+ CanvasService,
+ CanvasCacheService,
+ ExportService,
+ PixelReconciliationService,
+ CanvasAdminGuard,
+ ],
+ exports: [
+ CanvasService,
+ CanvasCacheService,
+ ExportService,
+ PixelReconciliationService,
+ ],
+})
+export class CanvasModule {}
diff --git a/packages/backend-nest/src/canvas/canvas.service.spec.ts b/packages/backend-nest/src/canvas/canvas.service.spec.ts
new file mode 100644
index 000000000..2fb44c896
--- /dev/null
+++ b/packages/backend-nest/src/canvas/canvas.service.spec.ts
@@ -0,0 +1,361 @@
+import { Test, type TestingModule } from "@nestjs/testing";
+
+import { DatabaseModule } from "@/common/database/database.module";
+import { NotFoundError } from "@/common/errors/not-found.error";
+import { UnprocessableError } from "@/common/errors/unprocessable.error";
+import { AppConfigModule } from "@/config/config.module";
+import { BroadcastService } from "@/realtime/broadcast.service";
+import { testPrisma as prisma, resetSequence } from "@/test/database";
+import { seedCanvases } from "@/test/seed/canvases";
+import { seedColors } from "@/test/seed/colors";
+import { seedEvents } from "@/test/seed/events";
+import { seedUsers } from "@/test/seed/users";
+import { CanvasService } from "./canvas.service";
+import { CanvasCacheService } from "./canvas-cache.service";
+import { PixelReconciliationService } from "./pixel-reconciliation.service";
+
+const broadcastService = {
+ broadcastCanvasInfo: vi.fn(),
+ broadcastPixelsBulk: vi.fn(),
+};
+
+const pixelReconciliationService = {
+ createBulkPlaceEntries: vi.fn(),
+};
+
+async function seedInfo() {
+ await prisma.info.create({
+ data: {
+ title: "Canvas Test",
+ canvasAdmin: [],
+ currentEventId: 1,
+ cachedCanvasIds: [],
+ adminServerId: 1n,
+ currentEmojiServerId: 1n,
+ hostServerId: 1n,
+ defaultCanvasId: 1,
+ },
+ });
+}
+
+describe("CanvasService", () => {
+ let moduleRef: TestingModule;
+ let service: CanvasService;
+ let cacheService: CanvasCacheService;
+
+ beforeAll(async () => {
+ moduleRef = await Test.createTestingModule({
+ imports: [AppConfigModule, DatabaseModule],
+ providers: [
+ CanvasService,
+ CanvasCacheService,
+ { provide: BroadcastService, useValue: broadcastService },
+ {
+ provide: PixelReconciliationService,
+ useValue: pixelReconciliationService,
+ },
+ ],
+ }).compile();
+ await moduleRef.init();
+
+ service = moduleRef.get(CanvasService);
+ cacheService = moduleRef.get(CanvasCacheService);
+ });
+
+ afterAll(async () => {
+ await moduleRef.close();
+ });
+
+ beforeEach(async () => {
+ vi.clearAllMocks();
+ await seedEvents();
+ await seedCanvases();
+ });
+
+ describe("getCanvases", () => {
+ it("throws an error for a nonexistent canvas", async () => {
+ await expect(service.getCanvasInfo(9999)).rejects.toThrow(NotFoundError);
+ });
+
+ it("returns all canvases", async () => {
+ expect((await service.getCanvases()).length).toBe(2);
+ });
+
+ it("returns a summary of canvases sorted by last pixel activity (most recent first)", async () => {
+ const canvases = await service.getCanvases();
+ expect(canvases).toMatchObject([
+ { id: 9, name: "Locked Canvas" },
+ { id: 1, name: "Unlocked Canvas" },
+ ]);
+ });
+
+ it("returns a canvas by ID", async () => {
+ expect(await service.getCanvasInfo(1)).toMatchObject({
+ id: 1,
+ name: "Unlocked Canvas",
+ width: 2,
+ height: 2,
+ isLocked: false,
+ eventId: 1,
+ startCoordinates: [1, 1],
+ });
+ });
+ });
+
+ describe("createCanvas", () => {
+ beforeEach(async () => {
+ await seedColors();
+ await seedInfo();
+ await resetSequence("canvas");
+ });
+
+ it("creates a canvas and seeds its pixels in the database", async () => {
+ const canvasName = `Generated Canvas ${Date.now()}`;
+
+ await service.createCanvas({
+ name: canvasName,
+ width: 3,
+ height: 2,
+ });
+
+ const createdCanvas = await prisma.canvas.findFirst({
+ where: { name: canvasName },
+ select: {
+ id: true,
+ width: true,
+ height: true,
+ },
+ });
+
+ expect(createdCanvas).not.toBeNull();
+ expect(createdCanvas).toMatchObject({
+ width: 3,
+ height: 2,
+ });
+
+ if (!createdCanvas) {
+ throw new Error("Expected the canvas to be created");
+ }
+
+ const pixels = await cacheService.getCanvasPixels(
+ createdCanvas.id,
+ createdCanvas.width,
+ createdCanvas.height,
+ );
+ expect(pixels).toHaveLength(6);
+ expect(pixels).toStrictEqual([
+ [88, 101, 242, 127],
+ [88, 101, 242, 127],
+ [88, 101, 242, 127],
+ [88, 101, 242, 127],
+ [88, 101, 242, 127],
+ [88, 101, 242, 127],
+ ]);
+
+ expect(broadcastService.broadcastCanvasInfo).toHaveBeenCalledWith(
+ expect.objectContaining({
+ id: createdCanvas.id,
+ name: canvasName,
+ width: 3,
+ height: 2,
+ isLocked: true,
+ allColorsGlobal: false,
+ cooldownDuration: 15,
+ }),
+ );
+ });
+ });
+
+ describe("editCanvas", () => {
+ it("updates the canvas fields in the database and broadcasts the new canvas info", async () => {
+ await service.editCanvas({
+ canvasId: 1,
+ name: "Edited Canvas",
+ isLocked: true,
+ allColorsGlobal: true,
+ cooldownDuration: 45,
+ });
+
+ const updatedCanvas = await prisma.canvas.findFirst({
+ where: { id: 1 },
+ select: {
+ cooldownLength: true,
+ allColorsGlobal: true,
+ },
+ });
+
+ expect(updatedCanvas).toMatchObject({
+ cooldownLength: 45,
+ allColorsGlobal: true,
+ });
+
+ expect(broadcastService.broadcastCanvasInfo).toHaveBeenCalledWith(
+ expect.objectContaining({
+ id: 1,
+ name: "Edited Canvas",
+ isLocked: true,
+ allColorsGlobal: true,
+ cooldownDuration: 45,
+ }),
+ );
+ });
+ });
+
+ describe("pasteCanvasData", () => {
+ beforeEach(async () => {
+ await seedColors();
+ });
+
+ it("throws an error for out-of-bounds coordinates", async () => {
+ await expect(service.pasteCanvasData(1, 1n, [[5, 0, 1]])).rejects.toThrow(
+ "out of bounds",
+ );
+
+ expect(
+ pixelReconciliationService.createBulkPlaceEntries,
+ ).not.toHaveBeenCalled();
+ });
+
+ it("throws an error for colors that are not in the event palette", async () => {
+ // Color 3 is non-global and has no participation in event 1.
+ await expect(service.pasteCanvasData(1, 1n, [[0, 0, 3]])).rejects.toThrow(
+ "not in the event palette",
+ );
+
+ expect(
+ pixelReconciliationService.createBulkPlaceEntries,
+ ).not.toHaveBeenCalled();
+ });
+
+ it("throws an error for a canvas without an event", async () => {
+ const canvas = await prisma.canvas.create({
+ data: {
+ name: "Eventless Canvas",
+ locked: false,
+ width: 2,
+ height: 2,
+ eventId: null,
+ id: 50,
+ },
+ });
+
+ await expect(
+ service.pasteCanvasData(canvas.id, 1n, [[0, 0, 1]]),
+ ).rejects.toThrow(UnprocessableError);
+ });
+
+ it("validates paste data and creates bulk history entries in the database", async () => {
+ const authorId = 1n;
+
+ await service.pasteCanvasData(1, authorId, [
+ [0, 0, 1],
+ [1, 1, 2],
+ ]);
+
+ expect(
+ pixelReconciliationService.createBulkPlaceEntries,
+ ).toHaveBeenCalledWith({
+ canvasId: 1,
+ userId: authorId,
+ entries: [
+ { x: 0, y: 0, colorId: 1 },
+ { x: 1, y: 1, colorId: 2 },
+ ],
+ });
+ });
+
+ it("treats an empty paste as a no-op instead of crashing", async () => {
+ await expect(service.pasteCanvasData(1, 1n, [])).resolves.toBeUndefined();
+
+ expect(
+ pixelReconciliationService.createBulkPlaceEntries,
+ ).toHaveBeenCalledWith({
+ canvasId: 1,
+ userId: 1n,
+ entries: [],
+ });
+ });
+ });
+
+ describe("getUserCanvasCooldown", () => {
+ beforeEach(async () => {
+ await seedUsers();
+ });
+
+ it("throws an error for a nonexistent canvas", async () => {
+ await expect(service.getUserCanvasCooldown(9999, 1n)).rejects.toThrow(
+ NotFoundError,
+ );
+ });
+
+ it("returns null if the user has no cooldown", async () => {
+ expect(await service.getUserCanvasCooldown(1, 1n)).toBeNull();
+ });
+
+ it("returns the remaining cooldown time in milliseconds", async () => {
+ await prisma.cooldown.create({
+ data: {
+ userId: 1n,
+ canvasId: 1,
+ cooldownTime: new Date(Date.now() + 30_000),
+ },
+ });
+
+ const remaining = await service.getUserCanvasCooldown(1, 1n);
+ expect(remaining).toBeGreaterThan(0);
+ expect(remaining).toBeLessThanOrEqual(30_000);
+ });
+
+ it("returns null if the cooldown has elapsed", async () => {
+ await prisma.cooldown.create({
+ data: {
+ userId: 1n,
+ canvasId: 1,
+ cooldownTime: new Date(Date.now() - 1_000),
+ },
+ });
+
+ expect(await service.getUserCanvasCooldown(1, 1n)).toBeNull();
+ });
+ });
+});
+
+describe("CanvasService.computePasteArea", () => {
+ it("returns the bounding box of the pasted pixels", () => {
+ expect(
+ CanvasService.computePasteArea([
+ [3, 7, 1],
+ [1, 9, 2],
+ [5, 2, 3],
+ ]),
+ ).toEqual({ topLeftX: 1, topLeftY: 2, bottomRightX: 5, bottomRightY: 9 });
+ });
+
+ it("returns null for an empty paste", () => {
+ expect(CanvasService.computePasteArea([])).toBeNull();
+ });
+
+ it("handles a single pixel", () => {
+ expect(CanvasService.computePasteArea([[4, 8, 1]])).toEqual({
+ topLeftX: 4,
+ topLeftY: 8,
+ bottomRightX: 4,
+ bottomRightY: 8,
+ });
+ });
+
+ it("does not overflow the call stack for very large pastes", () => {
+ const data = Array.from(
+ { length: 500_000 },
+ (_, i) => [i % 1000, Math.floor(i / 1000), 1] as [number, number, number],
+ );
+
+ expect(() => CanvasService.computePasteArea(data)).not.toThrow();
+ expect(CanvasService.computePasteArea(data)).toEqual({
+ topLeftX: 0,
+ topLeftY: 0,
+ bottomRightX: 999,
+ bottomRightY: 499,
+ });
+ });
+});
diff --git a/packages/backend-nest/src/canvas/canvas.service.ts b/packages/backend-nest/src/canvas/canvas.service.ts
new file mode 100644
index 000000000..3f6050930
--- /dev/null
+++ b/packages/backend-nest/src/canvas/canvas.service.ts
@@ -0,0 +1,463 @@
+import type {
+ BlurpleEvent,
+ CanvasInfo,
+ CanvasSummary,
+} from "@blurple-canvas-web/types";
+import { Inject, Injectable, Logger } from "@nestjs/common";
+
+import type { CanvasModel } from "@/common/database/generated/models";
+import { PrismaService } from "@/common/database/prisma.service";
+import { NotFoundError } from "@/common/errors/not-found.error";
+import { UnprocessableError } from "@/common/errors/unprocessable.error";
+import {
+ type PlacementConfig,
+ placementConfig,
+} from "@/config/placement.config";
+import { BroadcastService } from "@/realtime/broadcast.service";
+import {
+ type BulkPlaceEntry,
+ PixelReconciliationService,
+} from "./pixel-reconciliation.service";
+
+export interface CreateCanvasParams {
+ name: string;
+ width: number;
+ height: number;
+ startCoordinates?: [number, number];
+ allColorsGlobal?: boolean;
+ cooldownDuration?: number;
+}
+
+export interface EditCanvasParams {
+ canvasId: number;
+ name?: string;
+ isLocked?: boolean;
+ allColorsGlobal?: boolean;
+ cooldownDuration?: number;
+}
+
+export interface PasteArea {
+ topLeftX: number;
+ topLeftY: number;
+ bottomRightX: number;
+ bottomRightY: number;
+}
+
+@Injectable()
+export class CanvasService {
+ private readonly logger = new Logger(CanvasService.name);
+
+ constructor(
+ private readonly prisma: PrismaService,
+ private readonly broadcastService: BroadcastService,
+ private readonly pixelReconciliationService: PixelReconciliationService,
+ @Inject(placementConfig.KEY)
+ private readonly placementCfg: PlacementConfig,
+ ) {}
+
+ /**
+ * Retrieves canvas summary info for all canvases, sorted by last pixel
+ * activity (most recent first).
+ *
+ * @param eventId If provided, only canvases for the specified event will be
+ * returned
+ */
+ async getCanvases(
+ eventId?: BlurpleEvent["id"],
+ ): Promise<(CanvasSummary & { cooldownDuration: number | null })[]> {
+ const canvases = await this.prisma.$kysely
+ .selectFrom("canvas")
+ .leftJoin("history", (join) =>
+ join
+ .onRef("history.canvasId", "=", "canvas.id")
+ .on("history.erasedAt", "is", null),
+ )
+ .select((eb) => [
+ "canvas.id",
+ "canvas.name",
+ "canvas.eventId",
+ "canvas.locked",
+ "canvas.width",
+ "canvas.height",
+ "canvas.cooldownLength",
+ eb.fn.max("history.timestamp").as("lastPixelTimestamp"),
+ ])
+ .$if(eventId !== undefined, (qb) =>
+ qb.where("canvas.eventId", "=", eventId as number),
+ )
+ .groupBy([
+ "canvas.id",
+ "canvas.name",
+ "canvas.eventId",
+ "canvas.locked",
+ "canvas.width",
+ "canvas.height",
+ ])
+ .orderBy("lastPixelTimestamp", (ob) => ob.desc().nullsLast())
+ .orderBy("canvas.id", "desc")
+ .execute();
+
+ return canvases.map((canvas) => ({
+ id: canvas.id,
+ name: canvas.name,
+ eventId: canvas.eventId,
+ isLocked: canvas.locked,
+ width: canvas.width,
+ height: canvas.height,
+ cooldownDuration: canvas.cooldownLength,
+ }));
+ }
+
+ /**
+ * Retrieves the canvas info of the default canvas ID defined in the
+ * database.
+ */
+ async getCurrentCanvasInfo(): Promise {
+ return this.getCanvasInfo(await this.getDefaultCanvasId());
+ }
+
+ async getCanvasInfo(canvasId: number): Promise {
+ const canvas = await this.prisma.canvas.findFirst({
+ select: {
+ id: true,
+ name: true,
+ width: true,
+ height: true,
+ startCoordinates: true,
+ locked: true,
+ eventId: true,
+ cooldownLength: true,
+ allColorsGlobal: true,
+ },
+ where: {
+ id: canvasId,
+ },
+ });
+
+ if (!canvas) {
+ throw new NotFoundError(`There is no canvas with ID ${canvasId}`);
+ }
+
+ return this.canvasToCanvasInfo(canvas);
+ }
+
+ /** The default canvas ID defined in the database's `info` singleton row. */
+ async getDefaultCanvasId(): Promise {
+ const info = await this.prisma.info.findFirst({
+ select: { defaultCanvasId: true },
+ });
+
+ // To get rid of the nullable type from info. This should never happen
+ if (!info) {
+ throw new Error("The info table is empty! 😱");
+ }
+
+ return info.defaultCanvasId;
+ }
+
+ /**
+ * Gets the remaining cooldown time in milliseconds for the given user on
+ * the given canvas, or `null` when there is no active cooldown.
+ */
+ async getUserCanvasCooldown(
+ canvasId: number,
+ userId: bigint,
+ ): Promise {
+ const canvas = await this.prisma.canvas.findFirst({
+ where: { id: canvasId },
+ select: { id: true },
+ });
+
+ if (!canvas) {
+ throw new NotFoundError(`There is no canvas with ID ${canvasId}`);
+ }
+
+ const cooldown = await this.prisma.cooldown.findFirst({
+ where: {
+ userId,
+ canvasId,
+ },
+ select: { cooldownTime: true },
+ });
+
+ if (!cooldown?.cooldownTime) {
+ return null;
+ }
+
+ const remaining = cooldown.cooldownTime.valueOf() - Date.now();
+ return remaining > 0 ? remaining : null;
+ }
+
+ async createCanvas({
+ name,
+ width,
+ height,
+ startCoordinates = [1, 1],
+ allColorsGlobal = false,
+ cooldownDuration = 15,
+ }: CreateCanvasParams): Promise {
+ const currentEventId = await this.getCurrentEventId();
+
+ const canvas = await this.prisma.canvas.create({
+ data: {
+ name,
+ width,
+ height,
+ eventId: currentEventId,
+ startCoordinates,
+ locked: true,
+ cooldownLength: cooldownDuration,
+ allColorsGlobal,
+ },
+ });
+
+ await this.createCanvasPixelEntries(canvas.id, width, height);
+
+ this.broadcastService.broadcastCanvasInfo(this.canvasToCanvasInfo(canvas));
+
+ return canvas;
+ }
+
+ async editCanvas({
+ canvasId,
+ name,
+ isLocked,
+ allColorsGlobal,
+ cooldownDuration,
+ }: EditCanvasParams): Promise {
+ const canvas = await this.prisma.canvas.update({
+ where: {
+ id: canvasId,
+ },
+ data: {
+ name,
+ locked: isLocked,
+ cooldownLength: cooldownDuration,
+ allColorsGlobal,
+ },
+ });
+
+ this.broadcastService.broadcastCanvasInfo(this.canvasToCanvasInfo(canvas));
+
+ return canvas;
+ }
+
+ /**
+ * Bulk-pastes `[x, y, colorId]` triples onto a canvas: validates the data
+ * against the canvas bounds and the event palette, then writes the history
+ * entries and reconciles the pixels.
+ */
+ /**
+ * Bounding box of a paste, or null for an empty paste. Computed in a single
+ * pass: spreading `data` into Math.min/Math.max overflows the call stack for
+ * large pastes.
+ */
+ static computePasteArea(
+ data: readonly [number, number, number][],
+ ): PasteArea | null {
+ if (data.length === 0) {
+ return null;
+ }
+
+ let topLeftX = Infinity;
+ let topLeftY = Infinity;
+ let bottomRightX = -Infinity;
+ let bottomRightY = -Infinity;
+
+ for (const [x, y] of data) {
+ if (x < topLeftX) topLeftX = x;
+ if (y < topLeftY) topLeftY = y;
+ if (x > bottomRightX) bottomRightX = x;
+ if (y > bottomRightY) bottomRightY = y;
+ }
+
+ return { topLeftX, topLeftY, bottomRightX, bottomRightY };
+ }
+
+ async pasteCanvasData(
+ canvasId: number,
+ authorId: bigint,
+ data: [number, number, number][],
+ ): Promise {
+ const canvas = await this.prisma.canvas.findFirst({
+ where: { id: canvasId },
+ });
+
+ if (!canvas) {
+ throw new NotFoundError(`There is no canvas with ID ${canvasId}`);
+ }
+
+ if (!canvas.eventId) {
+ throw new UnprocessableError(
+ `Canvas with ID ${canvasId} is not associated with an event`,
+ );
+ }
+
+ // The event palette: global colours plus the partner colours of the
+ // canvas's event (same filter as the old `getEventPalette`).
+ const colors = await this.prisma.color.findMany({
+ select: { id: true },
+ where: {
+ OR: [
+ { global: true },
+ { participations: { some: { eventId: canvas.eventId } } },
+ ],
+ },
+ });
+
+ // ~~~ Validation ~~~
+
+ const entries = data.map(
+ ([x, y, colorId]): BulkPlaceEntry => ({
+ x,
+ y,
+ colorId,
+ }),
+ );
+
+ const area = CanvasService.computePasteArea(data);
+
+ if (
+ area &&
+ (area.topLeftX < 0 ||
+ area.topLeftY < 0 ||
+ area.bottomRightX >= canvas.width ||
+ area.bottomRightY >= canvas.height)
+ ) {
+ throw new Error(
+ `Data contains coordinates that are out of bounds for canvas with ID ${canvasId}`,
+ );
+ }
+
+ const uniqueColors = Array.from(
+ new Set(entries.map(({ colorId }) => colorId)),
+ );
+ const invalidColorIds = uniqueColors.filter(
+ (colorId) => !colors.some((color) => color.id === colorId),
+ );
+
+ if (invalidColorIds.length > 0) {
+ const formatter = new Intl.ListFormat();
+ throw new Error(
+ `Data contains color IDs that are not in the event palette: ${formatter.format(invalidColorIds.map((id) => id.toString()))}`,
+ );
+ }
+
+ // ~~~ Execution ~~~
+
+ await this.prisma.user.upsert({
+ where: { id: authorId },
+ create: { id: authorId },
+ update: {},
+ });
+
+ await this.pixelReconciliationService.createBulkPlaceEntries({
+ canvasId,
+ userId: authorId,
+ entries,
+ });
+ }
+
+ private async createCanvasPixelEntries(
+ canvasId: number,
+ width: number,
+ height: number,
+ ): Promise {
+ const pixelsData = [];
+ for (let y = 0; y < height; y++) {
+ for (let x = 0; x < width; x++) {
+ pixelsData.push({
+ canvasId,
+ x,
+ y,
+ colorId: 1, // Defaults to blank color (ID #1)
+ });
+ }
+ }
+
+ this.logger.log(
+ `Creating ${pixelsData.length} pixel entries for canvas ${canvasId}`,
+ );
+
+ // Insert pixels in batches to avoid overwhelming the database
+ const batchSize = 10_000;
+ for (let i = 0; i < pixelsData.length; i += batchSize) {
+ const batch = pixelsData.slice(i, i + batchSize);
+ this.logger.log(
+ `Inserting pixels ${i} to ${i + batch.length} for canvas ${canvasId}`,
+ );
+ await this.prisma.pixel.createMany({
+ data: batch,
+ });
+ }
+ }
+
+ /**
+ * Whether the given canvas belongs to the current event. Used by the
+ * moderator history-erase policy guard (admins bypass it via the force
+ * endpoint).
+ */
+ async isCanvasInCurrentEvent(canvasId: number): Promise {
+ const canvas = await this.prisma.canvas.findUnique({
+ where: { id: canvasId },
+ select: { eventId: true },
+ });
+
+ if (!canvas) {
+ throw new NotFoundError(`There is no canvas with ID ${canvasId}`);
+ }
+
+ const currentEventId = await this.getCurrentEventId();
+ return canvas.eventId === currentEventId;
+ }
+
+ private async getCurrentEventId(): Promise {
+ const info = await this.prisma.info.findFirst({
+ select: {
+ currentEvent: { select: { id: true } },
+ },
+ });
+
+ if (!info) {
+ throw new Error("The info table is empty! 😱");
+ }
+
+ if (!info.currentEvent) {
+ // The `current_event_id` value is not a valid ID in the `event` table
+ throw new NotFoundError("Can’t find the current event");
+ }
+
+ return info.currentEvent.id;
+ }
+
+ private canvasToCanvasInfo(
+ canvas: Pick<
+ CanvasModel,
+ | "id"
+ | "name"
+ | "width"
+ | "height"
+ | "startCoordinates"
+ | "locked"
+ | "eventId"
+ | "cooldownLength"
+ | "allColorsGlobal"
+ >,
+ ): CanvasInfo {
+ return {
+ id: canvas.id,
+ name: canvas.name,
+ width: canvas.width,
+ height: canvas.height,
+ startCoordinates: [
+ canvas.startCoordinates[0],
+ canvas.startCoordinates[1],
+ ],
+ isLocked: canvas.locked,
+ eventId: canvas.eventId,
+ webPlacingEnabled: this.placementCfg.webPlacingEnabled,
+ allColorsGlobal: canvas.allColorsGlobal,
+ cooldownDuration: canvas.cooldownLength,
+ };
+ }
+}
diff --git a/packages/backend-nest/src/canvas/export.service.spec.ts b/packages/backend-nest/src/canvas/export.service.spec.ts
new file mode 100644
index 000000000..07db84a5e
--- /dev/null
+++ b/packages/backend-nest/src/canvas/export.service.spec.ts
@@ -0,0 +1,209 @@
+import fs from "node:fs";
+import os from "node:os";
+import path from "node:path";
+import { Test, type TestingModule } from "@nestjs/testing";
+import sharp from "sharp";
+
+import { DatabaseModule } from "@/common/database/database.module";
+import { BadRequestError } from "@/common/errors/bad-request.error";
+import { appConfig } from "@/config/app.config";
+import { AppConfigModule } from "@/config/config.module";
+import { seedCanvases } from "@/test/seed/canvases";
+import { seedColors } from "@/test/seed/colors";
+import { seedEvents } from "@/test/seed/events";
+import { seedPixels } from "@/test/seed/pixels";
+import { CanvasCacheService } from "./canvas-cache.service";
+import { ExportService } from "./export.service";
+
+// The seeded 2x2 canvases are laid out as:
+// [ blank, blurple ]
+// [ red, blank ]
+const BLANK = [88, 101, 242, 127] as const;
+const BLURPLE = [88, 101, 242, 255] as const;
+const RED = [234, 35, 40, 255] as const;
+
+// Resizing premultiplies the alpha channel, so semi-transparent pixels pick
+// up a rounding error — the same as the old backend's sharp pipeline.
+const BLANK_SCALED = [86, 100, 240, 127] as const;
+
+async function streamToBuffer(stream: NodeJS.ReadableStream): Promise {
+ const chunks: Buffer[] = [];
+ for await (const chunk of stream) {
+ chunks.push(typeof chunk === "string" ? Buffer.from(chunk) : chunk);
+ }
+ return Buffer.concat(chunks);
+}
+
+/** Decodes a PNG into a list of `[r, g, b, a]` pixels. */
+async function decodePng(
+ png: Buffer,
+): Promise<{ width: number; height: number; pixels: number[][] }> {
+ const { data, info } = await sharp(png)
+ .ensureAlpha()
+ .raw()
+ .toBuffer({ resolveWithObject: true });
+
+ const pixels: number[][] = [];
+ for (let index = 0; index < data.length; index += 4) {
+ pixels.push([...data.subarray(index, index + 4)]);
+ }
+
+ return { width: info.width, height: info.height, pixels };
+}
+
+describe("ExportService", () => {
+ let moduleRef: TestingModule;
+ let service: ExportService;
+ let cacheService: CanvasCacheService;
+ let canvasesPath: string;
+
+ beforeEach(async () => {
+ canvasesPath = await fs.promises.mkdtemp(
+ path.join(os.tmpdir(), "canvas-export-"),
+ );
+
+ moduleRef = await Test.createTestingModule({
+ imports: [AppConfigModule, DatabaseModule],
+ providers: [CanvasCacheService, ExportService],
+ })
+ .overrideProvider(appConfig.KEY)
+ .useValue({
+ environment: "test",
+ port: 3001,
+ frontendUrl: "http://localhost:3000",
+ paths: { root: canvasesPath, canvases: canvasesPath },
+ })
+ .compile();
+
+ service = moduleRef.get(ExportService);
+ cacheService = moduleRef.get(CanvasCacheService);
+
+ await seedEvents();
+ await seedCanvases();
+ await seedColors();
+ await seedPixels();
+ });
+
+ afterEach(async () => {
+ await moduleRef.close();
+ await fs.promises.rm(canvasesPath, { recursive: true, force: true });
+ });
+
+ it("exports the full bounds of an unlocked canvas", async () => {
+ const stream = await service.exportCanvasBoundsAsStream({
+ canvasId: 1,
+ x0: 0,
+ y0: 0,
+ x1: 2,
+ y1: 2,
+ });
+
+ const decoded = await decodePng(await streamToBuffer(stream));
+
+ expect(decoded).toStrictEqual({
+ width: 2,
+ height: 2,
+ pixels: [BLANK, BLURPLE, RED, BLANK],
+ });
+ });
+
+ it("exports a cropped region of an unlocked canvas", async () => {
+ const stream = await service.exportCanvasBoundsAsStream({
+ canvasId: 1,
+ x0: 1,
+ y0: 0,
+ x1: 2,
+ y1: 1,
+ });
+
+ const decoded = await decodePng(await streamToBuffer(stream));
+
+ expect(decoded).toStrictEqual({
+ width: 1,
+ height: 1,
+ pixels: [BLURPLE],
+ });
+ });
+
+ it("scales an unlocked export with nearest-neighbour", async () => {
+ const stream = await service.exportCanvasBoundsAsStream({
+ canvasId: 1,
+ x0: 0,
+ y0: 1,
+ x1: 2,
+ y1: 2,
+ scale: 2,
+ });
+
+ const png = await streamToBuffer(stream);
+ const decoded = await decodePng(png);
+
+ // The [red, blank] row, doubled in both directions.
+ const scaledRow = [RED, RED, BLANK_SCALED, BLANK_SCALED];
+ expect(decoded).toStrictEqual({
+ width: 4,
+ height: 2,
+ pixels: [...scaledRow, ...scaledRow],
+ });
+
+ expect(await sharp(png).metadata()).toMatchObject({
+ density: 144,
+ icc: expect.any(Buffer),
+ });
+ });
+
+ it("extracts the crop from the materialised file of a locked canvas", async () => {
+ // Prime the cache so the locked canvas files exist on disk.
+ await cacheService.getCanvasPng(9);
+ const cached = await cacheService.getCanvasPng(9);
+ expect(cached.isLocked).toBe(true);
+
+ const stream = await service.exportCanvasBoundsAsStream({
+ canvasId: 9,
+ x0: 0,
+ y0: 1,
+ x1: 2,
+ y1: 2,
+ scale: 2,
+ });
+
+ const png = await streamToBuffer(stream);
+ const decoded = await decodePng(png);
+
+ const scaledRow = [RED, RED, BLANK_SCALED, BLANK_SCALED].map((color) => [
+ ...color,
+ ]);
+ expect(decoded).toStrictEqual({
+ width: 4,
+ height: 2,
+ pixels: [...scaledRow, ...scaledRow],
+ });
+
+ expect(await sharp(png).metadata()).toMatchObject({
+ density: 144,
+ icc: expect.any(Buffer),
+ });
+ });
+
+ it("rejects empty crop dimensions", async () => {
+ await expect(
+ service.exportCanvasBoundsAsStream({
+ canvasId: 1,
+ x0: 1,
+ y0: 0,
+ x1: 1,
+ y1: 1,
+ }),
+ ).rejects.toThrow(BadRequestError);
+
+ await expect(
+ service.exportCanvasBoundsAsStream({
+ canvasId: 1,
+ x0: 0,
+ y0: 1,
+ x1: 1,
+ y1: 0,
+ }),
+ ).rejects.toThrow(BadRequestError);
+ });
+});
diff --git a/packages/backend-nest/src/canvas/export.service.ts b/packages/backend-nest/src/canvas/export.service.ts
new file mode 100644
index 000000000..28cb7192d
--- /dev/null
+++ b/packages/backend-nest/src/canvas/export.service.ts
@@ -0,0 +1,153 @@
+import { createReadStream } from "node:fs";
+import { PassThrough } from "node:stream";
+import { pipeline } from "node:stream/promises";
+import {
+ type CanvasExportScale,
+ type CanvasInfo,
+ DEFAULT_CANVAS_EXPORT_SCALE,
+} from "@blurple-canvas-web/types";
+import { Injectable } from "@nestjs/common";
+import sharp from "sharp";
+
+import { BadRequestError } from "@/common/errors/bad-request.error";
+import {
+ CanvasCacheService,
+ type UnlockedCanvas,
+} from "./canvas-cache.service";
+
+@Injectable()
+export class ExportService {
+ constructor(private readonly canvasCacheService: CanvasCacheService) {}
+
+ /**
+ * Streams an unlocked canvas as a PNG, resized with nearest-neighbour when
+ * a scale above 1× is requested.
+ */
+ unlockedCanvasToPngStream(
+ unlockedCanvas: UnlockedCanvas,
+ scale: CanvasExportScale = DEFAULT_CANVAS_EXPORT_SCALE,
+ ): NodeJS.ReadableStream {
+ const rawBuffer = this.canvasCacheService.pixelsToRgbaBuffer(
+ unlockedCanvas.pixels,
+ unlockedCanvas.width,
+ unlockedCanvas.height,
+ );
+
+ const image = sharp(rawBuffer, {
+ raw: {
+ width: unlockedCanvas.width,
+ height: unlockedCanvas.height,
+ channels: 4,
+ },
+ });
+
+ const resized =
+ scale === 1 ? image : (
+ image.resize({
+ width: unlockedCanvas.width * scale,
+ height: unlockedCanvas.height * scale,
+ kernel: sharp.kernel.nearest,
+ })
+ );
+
+ return CanvasCacheService.withPngMetadata(resized, scale).png();
+ }
+
+ /**
+ * Streams a cropped region of a canvas as a PNG. Locked canvases are
+ * extracted from the materialised file at the requested scale; unlocked
+ * canvases are cropped from the in-memory pixel buffer and resized with
+ * nearest-neighbour.
+ */
+ async exportCanvasBoundsAsStream({
+ canvasId,
+ x0,
+ y0,
+ x1,
+ y1,
+ scale = DEFAULT_CANVAS_EXPORT_SCALE,
+ }: {
+ canvasId: CanvasInfo["id"];
+ x0: number;
+ y0: number;
+ x1: number;
+ y1: number;
+ scale?: CanvasExportScale;
+ }): Promise {
+ const width = x1 - x0;
+ const height = y1 - y0;
+
+ if (width <= 0 || height <= 0) {
+ throw new BadRequestError("Invalid crop dimensions");
+ }
+
+ const cached = await this.canvasCacheService.getCanvasPng(canvasId);
+
+ if (cached.isLocked) {
+ const canvasPath = cached.canvasPaths[scale];
+
+ if (!canvasPath) {
+ throw new Error(
+ `There is no cached canvas file for canvas ${canvasId} at ${scale}x`,
+ );
+ }
+
+ const cropX = x0 * scale;
+ const cropY = y0 * scale;
+ const cropWidth = width * scale;
+ const cropHeight = height * scale;
+
+ const fileStream = createReadStream(canvasPath);
+ const transformer = CanvasCacheService.withPngMetadata(
+ sharp().extract({
+ left: cropX,
+ top: cropY,
+ width: cropWidth,
+ height: cropHeight,
+ }),
+ scale,
+ ).png();
+
+ const output = new PassThrough();
+
+ pipeline(fileStream, transformer, output).catch((error: unknown) => {
+ output.destroy(error as Error);
+ });
+
+ return output;
+ }
+
+ const unlocked = cached;
+ const rawBuffer = this.canvasCacheService.pixelsToRgbaBuffer(
+ unlocked.pixels,
+ unlocked.width,
+ unlocked.height,
+ );
+
+ const source = sharp(rawBuffer, {
+ raw: { width: unlocked.width, height: unlocked.height, channels: 4 },
+ });
+
+ const cropped = source.extract({ left: x0, top: y0, width, height });
+ const resized =
+ scale === 1 ? cropped : (
+ cropped.resize({
+ width: width * scale,
+ height: height * scale,
+ kernel: sharp.kernel.nearest,
+ })
+ );
+
+ const transformer = CanvasCacheService.withPngMetadata(
+ resized,
+ scale,
+ ).png();
+ const output = new PassThrough();
+
+ pipeline(transformer, output).catch((error: unknown) => {
+ output.destroy(error as Error);
+ });
+
+ return output;
+ }
+}
diff --git a/packages/backend-nest/src/canvas/pixel-reconciliation.service.spec.ts b/packages/backend-nest/src/canvas/pixel-reconciliation.service.spec.ts
new file mode 100644
index 000000000..42b608870
--- /dev/null
+++ b/packages/backend-nest/src/canvas/pixel-reconciliation.service.spec.ts
@@ -0,0 +1,105 @@
+import { Test, type TestingModule } from "@nestjs/testing";
+
+import { DatabaseModule } from "@/common/database/database.module";
+import { AppConfigModule } from "@/config/config.module";
+import { BroadcastService } from "@/realtime/broadcast.service";
+import { testPrisma as prisma } from "@/test/database";
+import { seedAll } from "@/test/seed";
+import { CanvasCacheService } from "./canvas-cache.service";
+import { PixelReconciliationService } from "./pixel-reconciliation.service";
+
+const broadcastService = {
+ broadcastPixel: vi.fn(),
+ broadcastPixelsBulk: vi.fn(),
+ broadcastCanvasInfo: vi.fn(),
+};
+
+describe("PixelReconciliationService", () => {
+ let moduleRef: TestingModule;
+ let service: PixelReconciliationService;
+
+ beforeAll(async () => {
+ moduleRef = await Test.createTestingModule({
+ imports: [AppConfigModule, DatabaseModule],
+ providers: [
+ PixelReconciliationService,
+ CanvasCacheService,
+ { provide: BroadcastService, useValue: broadcastService },
+ ],
+ }).compile();
+ await moduleRef.init();
+
+ service = moduleRef.get(PixelReconciliationService);
+ });
+
+ afterAll(async () => {
+ await moduleRef.close();
+ });
+
+ beforeEach(async () => {
+ vi.clearAllMocks();
+ await seedAll();
+
+ // An erased placement by user 9 on each canvas, on a cell that otherwise
+ // has no live history (canvas 1 (1,1) and canvas 9 (1,1)).
+ await prisma.history.createMany({
+ data: [
+ {
+ canvasId: 1,
+ userId: 9n,
+ x: 1,
+ y: 1,
+ colorId: 2,
+ timestamp: new Date("2024-01-01T00:00:00.000Z"),
+ erasedAt: new Date("2024-01-02T00:00:00.000Z"),
+ },
+ {
+ canvasId: 9,
+ userId: 9n,
+ x: 1,
+ y: 1,
+ colorId: 2,
+ timestamp: new Date("2024-01-03T00:00:00.000Z"),
+ erasedAt: new Date("2024-01-04T00:00:00.000Z"),
+ },
+ ],
+ });
+ });
+
+ describe("restoreErasedHistory", () => {
+ it("un-erases only the requested canvas and rebuilds its pixels", async () => {
+ await service.restoreErasedHistory([9n], [1]);
+
+ const restored = await prisma.history.findFirst({
+ where: { canvasId: 1, userId: 9n, x: 1, y: 1 },
+ });
+ expect(restored?.erasedAt).toBeNull();
+
+ const pixel = await prisma.pixel.findFirst({
+ where: { canvasId: 1, x: 1, y: 1 },
+ });
+ expect(pixel?.colorId).toBe(2);
+
+ // The other canvas's erased row is untouched.
+ const untouched = await prisma.history.findFirst({
+ where: { canvasId: 9, userId: 9n, x: 1, y: 1 },
+ });
+ expect(untouched?.erasedAt).toBeInstanceOf(Date);
+
+ expect(broadcastService.broadcastPixelsBulk).toHaveBeenCalledTimes(1);
+ });
+
+ it("does nothing when there are no erased rows to restore", async () => {
+ await service.restoreErasedHistory([1n], [1]);
+
+ expect(broadcastService.broadcastPixelsBulk).not.toHaveBeenCalled();
+ });
+
+ it("does nothing for empty inputs", async () => {
+ await service.restoreErasedHistory([], [1]);
+ await service.restoreErasedHistory([9n], []);
+
+ expect(broadcastService.broadcastPixelsBulk).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/packages/backend-nest/src/canvas/pixel-reconciliation.service.ts b/packages/backend-nest/src/canvas/pixel-reconciliation.service.ts
new file mode 100644
index 000000000..2eef07a02
--- /dev/null
+++ b/packages/backend-nest/src/canvas/pixel-reconciliation.service.ts
@@ -0,0 +1,248 @@
+import type { PixelColor, Point } from "@blurple-canvas-web/types";
+import { Injectable, Logger } from "@nestjs/common";
+import type { History } from "@/common/database/prisma.client";
+import { PrismaService } from "@/common/database/prisma.service";
+import { NotFoundError } from "@/common/errors/not-found.error";
+import { BroadcastService } from "@/realtime/broadcast.service";
+import { CanvasCacheService } from "./canvas-cache.service";
+
+export const BLANK_PIXEL_COLOR_ID = 1;
+
+const COORDINATE_CHUNK_SIZE = 500;
+
+export type BulkPlaceEntry = Pick;
+
+@Injectable()
+export class PixelReconciliationService {
+ private readonly logger = new Logger(PixelReconciliationService.name);
+
+ constructor(
+ private readonly prisma: PrismaService,
+ private readonly canvasCacheService: CanvasCacheService,
+ private readonly broadcastService: BroadcastService,
+ ) {}
+
+ async createBulkPlaceEntries({
+ canvasId,
+ userId,
+ guildId,
+ timestamp,
+ entries,
+ }: {
+ canvasId: number;
+ userId: bigint;
+ guildId?: bigint;
+ timestamp?: Date;
+ entries: BulkPlaceEntry[];
+ }): Promise {
+ this.logger.log(
+ `Creating ${entries.length} history entries for canvas ${canvasId}`,
+ );
+
+ const batchSize = 10_000;
+ for (let i = 0; i < entries.length; i += batchSize) {
+ const batch = entries.slice(i, i + batchSize);
+ this.logger.log(
+ `Inserting batch ${i / batchSize + 1} (${batch.length} entries)`,
+ );
+
+ const data = batch.map((entry) => ({
+ canvasId,
+ userId,
+ guildId,
+ colorId: entry.colorId,
+ x: entry.x,
+ y: entry.y,
+ timestamp: timestamp ?? new Date(),
+ }));
+ await this.prisma.history.createMany({
+ data,
+ });
+ }
+
+ await this.restorePixelsAfterHistoryModification(canvasId, entries);
+ }
+
+ /**
+ * Un-erases (`erasedAt = NULL`) every history row matching the given
+ * users × canvases, then rebuilds the affected pixels from the now-live
+ * history. Invoked when a user is removed from the blocklist with
+ * `shouldRestoreHistoryForCanvasId`.
+ *
+ * This lives alongside the other history-write + reconcile operations rather
+ * than in the history module, so the blocklist can depend on it without
+ * creating a history ⇄ blocklist module cycle (the history module erases via
+ * the blocklist to block authors).
+ */
+ async restoreErasedHistory(
+ userIds: Iterable,
+ canvasIds: Iterable,
+ ): Promise {
+ const userIdsArray = Array.isArray(userIds) ? userIds : Array.from(userIds);
+ const canvasIdsArray =
+ Array.isArray(canvasIds) ? canvasIds : Array.from(canvasIds);
+
+ if (userIdsArray.length === 0 || canvasIdsArray.length === 0) {
+ return;
+ }
+
+ const restoredEntries = await this.prisma.$transaction((tx) =>
+ tx.$kysely
+ .updateTable("history")
+ .set({ erasedAt: null })
+ .where("userId", "in", userIdsArray)
+ .where("canvasId", "in", canvasIdsArray)
+ .where("erasedAt", "is not", null)
+ .returning(["canvasId", "x", "y", "timestamp"])
+ .execute(),
+ );
+
+ if (restoredEntries.length === 0) {
+ return;
+ }
+
+ const coordinatesByCanvas = new Map();
+ for (const entry of restoredEntries) {
+ const coordinates = coordinatesByCanvas.get(entry.canvasId) ?? [];
+ coordinates.push({ x: entry.x, y: entry.y });
+ coordinatesByCanvas.set(entry.canvasId, coordinates);
+ }
+
+ await Promise.all(
+ Array.from(coordinatesByCanvas.entries(), ([canvasId, coordinates]) =>
+ this.restorePixelsAfterHistoryModification(canvasId, coordinates),
+ ),
+ );
+
+ // TODO: snapshots
+ }
+
+ /**
+ * Rebuilds the current pixel state for the given coordinates after bulk
+ * history operations: the latest non-erased entry per coordinate wins, an
+ * empty history means the blank colour. Coordinates are processed in chunks
+ * to avoid hitting query size limits and ensure predictable performance for
+ * large erasures.
+ */
+ async restorePixelsAfterHistoryModification(
+ canvasId: number,
+ coordinates: Point[],
+ ): Promise {
+ const uniqueCoordinates = new Map();
+
+ for (const coordinate of coordinates) {
+ uniqueCoordinates.set(`${coordinate.x}:${coordinate.y}`, coordinate);
+ }
+
+ const blankColor = (await this.prisma.color.findUnique({
+ where: {
+ id: BLANK_PIXEL_COLOR_ID,
+ },
+ select: {
+ rgba: true,
+ },
+ })) as { rgba: PixelColor } | null;
+
+ if (!blankColor) {
+ throw new NotFoundError(
+ `There is no color with ID ${BLANK_PIXEL_COLOR_ID}`,
+ );
+ }
+
+ // Split coordinates into chunks to avoid unbounded OR clauses
+ const coordArray = Array.from(uniqueCoordinates.values());
+ const chunks: Point[][] = [];
+
+ for (let i = 0; i < coordArray.length; i += COORDINATE_CHUNK_SIZE) {
+ chunks.push(coordArray.slice(i, i + COORDINATE_CHUNK_SIZE));
+ }
+
+ const latestByCoord = new Map<
+ string,
+ Pick & {
+ color: { rgba: PixelColor };
+ }
+ >();
+
+ for (const chunk of chunks) {
+ const historyEntries = await this.prisma.history.findMany({
+ where: {
+ erasedAt: null,
+ canvasId,
+ OR: chunk.map((coordinate) => ({
+ x: coordinate.x,
+ y: coordinate.y,
+ })),
+ },
+ select: {
+ x: true,
+ y: true,
+ colorId: true,
+ timestamp: true,
+ id: true,
+ color: { select: { rgba: true } },
+ },
+ orderBy: [{ timestamp: "desc" }, { id: "desc" }],
+ });
+
+ // Reduce in memory to latest per coordinate
+ for (const entry of historyEntries) {
+ const key = `${entry.x}:${entry.y}`;
+ if (!latestByCoord.has(key)) {
+ latestByCoord.set(key, {
+ ...entry,
+ color: { rgba: entry.color.rgba as PixelColor },
+ });
+ }
+ }
+
+ // Group coordinates by colour ID for batch updates
+ const byColorId = new Map();
+ for (const coordinate of chunk) {
+ const key = `${coordinate.x}:${coordinate.y}`;
+ const latestEntry = latestByCoord.get(key);
+ const colorId = latestEntry?.colorId ?? BLANK_PIXEL_COLOR_ID;
+
+ const arr = byColorId.get(colorId);
+ if (arr) {
+ arr.push(coordinate);
+ } else {
+ byColorId.set(colorId, [coordinate]);
+ }
+ }
+
+ for (const [colorId, coords] of byColorId.entries()) {
+ await this.prisma.pixel.updateMany({
+ where: {
+ canvasId,
+ OR: coords.map((coordinate) => ({
+ x: coordinate.x,
+ y: coordinate.y,
+ })),
+ },
+ data: { colorId },
+ });
+ }
+ }
+
+ // Build bulk payload and update cache per-pixel
+ const pixels: { x: number; y: number; rgba: PixelColor }[] = [];
+ for (const coordinate of uniqueCoordinates.values()) {
+ const key = `${coordinate.x}:${coordinate.y}`;
+ const latestEntry = latestByCoord.get(key);
+ const pixelColor = latestEntry?.color.rgba ?? blankColor.rgba;
+
+ pixels.push({ x: coordinate.x, y: coordinate.y, rgba: pixelColor });
+
+ this.canvasCacheService.updateCachedCanvasPixel(
+ canvasId,
+ coordinate,
+ pixelColor,
+ );
+ }
+
+ if (pixels.length > 0) {
+ this.broadcastService.broadcastPixelsBulk(canvasId, { pixels });
+ }
+ }
+}
diff --git a/packages/backend-nest/src/common/api-exception.filter.spec.ts b/packages/backend-nest/src/common/api-exception.filter.spec.ts
new file mode 100644
index 000000000..3494e2388
--- /dev/null
+++ b/packages/backend-nest/src/common/api-exception.filter.spec.ts
@@ -0,0 +1,232 @@
+import type { ArgumentsHost } from "@nestjs/common";
+import { HttpException, Logger } from "@nestjs/common";
+import { Test, type TestingModule } from "@nestjs/testing";
+import {
+ PrismaClientInitializationError,
+ PrismaClientKnownRequestError,
+ PrismaClientRustPanicError,
+} from "@prisma/client/runtime/client";
+import type { Response } from "express";
+import { ZodSerializationException } from "nestjs-zod";
+import {
+ afterAll,
+ beforeAll,
+ beforeEach,
+ describe,
+ expect,
+ it,
+ vi,
+} from "vitest";
+import { z } from "zod";
+import { ApiExceptionFilter } from "./api-exception.filter";
+import { BadRequestError } from "./errors/bad-request.error";
+import { NotFoundError } from "./errors/not-found.error";
+
+type MockResponse = Response & {
+ headersSent: boolean;
+ status: ReturnType;
+ json: ReturnType;
+ destroy: ReturnType;
+};
+
+describe("ApiExceptionFilter", () => {
+ let moduleRef: TestingModule;
+ let filter: ApiExceptionFilter;
+ let response: MockResponse;
+ let host: ArgumentsHost;
+
+ beforeAll(async () => {
+ moduleRef = await Test.createTestingModule({
+ providers: [ApiExceptionFilter],
+ }).compile();
+ filter = moduleRef.get(ApiExceptionFilter);
+ });
+
+ afterAll(async () => {
+ await moduleRef.close();
+ });
+
+ beforeEach(() => {
+ response = {
+ headersSent: false,
+ status: vi.fn().mockReturnThis(),
+ json: vi.fn().mockReturnThis(),
+ destroy: vi.fn(),
+ } as unknown as MockResponse;
+ host = {
+ switchToHttp: () => ({ getResponse: () => response }),
+ } as ArgumentsHost;
+ });
+
+ it("renders an ApiError via its response-body mapping", () => {
+ filter.catch(new NotFoundError("not here"), host);
+
+ expect(response.status).toHaveBeenCalledWith(404);
+ expect(response.json).toHaveBeenCalledWith({ message: "not here" });
+ });
+
+ it("includes Zod issues from BadRequestError in the response body", () => {
+ filter.catch(
+ new BadRequestError("Invalid request data", [
+ { code: "custom", path: ["name"], message: "required" },
+ ]),
+ host,
+ );
+
+ expect(response.status).toHaveBeenCalledWith(400);
+ expect(response.json).toHaveBeenCalledWith({
+ message: "Invalid request data",
+ errors: [{ code: "custom", path: ["name"], message: "required" }],
+ });
+ });
+
+ it("always includes the errors array for BadRequestError, even when empty", () => {
+ filter.catch(new BadRequestError("Invalid request data"), host);
+
+ expect(response.json).toHaveBeenCalledWith({
+ message: "Invalid request data",
+ errors: [],
+ });
+ });
+
+ it("falls back to a generic 500 for unknown errors", () => {
+ const loggerErrorSpy = vi
+ .spyOn(Logger.prototype, "error")
+ .mockImplementation(() => {});
+
+ filter.catch(new Error("boom"), host);
+
+ expect(response.status).toHaveBeenCalledWith(500);
+ expect(response.json).toHaveBeenCalledWith({
+ message: "An unexpected error occurred",
+ });
+ expect(loggerErrorSpy).toHaveBeenCalled();
+ loggerErrorSpy.mockRestore();
+ });
+
+ it("returns a 503 for Prisma initialization failures", () => {
+ filter.catch(
+ new PrismaClientInitializationError(
+ "Can't reach database server",
+ "5.0.0",
+ ),
+ host,
+ );
+
+ expect(response.status).toHaveBeenCalledWith(503);
+ expect(response.json).toHaveBeenCalledWith({
+ message: "Database is unavailable",
+ });
+ });
+
+ it("returns a 503 for Prisma engine panics", () => {
+ filter.catch(
+ new PrismaClientRustPanicError("Query engine panicked", "5.0.0"),
+ host,
+ );
+
+ expect(response.status).toHaveBeenCalledWith(503);
+ expect(response.json).toHaveBeenCalledWith({
+ message: "Database is unavailable",
+ });
+ });
+
+ it("returns a 503 when an error message indicates the database is unreachable", () => {
+ filter.catch(
+ new Error("Can't reach database server at localhost:5432"),
+ host,
+ );
+
+ expect(response.status).toHaveBeenCalledWith(503);
+ expect(response.json).toHaveBeenCalledWith({
+ message: "Database is unavailable",
+ });
+ });
+
+ it("closes the connection when headers have already been sent", () => {
+ response.headersSent = true;
+
+ filter.catch(new NotFoundError("not here"), host);
+
+ expect(response.destroy).toHaveBeenCalled();
+ expect(response.status).not.toHaveBeenCalled();
+ expect(response.json).not.toHaveBeenCalled();
+ });
+
+ it("maps framework HttpExceptions onto the parity envelope", () => {
+ filter.catch(new HttpException("Cannot GET /nope", 404), host);
+
+ expect(response.status).toHaveBeenCalledWith(404);
+ expect(response.json).toHaveBeenCalledWith({ message: "Cannot GET /nope" });
+ });
+
+ it("maps a Prisma P2025 (record not found) onto a 404", () => {
+ filter.catch(
+ new PrismaClientKnownRequestError("Record to update not found.", {
+ code: "P2025",
+ clientVersion: "test",
+ }),
+ host,
+ );
+
+ expect(response.status).toHaveBeenCalledWith(404);
+ expect(response.json).toHaveBeenCalledWith({
+ message: "Resource not found",
+ });
+ });
+
+ it("maps a Prisma P2002 (unique violation) onto a 409", () => {
+ filter.catch(
+ new PrismaClientKnownRequestError("Unique constraint failed.", {
+ code: "P2002",
+ clientVersion: "test",
+ }),
+ host,
+ );
+
+ expect(response.status).toHaveBeenCalledWith(409);
+ expect(response.json).toHaveBeenCalledWith({
+ message: "A resource with these details already exists",
+ });
+ });
+
+ it("falls back to a generic 500 for unmapped Prisma error codes", () => {
+ const loggerErrorSpy = vi
+ .spyOn(Logger.prototype, "error")
+ .mockImplementation(() => {});
+
+ filter.catch(
+ new PrismaClientKnownRequestError("Value too long.", {
+ code: "P2000",
+ clientVersion: "test",
+ }),
+ host,
+ );
+
+ expect(response.status).toHaveBeenCalledWith(500);
+ expect(response.json).toHaveBeenCalledWith({
+ message: "An unexpected error occurred",
+ });
+ expect(loggerErrorSpy).toHaveBeenCalled();
+ loggerErrorSpy.mockRestore();
+ });
+
+ it("logs the ZodError and returns the parity 500 for serialization failures", () => {
+ const loggerErrorSpy = vi
+ .spyOn(Logger.prototype, "error")
+ .mockImplementation(() => {});
+ const parsed = z.object({ id: z.number() }).safeParse({ id: "nope" });
+ if (parsed.success) {
+ throw new Error("expected parse failure");
+ }
+
+ filter.catch(new ZodSerializationException(parsed.error), host);
+
+ expect(response.status).toHaveBeenCalledWith(500);
+ expect(response.json).toHaveBeenCalledWith({
+ message: "An unexpected error occurred",
+ });
+ expect(loggerErrorSpy).toHaveBeenCalledWith(parsed.error);
+ loggerErrorSpy.mockRestore();
+ });
+});
diff --git a/packages/backend-nest/src/common/api-exception.filter.ts b/packages/backend-nest/src/common/api-exception.filter.ts
new file mode 100644
index 000000000..9118aed8d
--- /dev/null
+++ b/packages/backend-nest/src/common/api-exception.filter.ts
@@ -0,0 +1,111 @@
+import {
+ type ArgumentsHost,
+ Catch,
+ type ExceptionFilter,
+ HttpException,
+ HttpStatus,
+ Logger,
+} from "@nestjs/common";
+import {
+ PrismaClientInitializationError,
+ PrismaClientKnownRequestError,
+ PrismaClientRustPanicError,
+} from "@prisma/client/runtime/client";
+import type { Response } from "express";
+import { ZodSerializationException } from "nestjs-zod";
+import { ApiError } from "./errors/api.error";
+
+@Catch()
+export class ApiExceptionFilter implements ExceptionFilter {
+ private readonly logger = new Logger(ApiExceptionFilter.name);
+ public catch(exception: unknown, host: ArgumentsHost): void {
+ const response = host.switchToHttp().getResponse();
+
+ // Mid-stream failure (e.g. streamed canvas images): headers are already
+ // out, so no JSON envelope can follow. Like Express's default error
+ // handler, close the connection so the client sees a truncated response
+ // rather than a cleanly-ended partial one.
+ if (response.headersSent) {
+ response.destroy();
+ return;
+ }
+
+ if (exception instanceof ApiError) {
+ response.status(exception.status).json(exception.toResponseBody());
+ return;
+ }
+
+ // Thrown by ZodSerializerInterceptor when a response body fails its
+ // schema. This is an HttpException subclass, so it must be handled before
+ // the generic HttpException branch — Nest's default "Internal Server
+ // Error" message would break the parity envelope.
+ if (exception instanceof ZodSerializationException) {
+ this.logger.error(exception.getZodError());
+ response.status(500).json({ message: "An unexpected error occurred" });
+ return;
+ }
+
+ if (ApiExceptionFilter.isDatabaseUnavailableError(exception)) {
+ response.status(503).json({ message: "Database is unavailable" });
+ return;
+ }
+
+ const mappedPrismaError =
+ ApiExceptionFilter.mapKnownPrismaRequestError(exception);
+ if (mappedPrismaError) {
+ response
+ .status(mappedPrismaError.status)
+ .json({ message: mappedPrismaError.message });
+ return;
+ }
+
+ // Framework-internal exceptions (unknown-route 404s, body-parser 400s,
+ // ...) are mapped onto the same envelope.
+ if (exception instanceof HttpException) {
+ response
+ .status(exception.getStatus())
+ .json({ message: exception.message });
+ return;
+ }
+
+ this.logger.error(exception);
+ response.status(500).json({ message: "An unexpected error occurred" });
+ }
+
+ private static mapKnownPrismaRequestError(
+ error: unknown,
+ ): { status: number; message: string } | null {
+ if (!(error instanceof PrismaClientKnownRequestError)) {
+ return null;
+ }
+
+ switch (error.code) {
+ case "P2025": // An operation failed because it depends on a missing record
+ return { status: HttpStatus.NOT_FOUND, message: "Resource not found" };
+ case "P2002": // Unique constraint violation
+ return {
+ status: HttpStatus.CONFLICT,
+ message: "A resource with these details already exists",
+ };
+ default:
+ return null;
+ }
+ }
+
+ private static isDatabaseUnavailableError(error: unknown): boolean {
+ if (
+ error instanceof PrismaClientInitializationError ||
+ error instanceof PrismaClientRustPanicError
+ ) {
+ return true;
+ }
+
+ if (error instanceof Error) {
+ return /can't reach database server|database server is not reachable|ECONNREFUSED/i.test(
+ error.message,
+ );
+ }
+
+ return false;
+ }
+}
diff --git a/packages/backend-nest/src/common/bigint-json.ts b/packages/backend-nest/src/common/bigint-json.ts
new file mode 100644
index 000000000..f260f1dc9
--- /dev/null
+++ b/packages/backend-nest/src/common/bigint-json.ts
@@ -0,0 +1,13 @@
+// Make BigInt JSON serializable, so Discord snowflakes (user/guild IDs) serialise
+// as strings. See: https://github.com/GoogleChromeLabs/jsbi/issues/30
+declare global {
+ interface BigInt {
+ toJSON(): string;
+ }
+}
+
+BigInt.prototype.toJSON = function (this: bigint): string {
+ return this.toString();
+};
+
+export {};
diff --git a/packages/backend-nest/src/common/database/database.module.ts b/packages/backend-nest/src/common/database/database.module.ts
new file mode 100644
index 000000000..59b2cfe8d
--- /dev/null
+++ b/packages/backend-nest/src/common/database/database.module.ts
@@ -0,0 +1,10 @@
+import { Global, Module } from "@nestjs/common";
+
+import { PrismaService } from "./prisma.service";
+
+@Global()
+@Module({
+ providers: [PrismaService],
+ exports: [PrismaService],
+})
+export class DatabaseModule {}
diff --git a/packages/backend-nest/src/common/database/prisma.client.ts b/packages/backend-nest/src/common/database/prisma.client.ts
new file mode 100644
index 000000000..36c1fa28d
--- /dev/null
+++ b/packages/backend-nest/src/common/database/prisma.client.ts
@@ -0,0 +1,44 @@
+import { PrismaPg } from "@prisma/adapter-pg";
+import {
+ CamelCasePlugin,
+ Kysely,
+ PostgresAdapter,
+ PostgresIntrospector,
+ PostgresQueryCompiler,
+} from "kysely";
+import kyselyExtension from "prisma-extension-kysely";
+import { PrismaClient } from "./generated/client";
+import type { DB } from "./kysely/types";
+
+/**
+ * Builds the dual-API client: Prisma (pg adapter) extended with a fully typed
+ * `$kysely` instance that shares Prisma's connection (and transactions).
+ *
+ * The schema maps camelCase fields onto the snake_case database columns; the
+ * CamelCasePlugin applies the same convention to `$kysely` queries.
+ * `underscoreBeforeDigits` is required for columns like `frame.x_0` (`x0` in
+ * the client API). Caveat: `session.expiresAt` is genuinely camelCase in the
+ * database, so it must not be queried through `$kysely`.
+ */
+export function createPrismaClient(databaseUrl: string) {
+ const adapter = new PrismaPg(databaseUrl);
+
+ return new PrismaClient({ adapter }).$extends(
+ kyselyExtension({
+ kysely: (driver) =>
+ new Kysely({
+ dialect: {
+ createDriver: () => driver,
+ createAdapter: () => new PostgresAdapter(),
+ createIntrospector: (db) => new PostgresIntrospector(db),
+ createQueryCompiler: () => new PostgresQueryCompiler(),
+ },
+ plugins: [new CamelCasePlugin({ underscoreBeforeDigits: true })],
+ }),
+ }),
+ );
+}
+
+export type ExtendedPrismaClient = ReturnType;
+
+export * from "./generated/client";
diff --git a/packages/backend-nest/src/common/database/prisma.service.spec.ts b/packages/backend-nest/src/common/database/prisma.service.spec.ts
new file mode 100644
index 000000000..1403c1521
--- /dev/null
+++ b/packages/backend-nest/src/common/database/prisma.service.spec.ts
@@ -0,0 +1,93 @@
+import { Test, TestingModule } from "@nestjs/testing";
+
+import { DatabaseModule } from "@/common/database/database.module";
+import { PrismaService } from "@/common/database/prisma.service";
+import { AppConfigModule } from "@/config/config.module";
+
+// No provider override: the test harness (src/test/database.ts) transparently
+// backs every PrismaService with the per-test transaction.
+describe("PrismaService", () => {
+ let prisma: PrismaService;
+ let moduleRef: TestingModule;
+
+ beforeAll(async () => {
+ moduleRef = await Test.createTestingModule({
+ imports: [AppConfigModule, DatabaseModule],
+ }).compile();
+ await moduleRef.init();
+
+ prisma = moduleRef.get(PrismaService);
+ });
+
+ afterAll(async () => {
+ await moduleRef.close();
+ });
+
+ it("round-trips camelCase writes and reads through Prisma and $kysely", async () => {
+ await prisma.event.create({ data: { id: 9001, name: "Test Event" } });
+ await prisma.canvas.create({
+ data: {
+ id: 9001,
+ name: "Test Canvas",
+ eventId: 9001,
+ width: 2,
+ height: 2,
+ cooldownLength: 10,
+ },
+ });
+ await prisma.user.create({ data: { id: 9001n } });
+ await prisma.color.create({
+ data: { id: 9001, code: "test", name: "Test", rgba: [1, 2, 3, 255] },
+ });
+
+ // snake_case columns with digits (x_0 ...) round-trip as x0 etc.
+ await prisma.frame.create({
+ data: {
+ id: "TEST01",
+ canvasId: 9001,
+ ownerUserId: 9001n,
+ name: "Test Frame",
+ x0: 0,
+ x1: 1,
+ y0: 0,
+ y1: 1,
+ },
+ });
+ const frame = await prisma.$kysely
+ .selectFrom("frame")
+ .select(["id", "canvasId", "ownerUserId", "x0", "x1", "y0", "y1"])
+ .where("id", "=", "TEST01")
+ .executeTakeFirstOrThrow();
+ expect(frame).toEqual({
+ id: "TEST01",
+ canvasId: 9001,
+ ownerUserId: 9001n,
+ x0: 0,
+ x1: 1,
+ y0: 0,
+ y1: 1,
+ });
+
+ // Write through $kysely, read back through the Prisma model API.
+ await prisma.$kysely
+ .insertInto("pixel")
+ .values({ canvasId: 9001, x: 1, y: 1, colorId: 9001 })
+ .execute();
+ const pixel = await prisma.pixel.findUniqueOrThrow({
+ where: { canvasId_x_y: { canvasId: 9001, x: 1, y: 1 } },
+ });
+ expect(pixel.colorId).toBe(9001);
+ });
+
+ it("is isolated per test: the previous test's writes were rolled back", async () => {
+ expect(await prisma.event.count({ where: { id: 9001 } })).toBe(0);
+ });
+
+ it("reads the statistics views through the camelCase client API", async () => {
+ // The views exist (created by the migrations) and are empty on a fresh DB.
+ expect(await prisma.canvasStats.findMany()).toEqual([]);
+ expect(
+ await prisma.$kysely.selectFrom("leaderboard").selectAll().execute(),
+ ).toEqual([]);
+ });
+});
diff --git a/packages/backend-nest/src/common/database/prisma.service.ts b/packages/backend-nest/src/common/database/prisma.service.ts
new file mode 100644
index 000000000..aa2b279a4
--- /dev/null
+++ b/packages/backend-nest/src/common/database/prisma.service.ts
@@ -0,0 +1,46 @@
+import {
+ Inject,
+ Injectable,
+ OnApplicationShutdown,
+ OnModuleInit,
+} from "@nestjs/common";
+
+import type { DatabaseConfig } from "@/config/database.config";
+import { databaseConfig } from "@/config/database.config";
+import { createPrismaClient, ExtendedPrismaClient } from "./prisma.client";
+
+// `$extends` returns a new object rather than mutating the client, so a plain
+// `class extends PrismaClient` would lose the Kysely extension. Instead the
+// base "class" returns the extended client from its constructor, which makes
+// `this` the extended client for the subclass — services get `prisma.`
+// and `prisma.$kysely` directly on the injected instance.
+class ExtendedPrismaClientSetup {
+ constructor(databaseUrl: string) {
+ // biome-ignore lint/correctness/noConstructorReturn: Substitutes the extended client as `this` for the subclass.
+ return createPrismaClient(databaseUrl);
+ }
+}
+
+const ExtendedPrismaClientBase = ExtendedPrismaClientSetup as new (
+ databaseUrl: string,
+) => ExtendedPrismaClient;
+
+@Injectable()
+export class PrismaService
+ extends ExtendedPrismaClientBase
+ implements OnModuleInit, OnApplicationShutdown
+{
+ constructor(@Inject(databaseConfig.KEY) config: DatabaseConfig) {
+ super(config.url);
+ }
+
+ // Instance fields (not prototype methods) so they are attached to the
+ // extended client returned by the base constructor.
+ onModuleInit = async (): Promise => {
+ await this.$connect();
+ };
+
+ onApplicationShutdown = async (): Promise => {
+ await this.$disconnect();
+ };
+}
diff --git a/packages/backend-nest/src/common/database/prisma/MIGRATIONS_README.md b/packages/backend-nest/src/common/database/prisma/MIGRATIONS_README.md
new file mode 100644
index 000000000..7bf4d375b
--- /dev/null
+++ b/packages/backend-nest/src/common/database/prisma/MIGRATIONS_README.md
@@ -0,0 +1,80 @@
+# Creating Prisma Migrations
+
+This guide explains how to create and manage database migrations using Prisma
+Migrate.
+
+## Quick Start
+
+### 1. Modify Your Schema
+
+Update the `schema.prisma` file with your desired database changes.
+
+```prisma
+model Post {
+ id Int @id @default(autoincrement())
+ title String
+ content String
+ // Add new field
+ published Boolean @default(false)
+}
+```
+
+### 2. Create a Migration
+
+Run the migration command to generate a migration file:
+
+```bash
+pnpm prisma migrate dev --name
+```
+
+Replace `` with a descriptive name for your changes, using
+snake_case. For example:
+
+```bash
+pnpm prisma migrate dev --name add_published_field_to_posts
+```
+
+### 3. What Happens Automatically
+
+- Prisma generates a new migration file in the `migrations/` folder
+- The migration file contains the SQL statements needed to update the schema
+- The migration is automatically applied to your development database
+- The Prisma Client is regenerated
+
+## Common Commands
+
+| Command | Purpose |
+| -------------------------------------------------- | --------------------------------------------------------------- |
+| `pnpm prisma migrate dev --name ` | Create and apply a new migration in development |
+| `pnpm prisma migrate status` | Check the status of all migrations |
+| `pnpm prisma migrate resolve --rolled-back ` | Manually mark a migration as rolled back |
+| `pnpm prisma migrate reset` | Reset the database and reapply all migrations (⚠️ deletes data) |
+| `pnpm prisma migrate deploy` | Apply pending migrations in production |
+
+## Best Practices
+
+- **Descriptive names**: Use clear, concise names that describe the change
+- **Small, focused changes**: Create one migration per logical change
+- **Test migrations**: Always test migrations in development before deploying
+- **Review migration files**: Check the generated SQL to ensure it's correct
+- **Commit migrations**: Version control all migration files with your code
+
+## Tips
+
+- Use `pnpm prisma migrate status` to see which migrations have been applied
+- Migration files are immutable once created—if you need to fix a mistake,
+ create a new migration
+- Never manually edit SQL in migration files; instead, create a new migration
+ with corrections
+
+## Further Documentation
+
+For comprehensive information about Prisma Migrate, visit the official
+documentation: https://www.prisma.io/docs/orm/prisma-migrate/getting-started
+
+This includes advanced topics like:
+
+- Handling conflicts and edge cases
+- Production deployments
+- Schema validation
+- Customizing migrations
diff --git a/packages/backend-nest/src/common/database/prisma/migrations/20260430000000_baseline_existing_schema/migration.sql b/packages/backend-nest/src/common/database/prisma/migrations/20260430000000_baseline_existing_schema/migration.sql
new file mode 100644
index 000000000..e5d7eb204
--- /dev/null
+++ b/packages/backend-nest/src/common/database/prisma/migrations/20260430000000_baseline_existing_schema/migration.sql
@@ -0,0 +1,423 @@
+-- Baseline for the 2025 and earlier schema.
+-- Resolve this migration as applied for existing databases.
+
+CREATE TABLE public.blacklist (
+ user_id bigint NOT NULL,
+ date_added timestamp with time zone DEFAULT now() NOT NULL,
+ CONSTRAINT blacklist_pkey PRIMARY KEY (user_id)
+);
+
+CREATE TABLE public.canvas (
+ id integer NOT NULL GENERATED BY DEFAULT AS IDENTITY,
+ name text NOT NULL,
+ locked boolean DEFAULT true NOT NULL,
+ event_id integer,
+ width integer NOT NULL,
+ height integer NOT NULL,
+ cooldown_length integer,
+ start_coordinates integer[] DEFAULT '{1,1}'::integer[] NOT NULL,
+ CONSTRAINT canvas_pkey PRIMARY KEY (id)
+);
+
+ CREATE TABLE public.cooldown (
+ user_id bigint NOT NULL,
+ canvas_id integer NOT NULL,
+ cooldown_time timestamp with time zone,
+ CONSTRAINT cooldown_pkey PRIMARY KEY (user_id, canvas_id)
+ );
+
+CREATE TABLE public.color (
+ id integer NOT NULL GENERATED BY DEFAULT AS IDENTITY,
+ code text NOT NULL,
+ emoji_name text NOT NULL,
+ emoji_id bigint NOT NULL,
+ global boolean DEFAULT true NOT NULL,
+ name text NOT NULL,
+ rgba integer[] NOT NULL,
+ CONSTRAINT color_pkey PRIMARY KEY (id)
+);
+
+CREATE TABLE public.discord_guild_record (
+ guild_id bigint NOT NULL,
+ name text NOT NULL,
+ CONSTRAINT discord_guild_record_pkey PRIMARY KEY (guild_id)
+);
+
+CREATE TABLE public.discord_user_profile (
+ user_id bigint NOT NULL,
+ username text NOT NULL,
+ profile_picture_url text,
+ CONSTRAINT discord_user_profile_pkey PRIMARY KEY (user_id)
+);
+
+CREATE TABLE public.event (
+ id integer NOT NULL,
+ name text NOT NULL,
+ CONSTRAINT event_pkey PRIMARY KEY (id)
+);
+
+CREATE TABLE public.frame (
+ id text NOT NULL,
+ canvas_id integer NOT NULL,
+ owner_id bigint NOT NULL,
+ is_guild_owned boolean DEFAULT false NOT NULL,
+ name text NOT NULL,
+ x_0 integer NOT NULL,
+ x_1 integer NOT NULL,
+ y_0 integer NOT NULL,
+ y_1 integer NOT NULL,
+ style_id integer,
+ CONSTRAINT frame_pkey PRIMARY KEY (id)
+);
+
+CREATE TABLE public.guild (
+ id bigint NOT NULL,
+ manager_role bigint,
+ invite text,
+ CONSTRAINT guild_pkey PRIMARY KEY (id)
+);
+
+CREATE TABLE public.history (
+ canvas_id integer NOT NULL,
+ user_id bigint NOT NULL,
+ x integer NOT NULL,
+ y integer NOT NULL,
+ color_id integer NOT NULL,
+ "timestamp" timestamp with time zone DEFAULT CURRENT_TIMESTAMP NOT NULL,
+ id bigint NOT NULL GENERATED BY DEFAULT AS IDENTITY,
+ guild_id bigint,
+ CONSTRAINT history_pkey PRIMARY KEY (id)
+);
+
+CREATE TABLE public.info (
+ title text NOT NULL,
+ canvas_admin bigint[] NOT NULL,
+ current_event_id integer NOT NULL,
+ cached_canvas_ids integer[],
+ highlight_color integer,
+ admin_server_id bigint NOT NULL,
+ current_emoji_server_id bigint NOT NULL,
+ host_server_id bigint NOT NULL,
+ event_role_id bigint,
+ default_canvas_id integer,
+ all_colors_global boolean DEFAULT false NOT NULL,
+ CONSTRAINT info_pkey PRIMARY KEY (title)
+);
+
+CREATE TABLE public.participation (
+ guild_id bigint NOT NULL,
+ event_id bigint NOT NULL,
+ color_id integer,
+ CONSTRAINT participation_pkey PRIMARY KEY (guild_id, event_id)
+);
+
+CREATE TABLE public.pixel (
+ canvas_id integer NOT NULL,
+ x integer NOT NULL,
+ y integer NOT NULL,
+ color_id integer NOT NULL,
+ CONSTRAINT pixel_pkey PRIMARY KEY (canvas_id, x, y)
+);
+
+CREATE TABLE public.session (
+ id text NOT NULL,
+ sid text NOT NULL,
+ data text NOT NULL,
+ "expiresAt" timestamp with time zone NOT NULL,
+ CONSTRAINT session_pkey PRIMARY KEY (id),
+ CONSTRAINT session_sid_key UNIQUE (sid)
+);
+
+CREATE TABLE public."user" (
+ id bigint NOT NULL,
+ current_canvas_id integer,
+ skip_confirm boolean DEFAULT false NOT NULL,
+ cooldown_remind boolean DEFAULT false NOT NULL,
+ CONSTRAINT user_pkey PRIMARY KEY (id)
+);
+
+ALTER TABLE public.blacklist
+ ADD CONSTRAINT blacklist_user_id_fkey FOREIGN KEY (user_id) REFERENCES public."user"(id);
+
+ALTER TABLE public.canvas
+ ADD CONSTRAINT canvas_event_id_fkey FOREIGN KEY (event_id) REFERENCES public.event(id);
+
+ALTER TABLE public.cooldown
+ ADD CONSTRAINT cooldown_user_id_fkey FOREIGN KEY (user_id) REFERENCES public."user"(id),
+ ADD CONSTRAINT cooldown_canvas_id_fkey FOREIGN KEY (canvas_id) REFERENCES public.canvas(id);
+
+ALTER TABLE public.discord_user_profile
+ ADD CONSTRAINT discord_user_profile_user_id_fkey FOREIGN KEY (user_id) REFERENCES public."user"(id);
+
+ALTER TABLE public.frame
+ ADD CONSTRAINT frame_canvas_id_fkey FOREIGN KEY (canvas_id) REFERENCES public.canvas(id);
+
+ALTER TABLE public.guild
+ ADD CONSTRAINT guild_id_fkey FOREIGN KEY (id) REFERENCES public.discord_guild_record(guild_id);
+
+ALTER TABLE public.history
+ ADD CONSTRAINT history_canvas_id_fkey FOREIGN KEY (canvas_id) REFERENCES public.canvas(id),
+ ADD CONSTRAINT history_user_id_fkey FOREIGN KEY (user_id) REFERENCES public."user"(id),
+ ADD CONSTRAINT history_color_id_fkey FOREIGN KEY (color_id) REFERENCES public.color(id),
+ ADD CONSTRAINT history_guild_id_fkey FOREIGN KEY (guild_id) REFERENCES public.guild(id);
+
+ALTER TABLE public.info
+ ADD CONSTRAINT info_current_event_id_fkey FOREIGN KEY (current_event_id) REFERENCES public.event(id);
+
+ALTER TABLE public.participation
+ ADD CONSTRAINT participation_guild_id_fkey FOREIGN KEY (guild_id) REFERENCES public.guild(id),
+ ADD CONSTRAINT participation_event_id_fkey FOREIGN KEY (event_id) REFERENCES public.event(id),
+ ADD CONSTRAINT participation_color_id_fkey FOREIGN KEY (color_id) REFERENCES public.color(id);
+
+ALTER TABLE public.pixel
+ ADD CONSTRAINT pixel_canvas_id_fkey FOREIGN KEY (canvas_id) REFERENCES public.canvas(id),
+ ADD CONSTRAINT pixel_color_id_fkey FOREIGN KEY (color_id) REFERENCES public.color(id);
+
+ALTER TABLE public."user"
+ ADD CONSTRAINT user_current_canvas_id_fkey FOREIGN KEY (current_canvas_id) REFERENCES public.canvas(id);
+
+CREATE UNIQUE INDEX pixel_canvas_id_x_y_key ON public.pixel (canvas_id, x, y);
+
+CREATE VIEW public.most_frequent_color AS
+SELECT
+ DISTINCT ON (history.user_id, history.canvas_id) history.user_id,
+ history.canvas_id,
+ history.color_id,
+ count(*) AS count
+FROM
+ history
+GROUP BY
+ history.user_id,
+ history.color_id,
+ history.canvas_id
+ORDER BY
+ history.user_id,
+ history.canvas_id,
+ (count(*)) DESC;
+
+CREATE VIEW public.color_place_frequency AS
+WITH time_diffs AS (
+ SELECT
+ history.user_id,
+ history.canvas_id,
+ (
+ history."timestamp" - lag(history."timestamp") OVER (
+ PARTITION BY history.user_id,
+ history.canvas_id
+ ORDER BY
+ history."timestamp"
+ )
+ ) AS time_diff
+ FROM
+ history
+ ORDER BY
+ history."timestamp"
+)
+SELECT
+ t.user_id,
+ t.canvas_id,
+ percentile_cont((0.5) :: double precision) WITHIN GROUP (
+ ORDER BY
+ t.time_diff
+ ) AS median_time_diff
+FROM
+ time_diffs t
+WHERE
+ (t.time_diff > '00:00:00.1' :: INTERVAL)
+GROUP BY
+ t.user_id,
+ t.canvas_id
+HAVING
+ (count(*) > 1);
+
+CREATE VIEW public.most_frequent_color_guild AS
+SELECT
+ DISTINCT ON (history.guild_id, history.canvas_id) history.guild_id,
+ history.canvas_id,
+ history.color_id,
+ count(*) AS count
+FROM
+ history
+GROUP BY
+ history.guild_id,
+ history.color_id,
+ history.canvas_id
+ORDER BY
+ history.guild_id,
+ history.canvas_id,
+ (count(*)) DESC;
+
+CREATE VIEW public.color_place_frequency_guild AS
+WITH time_diffs AS (
+ SELECT
+ history.guild_id,
+ history.canvas_id,
+ (
+ history."timestamp" - lag(history."timestamp") OVER (
+ PARTITION BY history.guild_id,
+ history.canvas_id
+ ORDER BY
+ history."timestamp"
+ )
+ ) AS time_diff
+ FROM
+ history
+ ORDER BY
+ history."timestamp"
+)
+SELECT
+ t.guild_id,
+ t.canvas_id,
+ percentile_cont((0.5) :: double precision) WITHIN GROUP (
+ ORDER BY
+ t.time_diff
+ ) AS median_time_diff
+FROM
+ time_diffs t
+WHERE
+ (t.time_diff > '00:00:00.05' :: INTERVAL)
+GROUP BY
+ t.guild_id,
+ t.canvas_id
+HAVING
+ (count(*) > 1);
+
+CREATE VIEW public.leaderboard_guild AS
+SELECT
+ history.user_id,
+ history.canvas_id,
+ history.guild_id,
+ count(*) AS total_pixels,
+ rank() OVER (
+ PARTITION BY history.canvas_id,
+ history.guild_id
+ ORDER BY
+ (count(*)) DESC
+ ) AS rank
+FROM
+ history
+WHERE
+ history.user_id NOT IN (SELECT user_id FROM blacklist)
+GROUP BY
+ history.user_id,
+ history.canvas_id,
+ history.guild_id;
+
+CREATE VIEW public.guild_stats AS
+SELECT
+ lb.guild_id,
+ lb.canvas_id,
+ lb.total_pixels,
+ mfc.color_id AS most_frequent_color_id,
+ mfc.count AS color_count,
+ cpf.median_time_diff AS place_frequency,
+ h.most_recent_timestamp
+FROM
+ (
+ (
+ (
+ (
+ SELECT
+ leaderboard_guild.canvas_id,
+ leaderboard_guild.guild_id,
+ (sum(leaderboard_guild.total_pixels)) :: integer AS total_pixels
+ FROM
+ leaderboard_guild
+ GROUP BY
+ leaderboard_guild.canvas_id,
+ leaderboard_guild.guild_id
+ ) lb
+ LEFT JOIN most_frequent_color_guild mfc ON (
+ (
+ (lb.canvas_id = mfc.canvas_id)
+ AND (lb.guild_id = mfc.guild_id)
+ )
+ )
+ )
+ LEFT JOIN color_place_frequency_guild cpf ON (
+ (
+ (lb.canvas_id = cpf.canvas_id)
+ AND (lb.guild_id = cpf.guild_id)
+ )
+ )
+ )
+ LEFT JOIN (
+ SELECT
+ history.guild_id,
+ history.canvas_id,
+ max(history."timestamp") AS most_recent_timestamp
+ FROM
+ history
+ GROUP BY
+ history.guild_id,
+ history.canvas_id
+ ) h ON (
+ (
+ (lb.canvas_id = h.canvas_id)
+ AND (lb.guild_id = h.guild_id)
+ )
+ )
+ );
+
+CREATE VIEW public.leaderboard AS
+SELECT
+ leaderboard_guild.user_id,
+ leaderboard_guild.canvas_id,
+ (sum(leaderboard_guild.total_pixels)) :: integer AS total_pixels,
+ rank() OVER (
+ PARTITION BY leaderboard_guild.canvas_id
+ ORDER BY
+ (sum(leaderboard_guild.total_pixels)) DESC
+ ) AS rank
+FROM
+ leaderboard_guild
+GROUP BY
+ leaderboard_guild.user_id,
+ leaderboard_guild.canvas_id;
+
+CREATE VIEW public.user_stats AS
+SELECT
+ lb.user_id,
+ lb.canvas_id,
+ lb.total_pixels,
+ lb.rank,
+ mfc.color_id AS most_frequent_color_id,
+ mfc.count AS color_count,
+ cpf.median_time_diff AS place_frequency,
+ h.most_recent_timestamp
+FROM
+ (
+ (
+ (
+ leaderboard lb
+ LEFT JOIN most_frequent_color mfc ON (
+ (
+ (lb.canvas_id = mfc.canvas_id)
+ AND (lb.user_id = mfc.user_id)
+ )
+ )
+ )
+ LEFT JOIN color_place_frequency cpf ON (
+ (
+ (lb.canvas_id = cpf.canvas_id)
+ AND (lb.user_id = cpf.user_id)
+ )
+ )
+ )
+ LEFT JOIN (
+ SELECT
+ history.user_id,
+ history.canvas_id,
+ max(history."timestamp") AS most_recent_timestamp
+ FROM
+ history
+ GROUP BY
+ history.user_id,
+ history.canvas_id
+ ) h ON (
+ (
+ (lb.canvas_id = h.canvas_id)
+ AND (lb.user_id = h.user_id)
+ )
+ )
+ );
diff --git a/packages/backend-nest/src/common/database/prisma/migrations/20260430110000_update_defaults/migration.sql b/packages/backend-nest/src/common/database/prisma/migrations/20260430110000_update_defaults/migration.sql
new file mode 100644
index 000000000..58df7ddef
--- /dev/null
+++ b/packages/backend-nest/src/common/database/prisma/migrations/20260430110000_update_defaults/migration.sql
@@ -0,0 +1,28 @@
+UPDATE "discord_user_profile"
+SET "profile_picture_url" = 'https://discord.com/assets/788f05731f8aa02e.png'
+WHERE "profile_picture_url" IS NULL;
+
+UPDATE "info"
+SET "cached_canvas_ids" = '{}'::integer[]
+WHERE "cached_canvas_ids" IS NULL;
+
+UPDATE "info"
+SET "default_canvas_id" = (SELECT "id" FROM "canvas" ORDER BY "id" LIMIT 1)
+WHERE "default_canvas_id" IS NULL;
+
+UPDATE "blacklist"
+SET "date_added" = NOW()
+WHERE "date_added" IS NULL;
+
+ALTER TABLE "color"
+ALTER COLUMN "emoji_name" DROP NOT NULL,
+ALTER COLUMN "emoji_id" DROP NOT NULL;
+
+ALTER TABLE "discord_user_profile"
+ALTER COLUMN "profile_picture_url" SET NOT NULL;
+
+ALTER TABLE "info"
+ALTER COLUMN "cached_canvas_ids" SET NOT NULL,
+ALTER COLUMN "default_canvas_id" SET NOT NULL;
+
+ALTER TABLE public.guild DROP CONSTRAINT IF EXISTS guild_id_fkey;
diff --git a/packages/backend-nest/src/common/database/prisma/migrations/20260430120000_add_notice_table/migration.sql b/packages/backend-nest/src/common/database/prisma/migrations/20260430120000_add_notice_table/migration.sql
new file mode 100644
index 000000000..5e28e2e5d
--- /dev/null
+++ b/packages/backend-nest/src/common/database/prisma/migrations/20260430120000_add_notice_table/migration.sql
@@ -0,0 +1,20 @@
+-- CreateTable
+CREATE TABLE "notice" (
+ "id" SERIAL NOT NULL,
+ "type" TEXT NOT NULL DEFAULT 'info',
+ "header" TEXT,
+ "content" TEXT,
+ "priority" INTEGER NOT NULL DEFAULT 0,
+ "start_at" TIMESTAMPTZ(6),
+ "end_at" TIMESTAMPTZ(6),
+ "persisted" BOOLEAN NOT NULL DEFAULT false,
+ "canvas_id" INTEGER REFERENCES "canvas"("id") ON DELETE SET NULL ON UPDATE CASCADE,
+ "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+
+ CONSTRAINT "notice_pkey" PRIMARY KEY ("id"),
+ CONSTRAINT "notice_end_requires_start_chk" CHECK ("end_at" IS NULL OR "start_at" IS NOT NULL),
+ CONSTRAINT "notice_end_after_start_chk" CHECK ("end_at" IS NULL OR "end_at" > "start_at")
+);
+
+-- CreateIndex
+CREATE INDEX "notice_canvas_id_idx" ON "notice"("canvas_id");
diff --git a/packages/backend-nest/src/common/database/prisma/migrations/20260504000000_add_database_functions/migration.sql b/packages/backend-nest/src/common/database/prisma/migrations/20260504000000_add_database_functions/migration.sql
new file mode 100644
index 000000000..0b6e45b23
--- /dev/null
+++ b/packages/backend-nest/src/common/database/prisma/migrations/20260504000000_add_database_functions/migration.sql
@@ -0,0 +1,33 @@
+-- CreateFunction clear_idle_sessions
+CREATE FUNCTION public.clear_idle_sessions() RETURNS integer
+ LANGUAGE plpgsql
+ AS $$
+DECLARE
+ session_record pg_stat_activity%ROWTYPE;
+ num_sessions_terminated INTEGER := 0;
+BEGIN
+ -- Query to identify idle sessions older than 3 minutes
+ FOR session_record IN SELECT * FROM pg_stat_activity WHERE state = 'idle' AND state_change < NOW() - INTERVAL '3 minutes' LOOP
+ -- Terminate idle sessions
+ EXECUTE 'SELECT pg_terminate_backend(' || session_record.pid || ')';
+ num_sessions_terminated := num_sessions_terminated + 1;
+ END LOOP;
+
+ -- Return the number of sessions terminated
+ RETURN num_sessions_terminated;
+END;
+$$;
+
+ALTER FUNCTION public.clear_idle_sessions() OWNER TO postgres;
+
+-- CreateFunction delete_surpassed_cooldowns
+CREATE FUNCTION public.delete_surpassed_cooldowns() RETURNS void
+ LANGUAGE plpgsql
+ AS $$
+BEGIN
+ DELETE FROM cooldown
+ WHERE cooldown_time is not null and cooldown_time < NOW();
+END;
+$$;
+
+ALTER FUNCTION public.delete_surpassed_cooldowns() OWNER TO postgres;
diff --git a/packages/backend-nest/src/common/database/prisma/migrations/20260505120000_add_recorded_to_history/migration.sql b/packages/backend-nest/src/common/database/prisma/migrations/20260505120000_add_recorded_to_history/migration.sql
new file mode 100644
index 000000000..3f8ba3a71
--- /dev/null
+++ b/packages/backend-nest/src/common/database/prisma/migrations/20260505120000_add_recorded_to_history/migration.sql
@@ -0,0 +1,2 @@
+ALTER TABLE "history"
+ADD COLUMN "erased_at" TIMESTAMPTZ(6) DEFAULT NULL;
diff --git a/packages/backend-nest/src/common/database/prisma/migrations/20260517120000_move_all_colors_global_to_canvas/migration.sql b/packages/backend-nest/src/common/database/prisma/migrations/20260517120000_move_all_colors_global_to_canvas/migration.sql
new file mode 100644
index 000000000..fb54764fe
--- /dev/null
+++ b/packages/backend-nest/src/common/database/prisma/migrations/20260517120000_move_all_colors_global_to_canvas/migration.sql
@@ -0,0 +1,5 @@
+ALTER TABLE "canvas"
+ADD COLUMN "all_colors_global" boolean NOT NULL DEFAULT false;
+
+ALTER TABLE "info"
+DROP COLUMN "all_colors_global";
diff --git a/packages/backend-nest/src/common/database/prisma/migrations/20260520180000_split_frame_owner_id/migration.sql b/packages/backend-nest/src/common/database/prisma/migrations/20260520180000_split_frame_owner_id/migration.sql
new file mode 100644
index 000000000..c5d8a2bf9
--- /dev/null
+++ b/packages/backend-nest/src/common/database/prisma/migrations/20260520180000_split_frame_owner_id/migration.sql
@@ -0,0 +1,22 @@
+ALTER TABLE "frame"
+ADD COLUMN "owner_user_id" BIGINT,
+ADD COLUMN "owner_guild_id" BIGINT;
+
+UPDATE "frame"
+SET "owner_user_id" = "owner_id"
+WHERE "is_guild_owned" = FALSE;
+
+UPDATE "frame"
+SET "owner_guild_id" = "owner_id"
+WHERE "is_guild_owned" = TRUE;
+
+ALTER TABLE "frame"
+DROP COLUMN "owner_id",
+DROP COLUMN "is_guild_owned";
+
+ALTER TABLE "frame"
+ADD CONSTRAINT "frame_owner_exactly_one_set" CHECK (
+ ("owner_user_id" IS NOT NULL AND "owner_guild_id" IS NULL)
+ OR
+ ("owner_user_id" IS NULL AND "owner_guild_id" IS NOT NULL)
+);
diff --git a/packages/backend-nest/src/common/database/prisma/migrations/20260522210000_add_audit_log/migration.sql b/packages/backend-nest/src/common/database/prisma/migrations/20260522210000_add_audit_log/migration.sql
new file mode 100644
index 000000000..f13ea8571
--- /dev/null
+++ b/packages/backend-nest/src/common/database/prisma/migrations/20260522210000_add_audit_log/migration.sql
@@ -0,0 +1,26 @@
+-- CreateTable
+CREATE TABLE "audit_log" (
+ "id" BIGSERIAL NOT NULL,
+ "created_at" TIMESTAMPTZ(6) NOT NULL DEFAULT CURRENT_TIMESTAMP,
+ "actor_id" BIGINT NOT NULL,
+ "actor_role" TEXT NOT NULL,
+ "action" TEXT NOT NULL,
+ "resource_type" TEXT,
+ "resource_id" TEXT,
+ "metadata" JSONB,
+
+ CONSTRAINT "audit_log_pkey" PRIMARY KEY ("id"),
+ CONSTRAINT "audit_log_actor_id_fkey" FOREIGN KEY ("actor_id") REFERENCES "user"("id") ON DELETE RESTRICT ON UPDATE CASCADE
+);
+
+-- CreateIndex
+CREATE INDEX "audit_log_created_at_idx" ON "audit_log"("created_at" DESC);
+
+-- CreateIndex
+CREATE INDEX "audit_log_actor_id_created_at_idx" ON "audit_log"("actor_id", "created_at" DESC);
+
+-- CreateIndex
+CREATE INDEX "audit_log_action_created_at_idx" ON "audit_log"("action", "created_at" DESC);
+
+-- CreateIndex
+CREATE INDEX "audit_log_resource_type_resource_id_idx" ON "audit_log"("resource_type", "resource_id");
diff --git a/packages/backend-nest/src/common/database/prisma/migrations/20260524080406_add_views/migration.sql b/packages/backend-nest/src/common/database/prisma/migrations/20260524080406_add_views/migration.sql
new file mode 100644
index 000000000..71e1cf178
--- /dev/null
+++ b/packages/backend-nest/src/common/database/prisma/migrations/20260524080406_add_views/migration.sql
@@ -0,0 +1,270 @@
+DROP VIEW IF EXISTS most_frequent_color CASCADE;
+
+DROP VIEW IF EXISTS color_place_frequency CASCADE;
+
+DROP VIEW IF EXISTS most_frequent_color_guild CASCADE;
+
+DROP VIEW IF EXISTS color_place_frequency_guild CASCADE;
+
+DROP VIEW IF EXISTS leaderboard_guild CASCADE;
+
+DROP VIEW IF EXISTS leaderboard CASCADE;
+
+DROP VIEW IF EXISTS user_stats CASCADE;
+
+DROP VIEW IF EXISTS guild_stats CASCADE;
+
+CREATE VIEW most_frequent_color AS
+SELECT DISTINCT
+ ON (history.user_id, history.canvas_id) history.user_id,
+ history.canvas_id,
+ history.color_id,
+ count(*) AS count
+FROM
+ history
+GROUP BY
+ history.user_id,
+ history.color_id,
+ history.canvas_id
+ORDER BY
+ history.user_id,
+ history.canvas_id,
+ (count(*)) DESC;
+
+CREATE VIEW color_place_frequency AS
+WITH
+ time_diffs AS (
+ SELECT
+ history.user_id,
+ history.canvas_id,
+ (
+ history."timestamp" - lag(history."timestamp") OVER (
+ PARTITION BY
+ history.user_id,
+ history.canvas_id
+ ORDER BY
+ history."timestamp"
+ )
+ ) AS time_diff
+ FROM
+ history
+ ORDER BY
+ history."timestamp"
+ )
+SELECT
+ t.user_id,
+ t.canvas_id,
+ percentile_cont((0.5)::double precision) WITHIN GROUP (
+ ORDER BY
+ t.time_diff
+ ) AS median_time_diff
+FROM
+ time_diffs t
+WHERE
+ (t.time_diff > '00:00:00.1'::INTERVAL)
+GROUP BY
+ t.user_id,
+ t.canvas_id
+HAVING
+ (count(*) > 1);
+
+CREATE VIEW most_frequent_color_guild AS
+SELECT DISTINCT
+ ON (history.guild_id, history.canvas_id) history.guild_id,
+ history.canvas_id,
+ history.color_id,
+ count(*) AS count
+FROM
+ history
+GROUP BY
+ history.guild_id,
+ history.color_id,
+ history.canvas_id
+ORDER BY
+ history.guild_id,
+ history.canvas_id,
+ (count(*)) DESC;
+
+CREATE VIEW color_place_frequency_guild AS
+WITH
+ time_diffs AS (
+ SELECT
+ history.guild_id,
+ history.canvas_id,
+ (
+ history."timestamp" - lag(history."timestamp") OVER (
+ PARTITION BY
+ history.guild_id,
+ history.canvas_id
+ ORDER BY
+ history."timestamp"
+ )
+ ) AS time_diff
+ FROM
+ history
+ ORDER BY
+ history."timestamp"
+ )
+SELECT
+ t.guild_id,
+ t.canvas_id,
+ percentile_cont((0.5)::double precision) WITHIN GROUP (
+ ORDER BY
+ t.time_diff
+ ) AS median_time_diff
+FROM
+ time_diffs t
+WHERE
+ (t.time_diff > '00:00:00.05'::INTERVAL)
+GROUP BY
+ t.guild_id,
+ t.canvas_id
+HAVING
+ (count(*) > 1);
+
+CREATE VIEW leaderboard_guild AS
+SELECT
+ history.user_id,
+ history.canvas_id,
+ history.guild_id,
+ count(*) AS total_pixels,
+ rank() OVER (
+ PARTITION BY
+ history.canvas_id,
+ history.guild_id
+ ORDER BY
+ (count(*)) DESC
+ ) AS rank
+FROM
+ history
+WHERE
+ history.user_id NOT IN (
+ SELECT
+ user_id
+ FROM
+ blacklist
+ )
+GROUP BY
+ history.user_id,
+ history.canvas_id,
+ history.guild_id;
+
+CREATE VIEW leaderboard AS
+SELECT
+ leaderboard_guild.user_id,
+ leaderboard_guild.canvas_id,
+ (sum(leaderboard_guild.total_pixels))::integer AS total_pixels,
+ rank() OVER (
+ PARTITION BY
+ leaderboard_guild.canvas_id
+ ORDER BY
+ (sum(leaderboard_guild.total_pixels)) DESC
+ ) AS rank
+FROM
+ leaderboard_guild
+GROUP BY
+ leaderboard_guild.user_id,
+ leaderboard_guild.canvas_id;
+
+CREATE VIEW user_stats AS
+SELECT
+ lb.user_id,
+ lb.canvas_id,
+ lb.total_pixels,
+ lb.rank,
+ mfc.color_id AS most_frequent_color_id,
+ mfc.count AS color_count,
+ cpf.median_time_diff AS place_frequency,
+ h.most_recent_timestamp
+FROM
+ (
+ (
+ (
+ leaderboard lb
+ LEFT JOIN most_frequent_color mfc ON (
+ (
+ (lb.canvas_id = mfc.canvas_id)
+ AND (lb.user_id = mfc.user_id)
+ )
+ )
+ )
+ LEFT JOIN color_place_frequency cpf ON (
+ (
+ (lb.canvas_id = cpf.canvas_id)
+ AND (lb.user_id = cpf.user_id)
+ )
+ )
+ )
+ LEFT JOIN (
+ SELECT
+ history.user_id,
+ history.canvas_id,
+ max(history."timestamp") AS most_recent_timestamp
+ FROM
+ history
+ GROUP BY
+ history.user_id,
+ history.canvas_id
+ ) h ON (
+ (
+ (lb.canvas_id = h.canvas_id)
+ AND (lb.user_id = h.user_id)
+ )
+ )
+ );
+
+CREATE VIEW guild_stats AS
+SELECT
+ lb.guild_id,
+ lb.canvas_id,
+ lb.total_pixels,
+ mfc.color_id AS most_frequent_color_id,
+ mfc.count AS color_count,
+ cpf.median_time_diff AS place_frequency,
+ h.most_recent_timestamp
+FROM
+ (
+ (
+ (
+ (
+ SELECT
+ leaderboard_guild.canvas_id,
+ leaderboard_guild.guild_id,
+ (sum(leaderboard_guild.total_pixels))::integer AS total_pixels
+ FROM
+ leaderboard_guild
+ GROUP BY
+ leaderboard_guild.canvas_id,
+ leaderboard_guild.guild_id
+ ) lb
+ LEFT JOIN most_frequent_color_guild mfc ON (
+ (
+ (lb.canvas_id = mfc.canvas_id)
+ AND (lb.guild_id = mfc.guild_id)
+ )
+ )
+ )
+ LEFT JOIN color_place_frequency_guild cpf ON (
+ (
+ (lb.canvas_id = cpf.canvas_id)
+ AND (lb.guild_id = cpf.guild_id)
+ )
+ )
+ )
+ LEFT JOIN (
+ SELECT
+ history.guild_id,
+ history.canvas_id,
+ max(history.timestamp) AS most_recent_timestamp
+ FROM
+ history
+ GROUP BY
+ history.guild_id,
+ history.canvas_id
+ ) h ON (
+ (
+ (lb.canvas_id = h.canvas_id)
+ AND (lb.guild_id = h.guild_id)
+ )
+ )
+ );
diff --git a/packages/backend-nest/src/common/database/prisma/migrations/20260524120000_add_canvas_event_stats_views/migration.sql b/packages/backend-nest/src/common/database/prisma/migrations/20260524120000_add_canvas_event_stats_views/migration.sql
new file mode 100644
index 000000000..4ea5b84c1
--- /dev/null
+++ b/packages/backend-nest/src/common/database/prisma/migrations/20260524120000_add_canvas_event_stats_views/migration.sql
@@ -0,0 +1,23 @@
+CREATE VIEW canvas_stats AS
+SELECT
+ canvas_id,
+ COUNT(*)::integer AS total_users,
+ SUM(total_pixels)::integer AS total_pixels,
+ MAX(most_recent_timestamp) AS last_placed_at
+FROM
+ user_stats
+GROUP BY
+ canvas_id;
+
+CREATE VIEW event_stats AS
+SELECT
+ c.event_id,
+ COUNT(DISTINCT us.user_id)::integer AS total_users,
+ SUM(us.total_pixels)::integer AS total_pixels
+FROM
+ user_stats us
+ JOIN canvas c ON c.id = us.canvas_id
+WHERE
+ c.event_id IS NOT NULL
+GROUP BY
+ c.event_id;
diff --git a/packages/backend-nest/src/common/database/prisma/migrations/migration_lock.toml b/packages/backend-nest/src/common/database/prisma/migrations/migration_lock.toml
new file mode 100644
index 000000000..9de562e72
--- /dev/null
+++ b/packages/backend-nest/src/common/database/prisma/migrations/migration_lock.toml
@@ -0,0 +1,3 @@
+# Please do not edit manually.
+# This file is automatically managed by Prisma Migrate.
+provider = "postgresql"
diff --git a/packages/backend-nest/src/common/database/prisma/schema.prisma b/packages/backend-nest/src/common/database/prisma/schema.prisma
new file mode 100644
index 000000000..b7ce8ec73
--- /dev/null
+++ b/packages/backend-nest/src/common/database/prisma/schema.prisma
@@ -0,0 +1,389 @@
+generator client {
+ provider = "prisma-client"
+ output = "../generated"
+ previewFeatures = ["views"]
+ moduleFormat = "cjs"
+ engineType = "client"
+ importFileExtension = ""
+}
+
+generator kysely {
+ provider = "prisma-kysely"
+ output = "../kysely"
+ fileName = "types.ts"
+ bigIntTypeOverride = "bigint"
+ camelCase = "true"
+}
+
+datasource db {
+ provider = "postgresql"
+}
+
+model Blacklist {
+ userId BigInt @id @map("user_id")
+ user User @relation(fields: [userId], references: [id])
+ dateAdded DateTime @default(now()) @map("date_added") @db.Timestamptz(6)
+
+ @@map("blacklist")
+}
+
+model Canvas {
+ id Int @id @default(autoincrement())
+ name String
+ locked Boolean @default(true)
+ // It's theoretically possible to have a canvas without an event. E.g. An off-season canvas
+ eventId Int? @map("event_id")
+ event Event? @relation(fields: [eventId], references: [id])
+ width Int
+ height Int
+ cooldownLength Int? @map("cooldown_length")
+ // The top-left coordinate from the user-perspective. For users, this is (1, 1)
+ startCoordinates Int[] @default([1, 1]) @map("start_coordinates")
+ allColorsGlobal Boolean @default(false) @map("all_colors_global")
+ frames Frame[]
+ pixels Pixel[]
+ history History[]
+ cooldowns Cooldown[]
+ userStats UserStats[]
+ guildStats GuildStats[]
+ leaderboard Leaderboard[]
+ leaderboardGuild LeaderboardGuild[]
+ notices Notice[]
+ canvasStats CanvasStats?
+
+ @@map("canvas")
+}
+
+model Color {
+ id Int @id @default(autoincrement())
+ code String
+ emojiName String? @map("emoji_name")
+ emojiId BigInt? @map("emoji_id")
+ global Boolean @default(true)
+ name String
+ rgba Int[]
+ pixels Pixel[]
+ history History[]
+ userStats UserStats[]
+ guildStats GuildStats[]
+ participations Participation[]
+
+ @@map("color")
+}
+
+model Cooldown {
+ userId BigInt @map("user_id")
+ user User @relation(fields: [userId], references: [id])
+ canvasId Int @map("canvas_id")
+ canvas Canvas @relation(fields: [canvasId], references: [id])
+ // A cooldown might be set to null to skip the cooldown
+ cooldownTime DateTime? @map("cooldown_time") @db.Timestamptz(6)
+
+ @@id([userId, canvasId])
+ @@map("cooldown")
+}
+
+model DiscordUserProfile {
+ userId BigInt @id @map("user_id")
+ username String
+ profilePictureUrl String @map("profile_picture_url")
+ history History[]
+ leaderboardGuild LeaderboardGuild[]
+ leaderboard Leaderboard[]
+ user User @relation(fields: [userId], references: [id])
+
+ @@map("discord_user_profile")
+}
+
+model DiscordGuildRecord {
+ guildId BigInt @id @map("guild_id")
+ name String
+ guild Guild[]
+
+ @@map("discord_guild_record")
+}
+
+model Event {
+ id Int @id
+ name String
+ canvases Canvas[]
+ participants Participation[]
+ info Info[]
+ eventStats EventStats?
+
+ @@map("event")
+}
+
+model Frame {
+ id String @id
+ canvasId Int @map("canvas_id")
+ canvas Canvas @relation(fields: [canvasId], references: [id])
+ // There is a database check that requires one of them to be set
+ ownerUserId BigInt? @map("owner_user_id")
+ ownerGuildId BigInt? @map("owner_guild_id")
+ name String
+ x0 Int @map("x_0")
+ x1 Int @map("x_1")
+ y0 Int @map("y_0")
+ y1 Int @map("y_1")
+ styleId Int? @map("style_id")
+
+ @@map("frame")
+}
+
+model Guild {
+ id BigInt @id
+ discordGuildRecord DiscordGuildRecord? @relation(fields: [id], references: [guildId])
+ managerRole BigInt? @map("manager_role")
+ invite String?
+ history History[]
+ participations Participation[]
+ guildStats GuildStats[]
+ leaderboard LeaderboardGuild[]
+
+ @@map("guild")
+}
+
+model History {
+ id BigInt @id @default(autoincrement())
+ erasedAt DateTime? @map("erased_at") @db.Timestamptz(6)
+ canvasId Int @map("canvas_id")
+ canvas Canvas @relation(fields: [canvasId], references: [id])
+ userId BigInt @map("user_id")
+ user User @relation(fields: [userId], references: [id])
+ x Int
+ y Int
+ colorId Int @map("color_id")
+ color Color @relation(fields: [colorId], references: [id])
+ timestamp DateTime @db.Timestamptz(6)
+ guildId BigInt? @map("guild_id")
+ guild Guild? @relation(fields: [guildId], references: [id])
+ discordUserProfile DiscordUserProfile? @relation(fields: [userId], references: [userId], map: "discord_user_profile_user_id_fkey")
+
+ @@map("history")
+}
+
+/// This is a special table that will only ever have one row. The title being the primary key
+/// is only to make it compatible with Prisma.
+model Info {
+ title String @id
+ canvasAdmin BigInt[] @map("canvas_admin")
+ currentEventId Int @map("current_event_id")
+ currentEvent Event @relation(fields: [currentEventId], references: [id])
+ cachedCanvasIds Int[] @map("cached_canvas_ids")
+ highlightColor Int? @map("highlight_color")
+ adminServerId BigInt @map("admin_server_id")
+ currentEmojiServerId BigInt @map("current_emoji_server_id")
+ hostServerId BigInt @map("host_server_id")
+ eventRoleId BigInt? @map("event_role_id")
+ defaultCanvasId Int @map("default_canvas_id")
+
+ @@map("info")
+}
+
+model Participation {
+ guildId BigInt @map("guild_id")
+ guild Guild @relation(fields: [guildId], references: [id])
+ eventId Int @map("event_id")
+ event Event @relation(fields: [eventId], references: [id])
+ // Not all participants may have a color, or it might not be set straight away
+ colorId Int? @map("color_id")
+ color Color? @relation(fields: [colorId], references: [id])
+
+ @@id([guildId, eventId])
+ @@map("participation")
+}
+
+model Pixel {
+ canvasId Int @map("canvas_id")
+ canvas Canvas @relation(fields: [canvasId], references: [id])
+ x Int
+ y Int
+ colorId Int @map("color_id")
+ color Color @relation(fields: [colorId], references: [id])
+
+ @@id([canvasId, x, y])
+ @@unique([canvasId, x, y])
+ @@map("pixel")
+}
+
+model User {
+ id BigInt @id
+ currentCanvasId Int? @map("current_canvas_id")
+ skipConfirm Boolean @default(false) @map("skip_confirm")
+ cooldownRemind Boolean @default(false) @map("cooldown_remind")
+ blacklist Blacklist?
+ cooldowns Cooldown[]
+ history History[]
+ userStats UserStats[]
+ leaderboard Leaderboard[]
+ leaderboardGuild LeaderboardGuild[]
+ discordUserProfile DiscordUserProfile?
+ auditLogs AuditLog[]
+
+ @@map("user")
+}
+
+model Session {
+ id String @id
+ sid String @unique
+ data String
+ // The column really is camelCase in the database (created by the session store).
+ expiresAt DateTime @db.Timestamptz(6)
+
+ @@map("session")
+}
+
+model Notice {
+ id Int @id @default(autoincrement())
+ type String @default("info") // info | warning | error
+ header String?
+ content String?
+ priority Int @default(0)
+ startAt DateTime? @map("start_at") @db.Timestamptz(6)
+ endAt DateTime? @map("end_at") @db.Timestamptz(6)
+ persisted Boolean @default(false)
+ canvasId Int? @map("canvas_id")
+ canvas Canvas? @relation(fields: [canvasId], references: [id])
+ createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
+
+ @@map("notice")
+}
+
+model AuditLog {
+ id BigInt @id @default(autoincrement())
+ createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
+ actorId BigInt @map("actor_id")
+ actor User @relation(fields: [actorId], references: [id])
+ actorRole String @map("actor_role") // "admin" | "moderator"
+ action String // e.g. "notice.create", "history.delete"
+ resourceType String? @map("resource_type")
+ resourceId String? @map("resource_id")
+ metadata Json?
+
+ @@index([createdAt(sort: Desc)])
+ @@index([actorId, createdAt(sort: Desc)])
+ @@index([action, createdAt(sort: Desc)])
+ @@index([resourceType, resourceId])
+ @@map("audit_log")
+}
+
+view UserStats {
+ userId BigInt @map("user_id")
+ user User @relation(fields: [userId], references: [id])
+ canvasId Int @map("canvas_id")
+ canvas Canvas @relation(fields: [canvasId], references: [id])
+ totalPixels Int @map("total_pixels")
+ rank Int
+ mostFrequentColorId Int @map("most_frequent_color_id")
+ mostFrequentColor Color @relation(fields: [mostFrequentColorId], references: [id])
+ colorCount Int @map("color_count")
+ // placeFrequency Unsupported("interval") @map("place_frequency")
+ mostRecentTimestamp DateTime @map("most_recent_timestamp") @db.Timestamptz(6)
+
+ @@unique([userId, canvasId])
+ @@map("user_stats")
+}
+
+view GuildStats {
+ guildId BigInt @map("guild_id")
+ guild Guild @relation(fields: [guildId], references: [id])
+ canvasId Int @map("canvas_id")
+ canvas Canvas @relation(fields: [canvasId], references: [id])
+ totalPixels Int @map("total_pixels")
+ mostFrequentColorId Int @map("most_frequent_color_id")
+ mostFrequentColor Color @relation(fields: [mostFrequentColorId], references: [id])
+ colorCount Int @map("color_count")
+ // placeFrequency Unsupported("interval") @map("place_frequency")
+ mostRecentTimestamp DateTime @map("most_recent_timestamp") @db.Timestamptz(6)
+
+ @@unique([guildId, canvasId])
+ @@map("guild_stats")
+}
+
+view Leaderboard {
+ userId BigInt @map("user_id")
+ user User @relation(fields: [userId], references: [id])
+ canvasId Int @map("canvas_id")
+ canvas Canvas @relation(fields: [canvasId], references: [id])
+ discordUserProfile DiscordUserProfile? @relation(fields: [userId], references: [userId])
+ totalPixels Int @map("total_pixels")
+ rank Int
+
+ @@unique([userId, canvasId])
+ @@map("leaderboard")
+}
+
+view LeaderboardGuild {
+ userId BigInt @map("user_id")
+ user User @relation(fields: [userId], references: [id])
+ canvasId Int @map("canvas_id")
+ canvas Canvas @relation(fields: [canvasId], references: [id])
+ guildId BigInt @map("guild_id")
+ guild Guild @relation(fields: [guildId], references: [id])
+ discordUserProfile DiscordUserProfile? @relation(fields: [userId], references: [userId])
+ totalPixels Int @map("total_pixels")
+ rank Int
+
+ @@unique([userId, canvasId, guildId])
+ @@map("leaderboard_guild")
+}
+
+view ColorPlaceFrequency {
+ userId BigInt @map("user_id")
+ canvasId Int @map("canvas_id")
+ medianTimeDiff Unsupported("interval") @map("median_time_diff")
+
+ @@unique([userId, canvasId])
+ @@map("color_place_frequency")
+}
+
+view ColorPlaceFrequencyGuild {
+ guildId BigInt @map("guild_id")
+ canvasId Int @map("canvas_id")
+ medianTimeDiff Unsupported("interval") @map("median_time_diff")
+
+ @@unique([guildId, canvasId])
+ @@map("color_place_frequency_guild")
+}
+
+view MostFrequentColorGuild {
+ guildId BigInt @map("guild_id")
+ canvasId Int @map("canvas_id")
+ colorId Int @map("color_id")
+ count Int
+
+ @@unique([guildId, canvasId])
+ @@map("most_frequent_color_guild")
+}
+
+view MostFrequentColor {
+ userId BigInt @map("user_id")
+ canvasId Int @map("canvas_id")
+ colorId Int @map("color_id")
+ count Int
+
+ @@unique([userId, canvasId])
+ @@map("most_frequent_color")
+}
+
+view CanvasStats {
+ canvasId Int @map("canvas_id")
+ canvas Canvas @relation(fields: [canvasId], references: [id])
+ totalUsers Int @map("total_users")
+ totalPixels Int @map("total_pixels")
+ lastPlacedAt DateTime @map("last_placed_at") @db.Timestamptz(6)
+
+ @@unique([canvasId])
+ @@map("canvas_stats")
+}
+
+view EventStats {
+ eventId Int @map("event_id")
+ event Event @relation(fields: [eventId], references: [id])
+ totalUsers Int @map("total_users")
+ totalPixels Int @map("total_pixels")
+
+ @@unique([eventId])
+ @@map("event_stats")
+}
diff --git a/packages/backend-nest/src/common/error-response.dto.ts b/packages/backend-nest/src/common/error-response.dto.ts
new file mode 100644
index 000000000..ab8025f85
--- /dev/null
+++ b/packages/backend-nest/src/common/error-response.dto.ts
@@ -0,0 +1,7 @@
+import { createZodDto } from "nestjs-zod";
+import { z } from "zod";
+
+/** Error envelope produced by `ApiExceptionFilter`. */
+export class ErrorResponseDto extends createZodDto(
+ z.object({ message: z.string() }),
+) {}
diff --git a/packages/backend-nest/src/common/errors/api.error.ts b/packages/backend-nest/src/common/errors/api.error.ts
new file mode 100644
index 000000000..0a9e5c55c
--- /dev/null
+++ b/packages/backend-nest/src/common/errors/api.error.ts
@@ -0,0 +1,23 @@
+import type { z } from "zod";
+
+export interface ApiErrorBody {
+ message: string;
+ errors?: z.core.$ZodIssue[];
+}
+
+export class ApiError extends Error {
+ constructor(
+ message: string,
+ public readonly status: number,
+ ) {
+ super(message);
+ }
+
+ /**
+ * The JSON body for this error. Subclasses override this to provide more
+ * complex error responses.
+ */
+ public toResponseBody(): ApiErrorBody {
+ return { message: this.message };
+ }
+}
diff --git a/packages/backend-nest/src/common/errors/bad-request.error.ts b/packages/backend-nest/src/common/errors/bad-request.error.ts
new file mode 100644
index 000000000..ee784731b
--- /dev/null
+++ b/packages/backend-nest/src/common/errors/bad-request.error.ts
@@ -0,0 +1,16 @@
+import { HttpStatus } from "@nestjs/common";
+import type { z } from "zod";
+import { ApiError, type ApiErrorBody } from "./api.error";
+
+export class BadRequestError extends ApiError {
+ constructor(
+ message: string,
+ protected errors: z.core.$ZodIssue[] = [],
+ ) {
+ super(message, HttpStatus.BAD_REQUEST);
+ }
+
+ public override toResponseBody(): ApiErrorBody {
+ return { message: this.message, errors: this.errors };
+ }
+}
diff --git a/packages/backend-nest/src/common/errors/conflict.error.ts b/packages/backend-nest/src/common/errors/conflict.error.ts
new file mode 100644
index 000000000..6e57a7bbf
--- /dev/null
+++ b/packages/backend-nest/src/common/errors/conflict.error.ts
@@ -0,0 +1,8 @@
+import { HttpStatus } from "@nestjs/common";
+import { ApiError } from "./api.error";
+
+export class ConflictError extends ApiError {
+ constructor(message: string) {
+ super(message, HttpStatus.CONFLICT);
+ }
+}
diff --git a/packages/backend-nest/src/common/errors/forbidden.error.ts b/packages/backend-nest/src/common/errors/forbidden.error.ts
new file mode 100644
index 000000000..d8e8fd9d7
--- /dev/null
+++ b/packages/backend-nest/src/common/errors/forbidden.error.ts
@@ -0,0 +1,8 @@
+import { HttpStatus } from "@nestjs/common";
+import { ApiError } from "./api.error";
+
+export class ForbiddenError extends ApiError {
+ constructor(message: string) {
+ super(message, HttpStatus.FORBIDDEN);
+ }
+}
diff --git a/packages/backend-nest/src/common/errors/not-acceptable.error.ts b/packages/backend-nest/src/common/errors/not-acceptable.error.ts
new file mode 100644
index 000000000..f677368eb
--- /dev/null
+++ b/packages/backend-nest/src/common/errors/not-acceptable.error.ts
@@ -0,0 +1,16 @@
+import { HttpStatus } from "@nestjs/common";
+import type { z } from "zod";
+import { ApiError, type ApiErrorBody } from "./api.error";
+
+export class NotAcceptableError extends ApiError {
+ constructor(
+ message: string,
+ protected errors: z.core.$ZodIssue[] = [],
+ ) {
+ super(message, HttpStatus.NOT_ACCEPTABLE);
+ }
+
+ public override toResponseBody(): ApiErrorBody {
+ return { message: this.message, errors: this.errors };
+ }
+}
diff --git a/packages/backend-nest/src/common/errors/not-found.error.ts b/packages/backend-nest/src/common/errors/not-found.error.ts
new file mode 100644
index 000000000..40f3e8a46
--- /dev/null
+++ b/packages/backend-nest/src/common/errors/not-found.error.ts
@@ -0,0 +1,8 @@
+import { HttpStatus } from "@nestjs/common";
+import { ApiError } from "./api.error";
+
+export class NotFoundError extends ApiError {
+ constructor(message: string) {
+ super(message, HttpStatus.NOT_FOUND);
+ }
+}
diff --git a/packages/backend-nest/src/common/errors/too-many-requests.error.ts b/packages/backend-nest/src/common/errors/too-many-requests.error.ts
new file mode 100644
index 000000000..a687a6fd7
--- /dev/null
+++ b/packages/backend-nest/src/common/errors/too-many-requests.error.ts
@@ -0,0 +1,8 @@
+import { HttpStatus } from "@nestjs/common";
+import { ApiError } from "./api.error";
+
+export class TooManyRequestsError extends ApiError {
+ constructor(message: string) {
+ super(message, HttpStatus.TOO_MANY_REQUESTS);
+ }
+}
diff --git a/packages/backend-nest/src/common/errors/unauthorized.error.ts b/packages/backend-nest/src/common/errors/unauthorized.error.ts
new file mode 100644
index 000000000..8d1e8ce0b
--- /dev/null
+++ b/packages/backend-nest/src/common/errors/unauthorized.error.ts
@@ -0,0 +1,8 @@
+import { HttpStatus } from "@nestjs/common";
+import { ApiError } from "./api.error";
+
+export class UnauthorizedError extends ApiError {
+ constructor(message: string) {
+ super(message, HttpStatus.UNAUTHORIZED);
+ }
+}
diff --git a/packages/backend-nest/src/common/errors/unprocessable.error.ts b/packages/backend-nest/src/common/errors/unprocessable.error.ts
new file mode 100644
index 000000000..5f0c34331
--- /dev/null
+++ b/packages/backend-nest/src/common/errors/unprocessable.error.ts
@@ -0,0 +1,8 @@
+import { HttpStatus } from "@nestjs/common";
+import { ApiError } from "./api.error";
+
+export class UnprocessableError extends ApiError {
+ constructor(message: string) {
+ super(message, HttpStatus.UNPROCESSABLE_ENTITY);
+ }
+}
diff --git a/packages/backend-nest/src/common/fetch-with-retries.spec.ts b/packages/backend-nest/src/common/fetch-with-retries.spec.ts
new file mode 100644
index 000000000..a04e90c34
--- /dev/null
+++ b/packages/backend-nest/src/common/fetch-with-retries.spec.ts
@@ -0,0 +1,113 @@
+import { NotAcceptableError } from "@/common/errors/not-acceptable.error";
+import { fetchWithRetries } from "./fetch-with-retries";
+
+function jsonResponse(status: number, headers?: Record) {
+ return new Response("{}", { status, headers });
+}
+
+describe("fetchWithRetries", () => {
+ const fetchMock = vi.fn();
+
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.spyOn(console, "warn").mockImplementation(() => {});
+ vi.stubGlobal("fetch", fetchMock);
+ fetchMock.mockReset();
+ });
+
+ afterEach(() => {
+ vi.unstubAllGlobals();
+ vi.restoreAllMocks();
+ vi.useRealTimers();
+ });
+
+ it("returns the first successful response without retrying", async () => {
+ fetchMock.mockResolvedValueOnce(jsonResponse(200));
+
+ const response = await fetchWithRetries("https://example.com");
+
+ expect(response.status).toBe(200);
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ });
+
+ it("does not retry non-retryable error statuses", async () => {
+ fetchMock.mockResolvedValueOnce(jsonResponse(404));
+
+ const response = await fetchWithRetries("https://example.com");
+
+ expect(response.status).toBe(404);
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ });
+
+ it("retries retryable statuses with exponential backoff", async () => {
+ fetchMock
+ .mockResolvedValueOnce(jsonResponse(503))
+ .mockResolvedValueOnce(jsonResponse(503))
+ .mockResolvedValueOnce(jsonResponse(200));
+
+ const responsePromise = fetchWithRetries("https://example.com");
+
+ // First retry waits backoff ** 0 = 1 s, second waits backoff ** 1 = 1.25 s.
+ await vi.advanceTimersByTimeAsync(1000);
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+ await vi.advanceTimersByTimeAsync(1250);
+
+ const response = await responsePromise;
+ expect(response.status).toBe(200);
+ expect(fetchMock).toHaveBeenCalledTimes(3);
+ });
+
+ it("prefers the Retry-After header over backoff", async () => {
+ fetchMock
+ .mockResolvedValueOnce(jsonResponse(429, { "retry-after": "2.5" }))
+ .mockResolvedValueOnce(jsonResponse(200));
+
+ const responsePromise = fetchWithRetries("https://example.com");
+
+ await vi.advanceTimersByTimeAsync(2499);
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ await vi.advanceTimersByTimeAsync(1);
+
+ const response = await responsePromise;
+ expect(response.status).toBe(200);
+ });
+
+ it("falls back to the X-Ratelimit-Reset-After header", async () => {
+ fetchMock
+ .mockResolvedValueOnce(
+ jsonResponse(429, { "x-ratelimit-reset-after": "3" }),
+ )
+ .mockResolvedValueOnce(jsonResponse(200));
+
+ const responsePromise = fetchWithRetries("https://example.com");
+
+ await vi.advanceTimersByTimeAsync(3000);
+
+ const response = await responsePromise;
+ expect(response.status).toBe(200);
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+ });
+
+ it("returns the last failing response once attempts are exhausted", async () => {
+ fetchMock.mockResolvedValue(jsonResponse(500));
+
+ const responsePromise = fetchWithRetries("https://example.com");
+
+ await vi.advanceTimersByTimeAsync(10_000);
+
+ const response = await responsePromise;
+ expect(response.status).toBe(500);
+ expect(fetchMock).toHaveBeenCalledTimes(3);
+ });
+
+ it("rejects a backoff below 1 outside production", async () => {
+ await expect(
+ fetchWithRetries("https://example.com", undefined, {
+ maxAttempts: 3,
+ backoff: 0.5,
+ statusCodes: new Set([500]),
+ }),
+ ).rejects.toBeInstanceOf(NotAcceptableError);
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/backend-nest/src/common/fetch-with-retries.ts b/packages/backend-nest/src/common/fetch-with-retries.ts
new file mode 100644
index 000000000..d368469db
--- /dev/null
+++ b/packages/backend-nest/src/common/fetch-with-retries.ts
@@ -0,0 +1,77 @@
+import { NotAcceptableError } from "@/common/errors/not-acceptable.error";
+
+function sleep(ms: number) {
+ return new Promise((resolve) => setTimeout(resolve, ms));
+}
+
+/**
+ * Parses `Retry-After: ` from header object, using `X-Ratelimit-Reset-After` as
+ * fallback. Returns `NaN` if neither can be determined.
+ */
+function getStandDownSecondsFromHeaders(headers: Headers): number {
+ const delaySeconds =
+ headers.get("retry-after") ?? headers.get("x-ratelimit-reset-after");
+ return delaySeconds ? Number.parseFloat(delaySeconds) : Number.NaN;
+}
+
+interface RetryOptions {
+ maxAttempts: number;
+ /**
+ * If the response has no `Retry-After` or `X-Ratelimit-Reset-After` header, will retry with delay
+ * of `attemptNumber ** backoff` seconds. (Attempt number starts from 0.) Must be at least 1.
+ */
+ backoff: number;
+ /** Retry only with these HTTP status codes. */
+ statusCodes: Set;
+}
+
+const defaultRetryConfig = {
+ maxAttempts: 3,
+ backoff: 1.25,
+ statusCodes: new Set([
+ 408, // Request Timeout
+ 409, // Conflict
+ 425, // Too Early
+ 429, // Too Many Requests
+ 500, // Internal Server Error
+ 502, // Bad Gateway
+ 503, // Service Unavailable
+ 504, // Gateway Timeout
+ ]),
+} as const;
+
+export async function fetchWithRetries(
+ input: string | URL | Request,
+ init?: RequestInit,
+ { maxAttempts, backoff, statusCodes }: RetryOptions = defaultRetryConfig,
+) {
+ if (process.env.NODE_ENV !== "production" && backoff < 1) {
+ throw new NotAcceptableError(
+ `RetryOptions backoff must be ≥1.0, but got ${backoff}`,
+ );
+ }
+
+ let response: Response;
+ for (let i = 0; i < maxAttempts; i++) {
+ response = await fetch(input, init);
+
+ if (response.ok || !statusCodes.has(response.status)) return response;
+
+ const delaySeconds = getStandDownSecondsFromHeaders(response.headers);
+ const delayMs =
+ Number.isFinite(delaySeconds) ?
+ // When available, prefer stand-down period from response header…
+ delaySeconds * 1000
+ // …otherwise use exponential backoff
+ : backoff ** i * 1000;
+
+ console.warn(
+ `Received ${response.status} response from ${(init?.method ?? "GET").toUpperCase()} ${input} responded. Retrying in ${delayMs} ms…`,
+ );
+
+ await sleep(delayMs);
+ }
+
+ // @ts-expect-error Definitely initialized
+ return response;
+}
diff --git a/packages/backend-nest/src/common/zod-validation.pipe.ts b/packages/backend-nest/src/common/zod-validation.pipe.ts
new file mode 100644
index 000000000..4b8ec9a61
--- /dev/null
+++ b/packages/backend-nest/src/common/zod-validation.pipe.ts
@@ -0,0 +1,30 @@
+import type { ArgumentMetadata, PipeTransform } from "@nestjs/common";
+import { Injectable } from "@nestjs/common";
+import { createZodValidationPipe } from "nestjs-zod";
+import { isZodDto } from "nestjs-zod/dto";
+import { ZodError } from "zod";
+
+import { BadRequestError } from "./errors/bad-request.error";
+
+const BaseZodValidationPipe: ReturnType =
+ createZodValidationPipe({
+ createValidationException: (error) =>
+ new BadRequestError(
+ "Invalid request data",
+ error instanceof ZodError ? error.issues : [],
+ ),
+ strictSchemaDeclaration: true,
+ });
+
+@Injectable()
+export class ZodValidationPipe
+ extends BaseZodValidationPipe
+ implements PipeTransform
+{
+ override transform(value: unknown, metadata: ArgumentMetadata): unknown {
+ if (metadata.type === "custom" && !isZodDto(metadata.metatype)) {
+ return value;
+ }
+ return super.transform(value, metadata);
+ }
+}
diff --git a/packages/backend-nest/src/config/app.config.ts b/packages/backend-nest/src/config/app.config.ts
new file mode 100644
index 000000000..16e17aade
--- /dev/null
+++ b/packages/backend-nest/src/config/app.config.ts
@@ -0,0 +1,30 @@
+import fs from "node:fs";
+import path from "node:path";
+import { Logger } from "@nestjs/common";
+import type { ConfigType } from "@nestjs/config";
+import { registerAs } from "@nestjs/config";
+import { ConfigNamespace } from "./config-namespace";
+import { validateEnv } from "./env";
+
+const logger = new Logger("AppConfig");
+
+export const appConfig = registerAs(ConfigNamespace.App, () => {
+ const env = validateEnv(process.env);
+
+ const paths = {
+ root: path.resolve(),
+ canvases: path.resolve("static", "canvas"),
+ };
+
+ logger.debug(`Creating canvases directory at ${paths.canvases}`);
+ fs.mkdirSync(paths.canvases, { recursive: true });
+
+ return {
+ environment: env.NODE_ENV,
+ port: env.PORT,
+ frontendUrl: env.FRONTEND_URL,
+ paths,
+ };
+});
+
+export type AppConfig = ConfigType;
diff --git a/packages/backend-nest/src/config/captcha.config.ts b/packages/backend-nest/src/config/captcha.config.ts
new file mode 100644
index 000000000..04bb4e7a7
--- /dev/null
+++ b/packages/backend-nest/src/config/captcha.config.ts
@@ -0,0 +1,15 @@
+import type { ConfigType } from "@nestjs/config";
+import { registerAs } from "@nestjs/config";
+import { ConfigNamespace } from "./config-namespace";
+import { validateEnv } from "./env";
+
+export const captchaConfig = registerAs(ConfigNamespace.Captcha, () => {
+ const env = validateEnv(process.env);
+
+ return {
+ enabled: env.CAPTCHA_ENABLED === "true",
+ turnstileSecretKey: env.TURNSTILE_SECRET_KEY,
+ };
+});
+
+export type CaptchaConfig = ConfigType;
diff --git a/packages/backend-nest/src/config/config-namespace.ts b/packages/backend-nest/src/config/config-namespace.ts
new file mode 100644
index 000000000..bee6aba91
--- /dev/null
+++ b/packages/backend-nest/src/config/config-namespace.ts
@@ -0,0 +1,13 @@
+export const ConfigNamespace = {
+ App: "app",
+ Captcha: "captcha",
+ Database: "database",
+ Discord: "discord",
+ Frames: "frames",
+ Placement: "placement",
+ Session: "session",
+ Telemetry: "telemetry",
+} as const;
+
+export type ConfigNamespace =
+ (typeof ConfigNamespace)[keyof typeof ConfigNamespace];
diff --git a/packages/backend-nest/src/config/config.module.ts b/packages/backend-nest/src/config/config.module.ts
new file mode 100644
index 000000000..50b3a9cd9
--- /dev/null
+++ b/packages/backend-nest/src/config/config.module.ts
@@ -0,0 +1,35 @@
+import { Module } from "@nestjs/common";
+import { ConfigModule as NestConfigModule } from "@nestjs/config";
+
+import { appConfig } from "./app.config";
+import { captchaConfig } from "./captcha.config";
+import { databaseConfig } from "./database.config";
+import { discordConfig } from "./discord.config";
+import { validateEnv } from "./env";
+import { framesConfig } from "./frames.config";
+import { placementConfig } from "./placement.config";
+import { sessionConfig } from "./session.config";
+import { telemetryConfig } from "./telemetry.config";
+
+@Module({
+ imports: [
+ NestConfigModule.forRoot({
+ isGlobal: true,
+ cache: true,
+ // dotenvx (workspace standard) already loads .env in env.ts.
+ ignoreEnvFile: true,
+ validate: validateEnv,
+ load: [
+ appConfig,
+ captchaConfig,
+ databaseConfig,
+ discordConfig,
+ framesConfig,
+ placementConfig,
+ sessionConfig,
+ telemetryConfig,
+ ],
+ }),
+ ],
+})
+export class AppConfigModule {}
diff --git a/packages/backend-nest/src/config/config.spec.ts b/packages/backend-nest/src/config/config.spec.ts
new file mode 100644
index 000000000..e6108eab0
--- /dev/null
+++ b/packages/backend-nest/src/config/config.spec.ts
@@ -0,0 +1,152 @@
+import { appConfig } from "./app.config";
+import { captchaConfig } from "./captcha.config";
+import { databaseConfig } from "./database.config";
+import { discordConfig } from "./discord.config";
+import { validateEnv } from "./env";
+import { framesConfig } from "./frames.config";
+import { placementConfig } from "./placement.config";
+import { sessionConfig } from "./session.config";
+
+const REQUIRED_ENV = {
+ DATABASE_URL: "postgresql://test:test@localhost:5432/test",
+ DISCORD_CLIENT_ID: "client-id",
+ DISCORD_CLIENT_SECRET: "client-secret",
+};
+
+describe("config", () => {
+ const originalEnv = { ...process.env };
+
+ beforeEach(() => {
+ process.env = { ...originalEnv, ...REQUIRED_ENV };
+ });
+
+ afterAll(() => {
+ process.env = { ...originalEnv };
+ });
+
+ describe("validateEnv", () => {
+ it.each(Object.keys(REQUIRED_ENV))(
+ "throws when required variable %s is missing",
+ (key) => {
+ delete process.env[key];
+ expect(() => validateEnv(process.env)).toThrow(key);
+ },
+ );
+
+ it("treats empty environment variables as unset", () => {
+ process.env.PORT = "";
+ process.env.FRONTEND_URL = "";
+
+ const env = validateEnv(process.env);
+
+ expect(env.PORT).toBe(8000);
+ expect(env.FRONTEND_URL).toBe("http://localhost:3000");
+ });
+
+ it("rejects non-numeric numeric variables", () => {
+ process.env.PORT = "not-a-port";
+ expect(() => validateEnv(process.env)).toThrow("PORT");
+ });
+ });
+
+ describe("appConfig", () => {
+ it("applies the documented defaults", () => {
+ expect(appConfig()).toMatchObject({
+ environment: "test",
+ port: 8000,
+ frontendUrl: "http://localhost:3000",
+ });
+ });
+
+ it("parses numeric overrides", () => {
+ process.env.PORT = "9123";
+ expect(appConfig().port).toBe(9123);
+ });
+ });
+
+ describe("databaseConfig", () => {
+ it("exposes the database url", () => {
+ expect(databaseConfig().url).toBe(REQUIRED_ENV.DATABASE_URL);
+ });
+ });
+
+ describe("discordConfig", () => {
+ it("exposes credentials and leaves management settings unset by default", () => {
+ expect(discordConfig()).toEqual({
+ clientId: "client-id",
+ clientSecret: "client-secret",
+ managementGuildId: undefined,
+ adminRoleId: undefined,
+ moderatorRoleId: undefined,
+ serverInvite: undefined,
+ });
+ });
+ });
+
+ describe("sessionConfig", () => {
+ it("defaults to the documented development secret", () => {
+ expect(sessionConfig().secret).toBe("change the secret in production");
+ });
+
+ it("disables secure cookies only in development (Safari over HTTP)", () => {
+ expect(sessionConfig().secureCookies).toBe(true);
+
+ process.env.NODE_ENV = "development";
+ expect(sessionConfig().secureCookies).toBe(false);
+ });
+ });
+
+ describe("placementConfig", () => {
+ it("applies the documented defaults", () => {
+ expect(placementConfig()).toMatchObject({
+ webGuildId: 0,
+ webPlacingEnabled: false,
+ botPlacingEnabled: true,
+ });
+ });
+
+ it("parses the placement feature flags with their exact semantics", () => {
+ process.env.WEB_PLACING_ENABLED = "true";
+ process.env.BOT_PLACING_ENABLED = "false";
+ expect(placementConfig()).toMatchObject({
+ webPlacingEnabled: true,
+ botPlacingEnabled: false,
+ });
+
+ // Anything other than the exact literals falls back to the defaults.
+ process.env.WEB_PLACING_ENABLED = "TRUE";
+ process.env.BOT_PLACING_ENABLED = "no";
+ expect(placementConfig()).toMatchObject({
+ webPlacingEnabled: false,
+ botPlacingEnabled: true,
+ });
+ });
+ });
+
+ describe("framesConfig", () => {
+ it("defaults both frame caps to 32", () => {
+ expect(framesConfig()).toEqual({
+ maxAllowedUser: 32,
+ maxAllowedGuild: 32,
+ });
+ });
+
+ it("parses numeric overrides", () => {
+ process.env.MAX_USER_FRAMES_ALLOWED = "5";
+ process.env.MAX_GUILD_FRAMES_ALLOWED = "7";
+ expect(framesConfig()).toEqual({
+ maxAllowedUser: 5,
+ maxAllowedGuild: 7,
+ });
+ });
+ });
+
+ describe("captchaConfig", () => {
+ it("is disabled by default and only enabled by the exact literal", () => {
+ expect(captchaConfig().enabled).toBe(false);
+
+ process.env.CAPTCHA_ENABLED = "true";
+ expect(captchaConfig().enabled).toBe(true);
+ });
+ });
+});
diff --git a/packages/backend-nest/src/config/database.config.ts b/packages/backend-nest/src/config/database.config.ts
new file mode 100644
index 000000000..c2ab10440
--- /dev/null
+++ b/packages/backend-nest/src/config/database.config.ts
@@ -0,0 +1,14 @@
+import type { ConfigType } from "@nestjs/config";
+import { registerAs } from "@nestjs/config";
+import { ConfigNamespace } from "./config-namespace";
+import { validateEnv } from "./env";
+
+export const databaseConfig = registerAs(ConfigNamespace.Database, () => {
+ const env = validateEnv(process.env);
+
+ return {
+ url: env.DATABASE_URL,
+ };
+});
+
+export type DatabaseConfig = ConfigType;
diff --git a/packages/backend-nest/src/config/discord.config.ts b/packages/backend-nest/src/config/discord.config.ts
new file mode 100644
index 000000000..ea4408c41
--- /dev/null
+++ b/packages/backend-nest/src/config/discord.config.ts
@@ -0,0 +1,21 @@
+import type { ConfigType } from "@nestjs/config";
+import { registerAs } from "@nestjs/config";
+import { ConfigNamespace } from "./config-namespace";
+import { validateEnv } from "./env";
+
+export const discordConfig = registerAs(ConfigNamespace.Discord, () => {
+ const env = validateEnv(process.env);
+
+ return {
+ clientId: env.DISCORD_CLIENT_ID,
+ clientSecret: env.DISCORD_CLIENT_SECRET,
+ /** Guild whose roles grant canvas admin/moderator. */
+ managementGuildId: env.DISCORD_MANAGEMENT_GUILD_ID,
+ adminRoleId: env.DISCORD_ADMIN_ROLE_ID,
+ moderatorRoleId: env.DISCORD_MODERATOR_ROLE_ID,
+ /** Community invite surfaced to the frontend. */
+ serverInvite: env.DISCORD_SERVER_INVITE,
+ };
+});
+
+export type DiscordConfig = ConfigType;
diff --git a/packages/backend-nest/src/config/env.ts b/packages/backend-nest/src/config/env.ts
new file mode 100644
index 000000000..f9f2323c4
--- /dev/null
+++ b/packages/backend-nest/src/config/env.ts
@@ -0,0 +1,63 @@
+import dotenvx from "@dotenvx/dotenvx";
+import { z } from "zod";
+
+// Load .env via dotenvx (workspace standard) once, at first import — before
+// @nestjs/config evaluates `validate` or any registerAs factory.
+if (!process.env.VITEST) {
+ dotenvx.config({ ignore: ["MISSING_ENV_FILE"], quiet: true });
+}
+
+const requiredString = z.string().min(1);
+
+export const envSchema = z.object({
+ DATABASE_URL: requiredString,
+ DISCORD_CLIENT_ID: requiredString,
+ DISCORD_CLIENT_SECRET: requiredString,
+ NODE_ENV: requiredString.default("production"),
+ PORT: z.coerce.number().int().min(1).max(65535).default(8000),
+ FRONTEND_URL: requiredString.default("http://localhost:3000"),
+ OTEL_SERVICE_NAME: requiredString.default("canvas-backend"),
+ OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: requiredString.default(
+ "http://localhost:4318/v1/traces",
+ ),
+ // Having a random secret would mess with persistent sessions.
+ EXPRESS_SESSION_SECRET: requiredString.default(
+ "change the secret in production",
+ ),
+ DISCORD_MANAGEMENT_GUILD_ID: requiredString.optional(),
+ DISCORD_ADMIN_ROLE_ID: requiredString.optional(),
+ DISCORD_MODERATOR_ROLE_ID: requiredString.optional(),
+ WEB_PLACING_ENABLED: z.string().optional(),
+ BOT_PLACING_ENABLED: z.string().optional(),
+ BOT_API_KEY: requiredString.optional(),
+ MAX_USER_FRAMES_ALLOWED: z.coerce.number().int().positive().default(32),
+ MAX_GUILD_FRAMES_ALLOWED: z.coerce.number().int().positive().default(32),
+ CAPTCHA_ENABLED: z.string().optional(),
+ TURNSTILE_SECRET_KEY: requiredString.optional(),
+ DISCORD_SERVER_INVITE: requiredString.optional(),
+});
+
+export type Env = z.infer;
+
+/**
+ * Validates the environment. The old backend treated empty env vars as unset
+ * (`process.env.X || default`); filtering them out before parsing preserves
+ * that behaviour.
+ */
+export function validateEnv(raw: Record): Env {
+ const withoutEmpty = Object.fromEntries(
+ Object.entries(raw).filter(
+ ([, value]) => value !== undefined && value !== "",
+ ),
+ );
+
+ const parsed = envSchema.safeParse(withoutEmpty);
+ if (!parsed.success) {
+ const details = parsed.error.issues
+ .map((issue) => `${issue.path.join(".")}: ${issue.message}`)
+ .join("\n ");
+ throw new Error(`Invalid environment configuration:\n ${details}`);
+ }
+
+ return parsed.data;
+}
diff --git a/packages/backend-nest/src/config/frames.config.ts b/packages/backend-nest/src/config/frames.config.ts
new file mode 100644
index 000000000..cde98d7f6
--- /dev/null
+++ b/packages/backend-nest/src/config/frames.config.ts
@@ -0,0 +1,15 @@
+import type { ConfigType } from "@nestjs/config";
+import { registerAs } from "@nestjs/config";
+import { ConfigNamespace } from "./config-namespace";
+import { validateEnv } from "./env";
+
+export const framesConfig = registerAs(ConfigNamespace.Frames, () => {
+ const env = validateEnv(process.env);
+
+ return {
+ maxAllowedUser: env.MAX_USER_FRAMES_ALLOWED,
+ maxAllowedGuild: env.MAX_GUILD_FRAMES_ALLOWED,
+ };
+});
+
+export type FramesConfig = ConfigType;
diff --git a/packages/backend-nest/src/config/placement.config.ts b/packages/backend-nest/src/config/placement.config.ts
new file mode 100644
index 000000000..c2b8152e5
--- /dev/null
+++ b/packages/backend-nest/src/config/placement.config.ts
@@ -0,0 +1,22 @@
+import type { ConfigType } from "@nestjs/config";
+import { registerAs } from "@nestjs/config";
+import { ConfigNamespace } from "./config-namespace";
+import { validateEnv } from "./env";
+
+export const placementConfig = registerAs(ConfigNamespace.Placement, () => {
+ const env = validateEnv(process.env);
+
+ return {
+ /**
+ * Placed pixels are typically attributed to guilds they were placed in.
+ * Identify pixels placed through the web with the ID of 0.
+ */
+ webGuildId: 0,
+ webPlacingEnabled: env.WEB_PLACING_ENABLED === "true",
+ // Keep bot placing enabled by default unless explicitly disabled.
+ botPlacingEnabled: env.BOT_PLACING_ENABLED !== "false",
+ botApiKey: env.BOT_API_KEY,
+ };
+});
+
+export type PlacementConfig = ConfigType;
diff --git a/packages/backend-nest/src/config/session.config.ts b/packages/backend-nest/src/config/session.config.ts
new file mode 100644
index 000000000..b5614c302
--- /dev/null
+++ b/packages/backend-nest/src/config/session.config.ts
@@ -0,0 +1,19 @@
+import type { ConfigType } from "@nestjs/config";
+import { registerAs } from "@nestjs/config";
+import { ConfigNamespace } from "./config-namespace";
+import { validateEnv } from "./env";
+
+export const sessionConfig = registerAs(ConfigNamespace.Session, () => {
+ const env = validateEnv(process.env);
+
+ return {
+ secret: env.EXPRESS_SESSION_SECRET,
+ /**
+ * In development mode, secure cookies are not used for sending the profile.
+ * This is because they can't be accessed over HTTP on Safari.
+ */
+ secureCookies: env.NODE_ENV !== "development",
+ };
+});
+
+export type SessionConfig = ConfigType;
diff --git a/packages/backend-nest/src/config/telemetry.config.ts b/packages/backend-nest/src/config/telemetry.config.ts
new file mode 100644
index 000000000..3a2a1d36e
--- /dev/null
+++ b/packages/backend-nest/src/config/telemetry.config.ts
@@ -0,0 +1,15 @@
+import type { ConfigType } from "@nestjs/config";
+import { registerAs } from "@nestjs/config";
+import { ConfigNamespace } from "./config-namespace";
+import { validateEnv } from "./env";
+
+export const telemetryConfig = registerAs(ConfigNamespace.Telemetry, () => {
+ const env = validateEnv(process.env);
+
+ return {
+ serviceName: env.OTEL_SERVICE_NAME,
+ otlpTracesEndpoint: env.OTEL_EXPORTER_OTLP_TRACES_ENDPOINT,
+ };
+});
+
+export type TelemetryConfig = ConfigType;
diff --git a/packages/backend-nest/src/discord/discord-guild.service.spec.ts b/packages/backend-nest/src/discord/discord-guild.service.spec.ts
new file mode 100644
index 000000000..b2b1bcdd2
--- /dev/null
+++ b/packages/backend-nest/src/discord/discord-guild.service.spec.ts
@@ -0,0 +1,358 @@
+import { Test } from "@nestjs/testing";
+import type { SessionData } from "express-session";
+
+import { PrismaService } from "@/common/database/prisma.service";
+import { NotFoundError } from "@/common/errors/not-found.error";
+import { TooManyRequestsError } from "@/common/errors/too-many-requests.error";
+import { UnauthorizedError } from "@/common/errors/unauthorized.error";
+import { fetchWithRetries } from "@/common/fetch-with-retries";
+import { type DiscordConfig, discordConfig } from "@/config/discord.config";
+import { DiscordGuildService } from "@/discord/discord-guild.service";
+import { testPrisma as prisma } from "@/test/database";
+
+vi.mock("@/common/fetch-with-retries", () => ({
+ fetchWithRetries: vi.fn(),
+}));
+
+const mockFetch = vi.mocked(fetchWithRetries);
+
+const managementConfig: DiscordConfig = {
+ clientId: "client-id",
+ clientSecret: "client-secret",
+ managementGuildId: "999",
+ adminRoleId: "admin-role",
+ moderatorRoleId: "mod-role",
+ serverInvite: undefined,
+};
+
+async function makeService(
+ config: Partial = {},
+): Promise {
+ const moduleRef = await Test.createTestingModule({
+ providers: [
+ DiscordGuildService,
+ { provide: PrismaService, useValue: prisma },
+ {
+ provide: discordConfig.KEY,
+ useValue: { ...managementConfig, ...config },
+ },
+ ],
+ }).compile();
+
+ return moduleRef.get(DiscordGuildService);
+}
+
+function mockJsonResponseOnce(body: unknown, init?: ResponseInit) {
+ mockFetch.mockResolvedValueOnce(
+ new Response(JSON.stringify(body), {
+ headers: {
+ "Content-Type": "application/json; charset=utf-8",
+ },
+ ...init,
+ }),
+ );
+}
+
+function makeSession(overrides: Partial = {}): SessionData {
+ return { cookie: {} as SessionData["cookie"], ...overrides };
+}
+
+const sampleGuilds = [
+ {
+ id: "1",
+ name: "Guild 1",
+ permissions: "0",
+ approximate_member_count: 10,
+ },
+];
+
+describe("DiscordGuildService", () => {
+ let service: DiscordGuildService;
+
+ beforeEach(async () => {
+ mockFetch.mockReset();
+ service = await makeService();
+ });
+
+ describe("getCachedUserGuildFlags", () => {
+ beforeEach(() => {
+ vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
+ });
+
+ afterEach(() => {
+ vi.useRealTimers();
+ });
+
+ it("fetches fresh flags when the session has no cache", async () => {
+ mockJsonResponseOnce(sampleGuilds);
+
+ const session = makeSession();
+ const result = await service.getCachedUserGuildFlags(session, "token");
+
+ expect(result).toMatchObject({ "1": { name: "Guild 1" } });
+ expect(mockFetch).toHaveBeenCalledTimes(1);
+ expect(session.discordGuildFlags).toEqual(result);
+ expect(session.discordGuildFlagsFetchedAt).toBe(Date.now());
+ });
+
+ it("returns cached flags within the TTL window without hitting Discord", async () => {
+ const cachedFlags = {
+ "1": {
+ name: "Cached Guild",
+ memberCount: 10,
+ administrator: false,
+ manageGuild: false,
+ },
+ };
+ const session = makeSession({
+ discordGuildFlags: cachedFlags,
+ discordGuildFlagsFetchedAt: Date.now(),
+ });
+
+ vi.advanceTimersByTime(14 * 60 * 1000);
+
+ const result = await service.getCachedUserGuildFlags(session, "token");
+
+ expect(result).toEqual(cachedFlags);
+ expect(mockFetch).not.toHaveBeenCalled();
+ });
+
+ it("refetches when the cached flags are older than the TTL", async () => {
+ const cachedFlags = {
+ "1": {
+ name: "Stale Guild",
+ memberCount: 10,
+ administrator: false,
+ manageGuild: false,
+ },
+ };
+ const session = makeSession({
+ discordGuildFlags: cachedFlags,
+ discordGuildFlagsFetchedAt: Date.now(),
+ });
+
+ vi.advanceTimersByTime(15 * 60 * 1000 + 1);
+
+ mockJsonResponseOnce([
+ {
+ id: "2",
+ name: "Refreshed Guild",
+ permissions: "0",
+ approximate_member_count: 5,
+ },
+ ]);
+
+ const result = await service.getCachedUserGuildFlags(session, "token");
+
+ expect(result).toMatchObject({ "2": { name: "Refreshed Guild" } });
+ expect(mockFetch).toHaveBeenCalledTimes(1);
+ expect(session.discordGuildFlags).toEqual(result);
+ expect(session.discordGuildFlagsFetchedAt).toBe(Date.now());
+ });
+
+ it("refetches when discordGuildFlagsFetchedAt is missing", async () => {
+ mockJsonResponseOnce(sampleGuilds);
+
+ const session = makeSession({
+ discordGuildFlags: {
+ old: {
+ name: "Old",
+ memberCount: null,
+ administrator: false,
+ manageGuild: false,
+ },
+ },
+ });
+
+ const result = await service.getCachedUserGuildFlags(session, "token");
+
+ expect(result).toMatchObject({ "1": { name: "Guild 1" } });
+ expect(mockFetch).toHaveBeenCalledTimes(1);
+ expect(session.discordGuildFlagsFetchedAt).toBe(Date.now());
+ });
+ });
+
+ describe("getGuildPermissionsForUser", () => {
+ it.each([
+ ["8", { administrator: true, manage_guild: true }],
+ ["32", { administrator: false, manage_guild: true }],
+ ["40", { administrator: true, manage_guild: true }],
+ ["0", { administrator: false, manage_guild: false }],
+ [undefined, { administrator: false, manage_guild: false }],
+ ])("maps the permission bitfield %s", async (permissions, expected) => {
+ mockJsonResponseOnce([{ id: "42", name: "Guild", permissions }]);
+
+ const result = await service.getGuildPermissionsForUser("42", "token");
+
+ expect(result).toEqual(expected);
+ });
+
+ it("throws NotFoundError when the guild is not in the user's list", async () => {
+ mockJsonResponseOnce(sampleGuilds);
+
+ await expect(
+ service.getGuildPermissionsForUser("42", "token"),
+ ).rejects.toBeInstanceOf(NotFoundError);
+ });
+ });
+
+ describe("getCurrentUserGuildFlags", () => {
+ it("derives administrator and manageGuild flags per guild", async () => {
+ mockJsonResponseOnce([
+ {
+ id: "1",
+ name: "Admin Guild",
+ permissions: "8",
+ approximate_member_count: 3,
+ },
+ { id: "2", name: "Member Guild", permissions: "0" },
+ ]);
+
+ const result = await service.getCurrentUserGuildFlags("token");
+
+ expect(result).toEqual({
+ "1": {
+ name: "Admin Guild",
+ memberCount: 3,
+ administrator: true,
+ manageGuild: true,
+ },
+ "2": {
+ name: "Member Guild",
+ memberCount: null,
+ administrator: false,
+ manageGuild: false,
+ },
+ });
+ });
+
+ it("maps Discord 401 responses onto UnauthorizedError", async () => {
+ mockJsonResponseOnce({}, { status: 401 });
+
+ await expect(
+ service.getCurrentUserGuildFlags("token"),
+ ).rejects.toBeInstanceOf(UnauthorizedError);
+ });
+
+ it("maps Discord 429 responses onto TooManyRequestsError", async () => {
+ vi.spyOn(console, "error").mockImplementation(() => {});
+ mockJsonResponseOnce(
+ {},
+ { status: 429, headers: { "retry-after": "1.5" } },
+ );
+
+ await expect(
+ service.getCurrentUserGuildFlags("token"),
+ ).rejects.toBeInstanceOf(TooManyRequestsError);
+ vi.restoreAllMocks();
+ });
+ });
+
+ describe("isCanvasAdmin / isCanvasModerator", () => {
+ it("returns true when the member has the admin role", async () => {
+ mockJsonResponseOnce({ roles: ["admin-role", "other"] });
+
+ await expect(service.isCanvasAdmin("token")).resolves.toBe(true);
+ expect(mockFetch).toHaveBeenCalledWith(
+ "https://discord.com/api/v10/users/@me/guilds/999/member",
+ expect.anything(),
+ );
+ });
+
+ it("returns false when the member lacks the admin role", async () => {
+ mockJsonResponseOnce({ roles: ["mod-role"] });
+
+ await expect(service.isCanvasAdmin("token")).resolves.toBe(false);
+ });
+
+ it("accepts either the moderator or the admin role for moderators", async () => {
+ mockJsonResponseOnce({ roles: ["admin-role"] });
+
+ await expect(service.isCanvasModerator("token")).resolves.toBe(true);
+ });
+
+ it("returns false when the user is not a member of the management guild", async () => {
+ mockJsonResponseOnce({}, { status: 404 });
+
+ await expect(service.isCanvasModerator("token")).resolves.toBe(false);
+ });
+
+ it("returns false without hitting Discord when the management guild is not configured", async () => {
+ const service = await makeService({
+ managementGuildId: undefined,
+ });
+
+ await expect(service.isCanvasAdmin("token")).resolves.toBe(false);
+ await expect(service.isCanvasModerator("token")).resolves.toBe(false);
+ expect(mockFetch).not.toHaveBeenCalled();
+ });
+
+ it("returns false for admins when no admin role is configured", async () => {
+ const service = await makeService({ adminRoleId: undefined });
+
+ await expect(service.isCanvasAdmin("token")).resolves.toBe(false);
+ expect(mockFetch).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("syncDiscordGuildRecords", () => {
+ it("creates records for unknown guilds", async () => {
+ await service.syncDiscordGuildRecords({
+ "123": {
+ name: "New Guild",
+ memberCount: 1,
+ administrator: false,
+ manageGuild: false,
+ },
+ });
+
+ const record = await prisma.discordGuildRecord.findUnique({
+ where: { guildId: 123n },
+ });
+ expect(record).toEqual({ guildId: 123n, name: "New Guild" });
+ });
+
+ it("updates records whose name changed", async () => {
+ await prisma.discordGuildRecord.create({
+ data: { guildId: 123n, name: "Old Name" },
+ });
+
+ await service.syncDiscordGuildRecords({
+ "123": {
+ name: "New Name",
+ memberCount: 1,
+ administrator: false,
+ manageGuild: false,
+ },
+ });
+
+ const record = await prisma.discordGuildRecord.findUnique({
+ where: { guildId: 123n },
+ });
+ expect(record).toEqual({ guildId: 123n, name: "New Name" });
+ });
+
+ it("leaves unchanged records alone and handles empty input", async () => {
+ await prisma.discordGuildRecord.create({
+ data: { guildId: 123n, name: "Same Name" },
+ });
+
+ await service.syncDiscordGuildRecords({
+ "123": {
+ name: "Same Name",
+ memberCount: 1,
+ administrator: false,
+ manageGuild: false,
+ },
+ });
+ await service.syncDiscordGuildRecords({});
+ await service.syncDiscordGuildRecords(undefined);
+
+ const record = await prisma.discordGuildRecord.findUnique({
+ where: { guildId: 123n },
+ });
+ expect(record).toEqual({ guildId: 123n, name: "Same Name" });
+ });
+ });
+});
diff --git a/packages/backend-nest/src/discord/discord-guild.service.ts b/packages/backend-nest/src/discord/discord-guild.service.ts
new file mode 100644
index 000000000..b1ead2c16
--- /dev/null
+++ b/packages/backend-nest/src/discord/discord-guild.service.ts
@@ -0,0 +1,333 @@
+import type { GuildData } from "@blurple-canvas-web/types";
+import { Inject, Injectable } from "@nestjs/common";
+import type { SessionData } from "express-session";
+
+import { PrismaService } from "@/common/database/prisma.service";
+import { ApiError } from "@/common/errors/api.error";
+import { BadRequestError } from "@/common/errors/bad-request.error";
+import { NotFoundError } from "@/common/errors/not-found.error";
+import { TooManyRequestsError } from "@/common/errors/too-many-requests.error";
+import { UnauthorizedError } from "@/common/errors/unauthorized.error";
+import { fetchWithRetries } from "@/common/fetch-with-retries";
+import { type DiscordConfig, discordConfig } from "@/config/discord.config";
+
+const GUILD_FLAGS_CACHE_TTL_MS = 900_000; // 15 min
+
+const DISCORD_API_BASE_URL = "https://discord.com/api/v10";
+const ADMINISTRATOR_PERMISSION = 0x8n;
+const MANAGE_GUILD_PERMISSION = 0x20n;
+
+interface DiscordGuild {
+ id: string;
+ name: string;
+ owner_id: string;
+ permissions?: string;
+ approximate_member_count?: number;
+}
+
+interface DiscordGuildMember {
+ user?: {
+ id: string;
+ };
+ roles: string[];
+}
+
+export interface GuildPermissionsSummary {
+ administrator: boolean;
+ manage_guild: boolean;
+}
+
+interface DiscordRequestOptions {
+ endpoint: string;
+ authorization: `Bearer ${string}`;
+}
+
+interface UserHasRolesInGuildProps {
+ guildId: string;
+ roleIds: string[];
+ accessToken: string;
+}
+
+type DiscordRateLimitHeader =
+ | "x-ratelimit-limit"
+ | "x-ratelimit-remaining"
+ | "x-ratelimit-reset"
+ | "x-ratelimit-reset-after"
+ | "x-ratelimit-bucket";
+
+@Injectable()
+export class DiscordGuildService {
+ private readonly discordRateLimitHeaders = new Set([
+ "x-ratelimit-limit",
+ "x-ratelimit-remaining",
+ "x-ratelimit-reset",
+ "x-ratelimit-reset-after",
+ "x-ratelimit-bucket",
+ ]);
+ constructor(
+ private readonly prisma: PrismaService,
+ @Inject(discordConfig.KEY) private readonly config: DiscordConfig,
+ ) {}
+
+ async getGuildPermissionsForUser(
+ guildId: string,
+ accessToken: string,
+ ): Promise {
+ const guilds = await this.discordRequest({
+ endpoint: "/users/@me/guilds?with_counts=true",
+ authorization: this.asBearerToken(accessToken),
+ });
+
+ const guild = guilds.find((currentGuild) => currentGuild.id === guildId);
+
+ if (!guild) {
+ throw new NotFoundError(
+ `Discord resource not found: /users/@me/guilds/${encodeURIComponent(guildId)}`,
+ );
+ }
+
+ const permissions = BigInt(guild.permissions ?? "0");
+ return this.getPermissions(permissions);
+ }
+
+ async isCanvasAdmin(accessToken: string): Promise {
+ const guildId = this.config.managementGuildId;
+ const roleId = this.config.adminRoleId;
+
+ if (!guildId || !roleId || !accessToken) {
+ return false;
+ }
+
+ return await this.userHasRolesInGuild({
+ guildId,
+ roleIds: [roleId],
+ accessToken,
+ });
+ }
+
+ async isCanvasModerator(accessToken: string): Promise {
+ const guildId = this.config.managementGuildId;
+ const roleIds = [
+ this.config.moderatorRoleId,
+ this.config.adminRoleId,
+ ].filter((roleId): roleId is string => Boolean(roleId));
+
+ if (!guildId || !accessToken || roleIds.length === 0) {
+ return false;
+ }
+
+ return await this.userHasRolesInGuild({ guildId, roleIds, accessToken });
+ }
+
+ async getCurrentUserGuildFlags(
+ accessToken: string,
+ ): Promise> {
+ const guilds = await this.discordRequest({
+ endpoint: "/users/@me/guilds?with_counts=true",
+ authorization: this.asBearerToken(accessToken),
+ });
+
+ return Object.fromEntries(
+ guilds.map((guild) => {
+ const permissions = BigInt(guild.permissions ?? "0");
+ const { administrator, manage_guild: manageGuild } =
+ this.getPermissions(permissions);
+ return [
+ guild.id,
+ {
+ name: guild.name,
+ memberCount: guild.approximate_member_count ?? null,
+ administrator,
+ manageGuild,
+ },
+ ];
+ }),
+ );
+ }
+
+ async getCachedUserGuildFlags(
+ session: SessionData,
+ accessToken: string,
+ ): Promise> {
+ const cached = session.discordGuildFlags;
+ const fetchedAt = session.discordGuildFlagsFetchedAt;
+ const isFresh =
+ cached !== undefined &&
+ typeof fetchedAt === "number" &&
+ Date.now() - fetchedAt < GUILD_FLAGS_CACHE_TTL_MS;
+
+ if (isFresh) {
+ return cached;
+ }
+
+ return await this.refreshCachedUserGuildFlags(session, accessToken);
+ }
+
+ async refreshCachedUserGuildFlags(
+ session: SessionData,
+ accessToken: string,
+ ): Promise> {
+ const guildFlags = await this.getCurrentUserGuildFlags(accessToken);
+ session.discordGuildFlags = guildFlags;
+ session.discordGuildFlagsFetchedAt = Date.now();
+ return guildFlags;
+ }
+
+ async syncDiscordGuildRecords(
+ guildFlags?: Record,
+ ): Promise {
+ if (!guildFlags || Object.keys(guildFlags).length === 0) return;
+
+ // not an upsert because upserts are expensive, especially when most existing rows probably won't need updates
+
+ const entries = Object.entries(guildFlags);
+ const ids = entries.map(([id]) => BigInt(id));
+
+ // 1) fetch existing records once
+ const existing = await this.prisma.discordGuildRecord.findMany({
+ where: { guildId: { in: ids } },
+ });
+ const existingMap = new Map(existing.map((r) => [r.guildId.toString(), r]));
+
+ // 2) compute create + update sets
+ const toCreate = entries
+ .filter(([id]) => !existingMap.has(id))
+ .map(([id, data]) => ({ guildId: BigInt(id), name: data.name }));
+
+ const toUpdateEntries = entries.filter(([id, data]) => {
+ const ex = existingMap.get(id);
+ return !!ex && ex.name !== data.name;
+ });
+
+ if (toCreate.length === 0 && toUpdateEntries.length === 0) return;
+
+ // 3) Create missing rows
+ if (toCreate.length > 0) {
+ await this.prisma.discordGuildRecord.createMany({
+ data: toCreate,
+ skipDuplicates: true,
+ });
+ }
+
+ // 4) Update changed names in bounded parallel chunks
+ const UPDATE_CHUNK = 50;
+ for (let i = 0; i < toUpdateEntries.length; i += UPDATE_CHUNK) {
+ const chunk = toUpdateEntries.slice(i, i + UPDATE_CHUNK);
+ await Promise.all(
+ chunk.map(([id, data]) =>
+ this.prisma.discordGuildRecord.update({
+ where: { guildId: BigInt(id) },
+ data: { name: data.name },
+ }),
+ ),
+ );
+ }
+ }
+
+ private isDiscordRateLimitHeader(key: string): key is DiscordRateLimitHeader {
+ return this.discordRateLimitHeaders.has(key as DiscordRateLimitHeader);
+ }
+
+ private async discordRequest({
+ endpoint,
+ authorization,
+ }: DiscordRequestOptions): Promise {
+ const response = await fetchWithRetries(
+ `${DISCORD_API_BASE_URL}${endpoint}`,
+ {
+ headers: {
+ Authorization: authorization,
+ },
+ },
+ );
+
+ if (response.status === 401 || response.status === 403) {
+ throw new UnauthorizedError(
+ "Discord token is invalid or missing permissions",
+ );
+ }
+
+ if (response.status === 404) {
+ throw new NotFoundError(`Discord resource not found: ${endpoint}`);
+ }
+
+ if (response.status === 429) {
+ const rateLimitHeaders: Partial> =
+ {};
+ for (const [k, v] of response.headers.entries()) {
+ if (this.isDiscordRateLimitHeader(k)) rateLimitHeaders[k] = v;
+ }
+
+ console.error("Headers", rateLimitHeaders);
+ console.error("Body", await response.json());
+
+ const retryAfter = response.headers.get("retry-after");
+ const suffix =
+ retryAfter ?
+ new Intl.DurationFormat("en-US", { style: "narrow" }).format({
+ seconds: Math.ceil(Number.parseFloat(retryAfter)),
+ })
+ : "";
+ throw new TooManyRequestsError(
+ `Rate limited by Discord API. Please try again${suffix}.`,
+ );
+ }
+
+ if (!response.ok) {
+ console.error(response);
+ throw new BadRequestError(
+ `Discord API request failed with status ${response.status}: ${endpoint}`,
+ );
+ }
+
+ const contentType = response.headers.get("content-type");
+ if (!contentType?.startsWith("application/json")) {
+ throw new ApiError(
+ `Expected application/json but got ${contentType}`,
+ 500,
+ );
+ }
+
+ return (await response.json()) as T;
+ }
+
+ private asBearerToken(accessToken: T): `Bearer ${T}` {
+ return `Bearer ${accessToken}`;
+ }
+
+ private getPermissions(permissions: bigint): GuildPermissionsSummary {
+ const administrator =
+ (permissions & ADMINISTRATOR_PERMISSION) === ADMINISTRATOR_PERMISSION;
+ const manageGuild =
+ administrator ||
+ (permissions & MANAGE_GUILD_PERMISSION) === MANAGE_GUILD_PERMISSION;
+
+ return {
+ administrator,
+ manage_guild: manageGuild,
+ };
+ }
+
+ private async userHasRolesInGuild({
+ guildId,
+ roleIds,
+ accessToken,
+ }: UserHasRolesInGuildProps): Promise {
+ let member: DiscordGuildMember;
+
+ try {
+ member = await this.discordRequest({
+ endpoint: `/users/@me/guilds/${encodeURIComponent(guildId)}/member`,
+ authorization: this.asBearerToken(accessToken),
+ });
+ } catch (error) {
+ if (error instanceof NotFoundError) {
+ return false;
+ }
+
+ throw error;
+ }
+
+ return member.roles.some((role) => roleIds.includes(role));
+ }
+}
diff --git a/packages/backend-nest/src/discord/discord-profile.service.spec.ts b/packages/backend-nest/src/discord/discord-profile.service.spec.ts
new file mode 100644
index 000000000..746e9cfd0
--- /dev/null
+++ b/packages/backend-nest/src/discord/discord-profile.service.spec.ts
@@ -0,0 +1,131 @@
+import { Test, type TestingModule } from "@nestjs/testing";
+
+import { DatabaseModule } from "@/common/database/database.module";
+import { NotFoundError } from "@/common/errors/not-found.error";
+import { AppConfigModule } from "@/config/config.module";
+import { testPrisma as prisma } from "@/test/database";
+import { seedDiscordProfiles } from "@/test/seed/discord-profiles";
+import { seedUsers } from "@/test/seed/users";
+import { DiscordModule } from "./discord.module";
+import { DiscordProfileService } from "./discord-profile.service";
+
+describe("DiscordProfileService", () => {
+ let moduleRef: TestingModule;
+ let service: DiscordProfileService;
+
+ beforeAll(async () => {
+ moduleRef = await Test.createTestingModule({
+ imports: [AppConfigModule, DatabaseModule, DiscordModule],
+ }).compile();
+ await moduleRef.init();
+
+ service = moduleRef.get(DiscordProfileService);
+ });
+
+ afterAll(async () => {
+ await moduleRef.close();
+ });
+
+ beforeEach(async () => {
+ await seedUsers();
+ await seedDiscordProfiles();
+ });
+
+ describe("getDiscordProfile", () => {
+ it("returns the Discord profile for a given user ID", async () => {
+ const profile = await service.getDiscordProfile(204778476102877187n);
+
+ expect(profile).toEqual({
+ userId: 204778476102877187n,
+ username: "rocked03",
+ profilePictureUrl:
+ "https://cdn.discordapp.com/avatars/204778476102877187/f4468ea05fa0dada4e3a3fbe18b748fe.png",
+ });
+ });
+
+ it("throws NotFoundError for an unknown user ID", async () => {
+ await expect(
+ service.getDiscordProfile(999999999999999999n),
+ ).rejects.toBeInstanceOf(NotFoundError);
+ });
+ });
+
+ describe("createOrUpdateDiscordProfile", () => {
+ it("creates a discord profile", async () => {
+ const profile = {
+ userId: 111111111111111111n,
+ username: "test_user",
+ profilePictureUrl:
+ "https://cdn.discordapp.com/avatars/204778476102877187/f4468ea05fa0dada4e3a3fbe18b748fe.png",
+ };
+
+ await service.createOrUpdateDiscordProfile(profile);
+
+ const createdProfile = await prisma.discordUserProfile.findUnique({
+ where: { userId: 111111111111111111n },
+ });
+
+ expect(createdProfile).toEqual(profile);
+ });
+
+ it("updates a discord profile", async () => {
+ const profile = {
+ userId: 204778476102877187n,
+ username: "rocked314",
+ profilePictureUrl:
+ "https://cdn.discordapp.com/avatars/204778476102877187/f4468ea05fa0dada4e3a3fbe18b748fe.png",
+ };
+
+ await service.createOrUpdateDiscordProfile(profile);
+
+ const updatedProfile = await prisma.discordUserProfile.findUnique({
+ where: { userId: 204778476102877187n },
+ });
+
+ expect(updatedProfile).toEqual(profile);
+ });
+ });
+
+ describe("createDefaultAvatarUrl", () => {
+ it("creates a default avatar URL for a given user ID", () => {
+ const url = service.createDefaultAvatarUrl(111111111111111111n);
+
+ expect(url).toEqual("https://cdn.discordapp.com/embed/avatars/1.png");
+ });
+ });
+
+ describe("createCustomAvatarUrl", () => {
+ it("creates a custom avatar URL for a given user ID and profile picture hash", () => {
+ const url = service.createCustomAvatarUrl(
+ 204778476102877187n,
+ "f4468ea05fa0dada4e3a3fbe18b748fe",
+ );
+
+ expect(url).toEqual(
+ "https://cdn.discordapp.com/avatars/204778476102877187/f4468ea05fa0dada4e3a3fbe18b748fe.png",
+ );
+ });
+ });
+
+ describe("saveDiscordProfile", () => {
+ it("saves the discord profile for a given user ID, username, and profile picture URL", async () => {
+ await service.saveDiscordProfile({
+ id: "228441721246056449",
+ username: "rocked03",
+ profilePictureUrl:
+ "https://cdn.discordapp.com/avatars/228441721246056449/67384b584aa7b9145ebb4028ff697931.png",
+ });
+
+ const savedProfile = await prisma.discordUserProfile.findUnique({
+ where: { userId: 228441721246056449n },
+ });
+
+ expect(savedProfile).toEqual({
+ userId: 228441721246056449n,
+ username: "rocked03",
+ profilePictureUrl:
+ "https://cdn.discordapp.com/avatars/228441721246056449/67384b584aa7b9145ebb4028ff697931.png",
+ });
+ });
+ });
+});
diff --git a/packages/backend-nest/src/discord/discord-profile.service.ts b/packages/backend-nest/src/discord/discord-profile.service.ts
new file mode 100644
index 000000000..90120805a
--- /dev/null
+++ b/packages/backend-nest/src/discord/discord-profile.service.ts
@@ -0,0 +1,84 @@
+import type { DiscordUserProfile } from "@blurple-canvas-web/types";
+import { Injectable } from "@nestjs/common";
+
+import type { DiscordUserProfileModel } from "@/common/database/generated/models";
+import { PrismaService } from "@/common/database/prisma.service";
+import { NotFoundError } from "@/common/errors/not-found.error";
+
+@Injectable()
+export class DiscordProfileService {
+ constructor(private readonly prisma: PrismaService) {}
+
+ createDefaultAvatarUrl(userId: bigint): string {
+ const BIT_SHIFT_VALUE = 22n;
+ const NUMBER_OF_AVATARS = 6n;
+ const avatarId = (userId >> BIT_SHIFT_VALUE) % NUMBER_OF_AVATARS;
+
+ return `https://cdn.discordapp.com/embed/avatars/${avatarId}.png`;
+ }
+
+ createCustomAvatarUrl(userId: bigint, profilePictureHash: string): string {
+ return `https://cdn.discordapp.com/avatars/${userId}/${profilePictureHash}.png`;
+ }
+
+ getProfilePictureUrlFromHash(
+ userId: bigint,
+ profilePictureHash: string | null,
+ ): string {
+ if (!profilePictureHash) {
+ return this.createDefaultAvatarUrl(userId);
+ }
+
+ return this.createCustomAvatarUrl(userId, profilePictureHash);
+ }
+
+ async getDiscordProfile(userId: bigint): Promise {
+ const discordUserProfile = await this.prisma.discordUserProfile.findFirst({
+ where: { userId },
+ });
+
+ if (!discordUserProfile) {
+ throw new NotFoundError(
+ `Discord profile not found for user ID ${userId}`,
+ );
+ }
+
+ return discordUserProfile;
+ }
+
+ async createOrUpdateDiscordProfile(
+ profile: DiscordUserProfileModel,
+ ): Promise {
+ await this.prisma.discordUserProfile.upsert({
+ where: {
+ userId: profile.userId,
+ },
+ update: {
+ username: profile.username,
+ profilePictureUrl: profile.profilePictureUrl,
+ },
+ create: {
+ username: profile.username,
+ profilePictureUrl: profile.profilePictureUrl,
+ user: {
+ connectOrCreate: {
+ where: {
+ id: profile.userId,
+ },
+ create: {
+ id: profile.userId,
+ },
+ },
+ },
+ },
+ });
+ }
+
+ async saveDiscordProfile(profile: DiscordUserProfile): Promise {
+ await this.createOrUpdateDiscordProfile({
+ userId: BigInt(profile.id),
+ username: profile.username,
+ profilePictureUrl: profile.profilePictureUrl,
+ });
+ }
+}
diff --git a/packages/backend-nest/src/discord/discord-token.service.spec.ts b/packages/backend-nest/src/discord/discord-token.service.spec.ts
new file mode 100644
index 000000000..cdb6a0903
--- /dev/null
+++ b/packages/backend-nest/src/discord/discord-token.service.spec.ts
@@ -0,0 +1,204 @@
+import { Test, type TestingModule } from "@nestjs/testing";
+
+import { UnauthorizedError } from "@/common/errors/unauthorized.error";
+import { DiscordTokenService } from "@/discord/discord-token.service";
+
+const { mockRequestNewAccessToken } = vi.hoisted(() => ({
+ mockRequestNewAccessToken: vi.fn(),
+}));
+
+vi.mock("passport-oauth2-refresh", () => ({
+ default: {
+ requestNewAccessToken: mockRequestNewAccessToken,
+ },
+}));
+
+type RefreshDone = (
+ error: Error | null,
+ accessToken?: string,
+ refreshToken?: string,
+) => void;
+
+function mockRefreshOnce(accessToken?: string, refreshToken?: string) {
+ mockRequestNewAccessToken.mockImplementationOnce(
+ (_strategy: string, _refreshToken: string, done: RefreshDone) => {
+ done(null, accessToken, refreshToken);
+ },
+ );
+}
+
+describe("DiscordTokenService", () => {
+ let moduleRef: TestingModule;
+ let service: DiscordTokenService;
+
+ beforeAll(async () => {
+ moduleRef = await Test.createTestingModule({
+ providers: [DiscordTokenService],
+ }).compile();
+ service = moduleRef.get(DiscordTokenService);
+ });
+
+ afterAll(async () => {
+ await moduleRef.close();
+ });
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe("refreshDiscordAccessToken", () => {
+ it("throws when the refresh token is missing", async () => {
+ await expect(
+ service.refreshDiscordAccessToken({ discordAccessToken: "token" }),
+ ).rejects.toBeInstanceOf(UnauthorizedError);
+ expect(mockRequestNewAccessToken).not.toHaveBeenCalled();
+ });
+
+ it("updates the session with refreshed tokens", async () => {
+ mockRefreshOnce("new-access-token", "new-refresh-token");
+
+ const session = {
+ discordAccessToken: "old-access-token",
+ discordRefreshToken: "old-refresh-token",
+ discordTokenExpiresAt: undefined,
+ };
+
+ const accessToken = await service.refreshDiscordAccessToken(session);
+
+ expect(accessToken).toBe("new-access-token");
+ expect(session.discordAccessToken).toBe("new-access-token");
+ expect(session.discordRefreshToken).toBe("new-refresh-token");
+ expect(session.discordTokenExpiresAt).toBeUndefined();
+ expect(mockRequestNewAccessToken).toHaveBeenCalledWith(
+ "discord",
+ "old-refresh-token",
+ expect.any(Function),
+ );
+ });
+
+ it("tracks the new expiry when the token lifetime is known", async () => {
+ vi.useFakeTimers({ now: 1_000_000 });
+ mockRefreshOnce("new-access-token");
+
+ const session = {
+ discordRefreshToken: "refresh-token",
+ discordTokenLifetimeMs: 60_000,
+ };
+
+ await service.refreshDiscordAccessToken(session);
+
+ expect(session).toMatchObject({ discordTokenExpiresAt: 1_060_000 });
+ vi.useRealTimers();
+ });
+
+ it("deduplicates concurrent refreshes for the same session", async () => {
+ let resolveRefresh: RefreshDone | undefined;
+ mockRequestNewAccessToken.mockImplementationOnce(
+ (_strategy: string, _refreshToken: string, done: RefreshDone) => {
+ resolveRefresh = done;
+ },
+ );
+
+ const session = { discordRefreshToken: "refresh-token" };
+
+ const first = service.refreshDiscordAccessToken(session);
+ const second = service.refreshDiscordAccessToken(session);
+
+ resolveRefresh?.(null, "refreshed-token");
+
+ await expect(first).resolves.toBe("refreshed-token");
+ await expect(second).resolves.toBe("refreshed-token");
+ expect(mockRequestNewAccessToken).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe("getDiscordAccessToken", () => {
+ it("returns the existing access token when no refresh is needed", async () => {
+ const accessToken = await service.getDiscordAccessToken({
+ discordAccessToken: "cached-token",
+ discordRefreshToken: "refresh-token",
+ });
+
+ expect(accessToken).toBe("cached-token");
+ expect(mockRequestNewAccessToken).not.toHaveBeenCalled();
+ });
+
+ it("refreshes when the access token is missing", async () => {
+ mockRefreshOnce("refreshed-token");
+
+ const accessToken = await service.getDiscordAccessToken({
+ discordRefreshToken: "refresh-token",
+ });
+
+ expect(accessToken).toBe("refreshed-token");
+ expect(mockRequestNewAccessToken).toHaveBeenCalledTimes(1);
+ });
+
+ it("refreshes when the token expires within the 30 s buffer", async () => {
+ vi.useFakeTimers({ now: 1_000_000 });
+ mockRefreshOnce("refreshed-token");
+
+ const accessToken = await service.getDiscordAccessToken({
+ discordAccessToken: "cached-token",
+ discordRefreshToken: "refresh-token",
+ discordTokenExpiresAt: 1_000_000 + 29_999,
+ });
+
+ expect(accessToken).toBe("refreshed-token");
+ expect(mockRequestNewAccessToken).toHaveBeenCalledTimes(1);
+ vi.useRealTimers();
+ });
+
+ it("keeps the cached token while outside the refresh buffer", async () => {
+ vi.useFakeTimers({ now: 1_000_000 });
+
+ const accessToken = await service.getDiscordAccessToken({
+ discordAccessToken: "cached-token",
+ discordRefreshToken: "refresh-token",
+ discordTokenExpiresAt: 1_000_000 + 30_001,
+ });
+
+ expect(accessToken).toBe("cached-token");
+ expect(mockRequestNewAccessToken).not.toHaveBeenCalled();
+ vi.useRealTimers();
+ });
+ });
+
+ describe("withDiscordAccessToken", () => {
+ it("retries once after an unauthorized error from the action", async () => {
+ mockRefreshOnce("refreshed-token");
+
+ const action = vi
+ .fn<(accessToken: string) => Promise>()
+ .mockRejectedValueOnce(new UnauthorizedError("unauthorized"))
+ .mockResolvedValueOnce("retried-success");
+
+ const result = await service.withDiscordAccessToken(
+ {
+ discordAccessToken: "cached-token",
+ discordRefreshToken: "refresh-token",
+ },
+ action,
+ );
+
+ expect(result).toBe("retried-success");
+ expect(action).toHaveBeenCalledTimes(2);
+ expect(action).toHaveBeenNthCalledWith(1, "cached-token");
+ expect(action).toHaveBeenNthCalledWith(2, "refreshed-token");
+ });
+
+ it("rethrows unauthorized errors when no refresh token is available", async () => {
+ const action = vi
+ .fn<(accessToken: string) => Promise>()
+ .mockRejectedValue(new UnauthorizedError("unauthorized"));
+
+ await expect(
+ service.withDiscordAccessToken(
+ { discordAccessToken: "cached-token" },
+ action,
+ ),
+ ).rejects.toBeInstanceOf(UnauthorizedError);
+ expect(action).toHaveBeenCalledTimes(1);
+ });
+ });
+});
diff --git a/packages/backend-nest/src/discord/discord-token.service.ts b/packages/backend-nest/src/discord/discord-token.service.ts
new file mode 100644
index 000000000..5348df4ee
--- /dev/null
+++ b/packages/backend-nest/src/discord/discord-token.service.ts
@@ -0,0 +1,149 @@
+import { Injectable } from "@nestjs/common";
+import refresh from "passport-oauth2-refresh";
+
+import { UnauthorizedError } from "@/common/errors/unauthorized.error";
+import { DISCORD_STRATEGY_NAME } from "@/discord/discord.constants";
+
+/** Refresh proactively when the token expires within this buffer. */
+const DISCORD_TOKEN_REFRESH_BUFFER_MS = 30_000;
+
+export interface DiscordTokenSession {
+ discordAccessToken?: string;
+ discordRefreshToken?: string;
+ discordTokenExpiresAt?: number;
+ discordTokenLifetimeMs?: number;
+}
+
+interface DiscordRefreshedTokenResponse {
+ accessToken: string;
+ refreshToken?: string;
+}
+
+@Injectable()
+export class DiscordTokenService {
+ /** Deduplicates concurrent refreshes per session object. */
+ private readonly inFlightRefreshes = new WeakMap