From 763c70257886f2fb665a6c8b9583f5c55a31ad23 Mon Sep 17 00:00:00 2001 From: DooseWayo <“doosewayo94@gmail.com”> Date: Sun, 30 Aug 2026 18:37:07 +0100 Subject: [PATCH 1/4] chore: resync package-lock.json with package.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit npm ci failed with "Missing: zod@3.25.76 from lock file" — the lock file had drifted from package.json after recent merges. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Uqn6VeNfzGEJsCDDxEJR7P --- package-lock.json | 82 ++++++++++++++--------------------------------- 1 file changed, 24 insertions(+), 58 deletions(-) diff --git a/package-lock.json b/package-lock.json index d35a04c..12682da 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2720,9 +2720,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2737,9 +2734,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2754,9 +2748,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2771,9 +2762,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2788,9 +2776,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -2805,9 +2790,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2822,9 +2804,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -2839,9 +2818,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4039,9 +4015,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4056,9 +4029,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4073,9 +4043,6 @@ "loong64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4090,9 +4057,6 @@ "loong64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4107,9 +4071,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4124,9 +4085,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4141,9 +4099,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4158,9 +4113,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4175,9 +4127,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -4192,9 +4141,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -4873,6 +4819,17 @@ } } }, + "node_modules/@walletconnect/utils/node_modules/zod": { + "version": "3.25.76", + "resolved": "https://registry.npmjs.org/zod/-/zod-3.25.76.tgz", + "integrity": "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ==", + "license": "MIT", + "optional": true, + "peer": true, + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "node_modules/@walletconnect/window-getters": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/@walletconnect/window-getters/-/window-getters-1.0.1.tgz", @@ -5017,6 +4974,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==", + "dev": true, "license": "ISC", "optional": true, "engines": { @@ -6784,6 +6742,7 @@ "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, "license": "MIT", "optional": true, "engines": { @@ -7520,6 +7479,7 @@ "version": "3.1.3", "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", + "dev": true, "license": "Apache-2.0", "optional": true }, @@ -7707,7 +7667,7 @@ "version": "6.5.0", "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=12.0.0" @@ -8240,7 +8200,7 @@ "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "devOptional": true, + "dev": true, "license": "ISC" }, "node_modules/h3": { @@ -10743,6 +10703,7 @@ "version": "12.4.0", "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.4.0.tgz", "integrity": "sha512-OMcPNvqTCFUnNaBlmdgq+lfNqY7gTiSmNRDjY3uAXRyudeKZEZxu3CLtjMQrx4zZxCX2b/mpNqTtwuCJgXhHkw==", + "dev": true, "license": "MIT", "optional": true, "dependencies": { @@ -10779,6 +10740,7 @@ "version": "4.0.0", "resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz", "integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==", + "dev": true, "license": "BlueOak-1.0.0", "optional": true, "engines": { @@ -10789,6 +10751,7 @@ "version": "6.0.1", "resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz", "integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==", + "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -10828,6 +10791,7 @@ "version": "9.0.0", "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz", "integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==", + "dev": true, "license": "ISC", "optional": true, "dependencies": { @@ -11367,7 +11331,7 @@ "version": "4.0.7", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -11731,6 +11695,7 @@ "version": "6.1.0", "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", + "dev": true, "license": "ISC", "optional": true, "engines": { @@ -13552,7 +13517,7 @@ "version": "0.2.17", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", - "devOptional": true, + "dev": true, "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -14168,6 +14133,7 @@ "version": "6.28.0", "resolved": "https://registry.npmjs.org/undici/-/undici-6.28.0.tgz", "integrity": "sha512-LIY910g9TI13YS95lrMFrs8Rm/u/irgHeTWoKCoteeJ04CUJ92eEfj0rVn+7VKMPBpUPiUoBKfhNyLI23EE/KA==", + "dev": true, "license": "MIT", "optional": true, "engines": { From cde37d83c7059be0604eb9825c5721a55758c8aa Mon Sep 17 00:00:00 2001 From: DooseWayo <“doosewayo94@gmail.com”> Date: Sun, 30 Aug 2026 18:37:21 +0100 Subject: [PATCH 2/4] fix(api-keys): repair revocation/scope drift and enforce role ceiling Auditing the API key surface (creation, revocation, scoping) surfaced several bugs from a bad merge between competing ApiKey designs: - ApiKey was never registered in AppDataSource's entities array, so every ApiKeyService call throws "No metadata for ApiKey was found" outside tests (which mask it via synchronize:true). - The entity had isRevoked (never migrated, never read) while the service read/wrote revokedAt (never on the entity). Replaced isRevoked with revokedAt and added the missing keyPrefix column. - scopes and rateLimitTier existed on the entity but were never migrated, so production (migrations-only, no synchronize) would fail to persist them. Added a migration for both. - ApiKeyController passed the request's `permissions` into the `scopes` parameter slot, silently dropping requested scopes. - keyHasScope treated an empty scope list as unrestricted access (fail-open); a key with no scopes now holds none (fail-closed). - keyHasPermission never re-checked the owning user's role, so a permission survived a role downgrade as long as the key still listed it. It now requires both the key to list the permission and the current role to still hold it (matches the documented "never exceed the owner's role" invariant). - createApiKey didn't enforce that invariant at issue time either; it now rejects unknown or role-exceeding permissions up front. Rewrote the stale apiKeyScopes.test.ts (asserted behavior no test subject implements), fixed apiKeyRateLimitTier.test.ts's call signature, and added ApiKeyService.test.ts covering revoked-key rejection, ownership checks, and scope/permission enforcement. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Uqn6VeNfzGEJsCDDxEJR7P --- src/__tests__/apiKeyScopes.test.ts | 77 +++-- src/config/db.ts | 2 + src/controllers/ApiKeyController.ts | 11 +- src/entities/ApiKey.ts | 9 +- .../__tests__/apiKeyRateLimitTier.test.ts | 17 +- ...0000000-AddApiKeyScopesAndRateLimitTier.ts | 52 ++++ src/services/ApiKeyService.ts | 77 ++++- src/services/__tests__/ApiKeyService.test.ts | 267 ++++++++++++++++++ 8 files changed, 472 insertions(+), 40 deletions(-) create mode 100644 src/migrations/1754100000000-AddApiKeyScopesAndRateLimitTier.ts create mode 100644 src/services/__tests__/ApiKeyService.test.ts diff --git a/src/__tests__/apiKeyScopes.test.ts b/src/__tests__/apiKeyScopes.test.ts index 52db019..8fb74e0 100644 --- a/src/__tests__/apiKeyScopes.test.ts +++ b/src/__tests__/apiKeyScopes.test.ts @@ -1,7 +1,7 @@ import 'reflect-metadata'; import { ApiKeyService } from '../services/ApiKeyService'; -import { ApiKey } from '../entities/ApiKey'; -import { User } from '../entities/User'; +import { ApiKey, ApiKeyScope } from '../entities/ApiKey'; +import { User, UserRole } from '../entities/User'; import { Permission } from '../types/Permissions'; // Test suite for API key scope validation and permission enforcement @@ -12,27 +12,68 @@ describe('ApiKey scopes and permissions enforcement', () => { apiKeyService = new ApiKeyService(); }); - it('grants permission if scope matches', () => { - const apiKey = new ApiKey(); - apiKey.permissions = []; - apiKey.scopes = [Permission.CONTENT_MODERATE]; + describe('keyHasScope', () => { + it('grants a scope the key was explicitly issued', () => { + const apiKey = new ApiKey(); + apiKey.scopes = [ApiKeyScope.UPLOAD]; - const user = new User(); - user.role = 'user'; + expect(apiKeyService.keyHasScope(apiKey, ApiKeyScope.UPLOAD)).toBe(true); + }); - const hasPerm = apiKeyService.keyHasPermission(apiKey, user, Permission.CONTENT_MODERATE); - expect(hasPerm).toBe(true); + it('denies a scope the key was not issued', () => { + const apiKey = new ApiKey(); + apiKey.scopes = [ApiKeyScope.READ_ONLY]; + + expect(apiKeyService.keyHasScope(apiKey, ApiKeyScope.UPLOAD)).toBe(false); + }); + + it('the admin scope implies every other scope', () => { + const apiKey = new ApiKey(); + apiKey.scopes = [ApiKeyScope.ADMIN]; + + expect(apiKeyService.keyHasScope(apiKey, ApiKeyScope.UPLOAD)).toBe(true); + expect(apiKeyService.keyHasScope(apiKey, ApiKeyScope.READ_ONLY)).toBe(true); + }); + + it('denies every scope when the key was issued with none (fails closed)', () => { + const apiKey = new ApiKey(); + apiKey.scopes = []; + + expect(apiKeyService.keyHasScope(apiKey, ApiKeyScope.READ_ONLY)).toBe(false); + }); }); - it('rejects if scope is missing', () => { - const apiKey = new ApiKey(); - apiKey.permissions = []; - apiKey.scopes = ['read-only']; + describe('keyHasPermission', () => { + it('grants a permission the key lists when the owning role still holds it', () => { + const apiKey = new ApiKey(); + apiKey.permissions = [Permission.CONTENT_MODERATE]; + + const user = new User(); + user.role = UserRole.MODERATOR; + + expect(apiKeyService.keyHasPermission(apiKey, user, Permission.CONTENT_MODERATE)).toBe(true); + }); + + it('denies a permission the key was not issued', () => { + const apiKey = new ApiKey(); + apiKey.permissions = []; + + const user = new User(); + user.role = UserRole.ADMIN; + + expect(apiKeyService.keyHasPermission(apiKey, user, Permission.CONTENT_MODERATE)).toBe(false); + }); + + it('denies a permission the key lists once the owner is downgraded below it', () => { + // Key was issued while the user was a moderator; the user has since + // been demoted to a listener. The key must not keep the permission. + const apiKey = new ApiKey(); + apiKey.permissions = [Permission.CONTENT_MODERATE]; - const user = new User(); - user.role = 'user'; + const user = new User(); + user.role = UserRole.LISTENER; - const hasPerm = apiKeyService.keyHasPermission(apiKey, user, Permission.CONTENT_MODERATE); - expect(hasPerm).toBe(false); + expect(apiKeyService.keyHasPermission(apiKey, user, Permission.CONTENT_MODERATE)).toBe(false); + }); }); }); diff --git a/src/config/db.ts b/src/config/db.ts index 170c7d9..2b37e7e 100644 --- a/src/config/db.ts +++ b/src/config/db.ts @@ -9,6 +9,7 @@ import { Album } from '../entities/Album'; import { RoyaltyPayout } from '../entities/RoyaltyPayout'; import { WebhookSubscription } from '../entities/WebhookSubscription'; import { TakedownRequest } from '../entities/TakedownRequest'; +import { ApiKey } from '../entities/ApiKey'; dotenv.config(); @@ -32,6 +33,7 @@ const AppDataSource = new DataSource({ RoyaltyPayout, WebhookSubscription, TakedownRequest, + ApiKey, ], migrations: [__dirname + '/../migrations/*.{js,ts}'], migrationsTableName: 'migrations', diff --git a/src/controllers/ApiKeyController.ts b/src/controllers/ApiKeyController.ts index 8cd1a16..9807a0b 100644 --- a/src/controllers/ApiKeyController.ts +++ b/src/controllers/ApiKeyController.ts @@ -30,9 +30,16 @@ export class ApiKeyController { return handleError(req, res, AppError.authentication('User not authenticated')); } - const { name, permissions } = req.body; + const { name, scopes, permissions } = req.body; - const created = await this.apiKeyService.createApiKey(userId, name, permissions ?? []); + // Rate-limit tier is never taken from client input — self-service keys + // always issue at the "standard" tier (see ApiKeyService.createApiKey). + const created = await this.apiKeyService.createApiKey( + userId, + name, + scopes ?? [], + permissions ?? [], + ); res.status(HTTP_STATUS.CREATED).json({ message: 'API key created. Store it now — it will not be shown again.', diff --git a/src/entities/ApiKey.ts b/src/entities/ApiKey.ts index 4ea61e5..c3d3cd7 100644 --- a/src/entities/ApiKey.ts +++ b/src/entities/ApiKey.ts @@ -33,6 +33,10 @@ export class ApiKey { @Column({ unique: true }) keyHash!: string; + /** Non-secret display prefix, e.g. `abk_1a2b3c4d` — shown in key listings. */ + @Column({ nullable: true }) + keyPrefix?: string; + @Column({ type: 'simple-array', default: '' }) scopes!: ApiKeyScope[]; @@ -50,8 +54,9 @@ export class ApiKey { @Column('simple-array', { default: '' }) permissions!: string[]; - @Column({ default: false }) - isRevoked!: boolean; + /** Set when the key is revoked; unset (null) means the key is active. */ + @Column({ type: 'timestamp', nullable: true }) + revokedAt?: Date; @Column({ type: 'timestamp', nullable: true }) lastUsedAt?: Date; diff --git a/src/middlewares/__tests__/apiKeyRateLimitTier.test.ts b/src/middlewares/__tests__/apiKeyRateLimitTier.test.ts index 407205b..6d08258 100644 --- a/src/middlewares/__tests__/apiKeyRateLimitTier.test.ts +++ b/src/middlewares/__tests__/apiKeyRateLimitTier.test.ts @@ -1,9 +1,8 @@ -import request from 'supertest'; import express from 'express'; import { requireApiKey } from '../apiKeyMiddleware'; import { ApiKeyService } from '../../services/ApiKeyService'; import AppDataSource from '../../config/db'; -import { User } from '../../entities/User'; +import { User, UserRole } from '../../entities/User'; jest.mock('../../config/redis', () => { const store = new Map(); @@ -20,7 +19,13 @@ jest.mock('../../config/redis', () => { return Promise.resolve([null, count]); }, expire: () => {}, - exec: () => Promise.resolve([[null, 0], [null, 1], [null, 5], [null, 1]]) + exec: () => + Promise.resolve([ + [null, 0], + [null, 1], + [null, 5], + [null, 1], + ]), }; }, }, @@ -52,13 +57,13 @@ describe('API Key Rate Limit Tiers', () => { email: 'tier@example.com', username: 'tieruser', passwordHash: 'hash', - role: 'admin', + role: UserRole.ADMIN, }); await userRepo.save(user); const service = new ApiKeyService(); - const standardKey = await service.createApiKey(user.id, 'Standard Key', [], 'standard'); - const highKey = await service.createApiKey(user.id, 'High Key', [], 'high'); + const standardKey = await service.createApiKey(user.id, 'Standard Key', [], [], 'standard'); + const highKey = await service.createApiKey(user.id, 'High Key', [], [], 'high'); expect(standardKey.rateLimitTier).toBe('standard'); expect(highKey.rateLimitTier).toBe('high'); diff --git a/src/migrations/1754100000000-AddApiKeyScopesAndRateLimitTier.ts b/src/migrations/1754100000000-AddApiKeyScopesAndRateLimitTier.ts new file mode 100644 index 0000000..987e783 --- /dev/null +++ b/src/migrations/1754100000000-AddApiKeyScopesAndRateLimitTier.ts @@ -0,0 +1,52 @@ +import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm'; + +/** + * Adds the `scopes` and `rateLimitTier` columns to `api_keys`. + * + * These columns were added to the `ApiKey` entity (coarse-grained scopes + * alongside fine-grained `permissions`, and a per-key rate-limit tier) but + * were never migrated, so `synchronize: true` (dev/test) masked the drift + * while production — which runs migrations, not sync — was missing both + * columns entirely. This migration brings the schema back in line with the + * entity. + */ +export class AddApiKeyScopesAndRateLimitTier1754100000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + const table = await queryRunner.getTable('api_keys'); + + if (table && !table.findColumnByName('scopes')) { + await queryRunner.addColumn( + 'api_keys', + new TableColumn({ + name: 'scopes', + type: 'text', + default: "''", + }), + ); + } + + if (table && !table.findColumnByName('rateLimitTier')) { + await queryRunner.addColumn( + 'api_keys', + new TableColumn({ + name: 'rateLimitTier', + type: 'varchar', + length: '50', + default: "'standard'", + }), + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + const table = await queryRunner.getTable('api_keys'); + + if (table?.findColumnByName('rateLimitTier')) { + await queryRunner.dropColumn('api_keys', 'rateLimitTier'); + } + + if (table?.findColumnByName('scopes')) { + await queryRunner.dropColumn('api_keys', 'scopes'); + } + } +} diff --git a/src/services/ApiKeyService.ts b/src/services/ApiKeyService.ts index 64f1f60..8624324 100644 --- a/src/services/ApiKeyService.ts +++ b/src/services/ApiKeyService.ts @@ -1,9 +1,10 @@ import { IsNull, Repository } from 'typeorm'; -import { ApiKey } from '../entities/ApiKey'; -import { User } from '../entities/User'; +import { ApiKey, ApiKeyScope } from '../entities/ApiKey'; +import { User, UserRole } from '../entities/User'; import AppDataSource from '../config/db'; import { AppError } from '../errors/AppError'; import { ERROR_MESSAGES } from '../config/constants'; +import { Permission, roleHasPermission } from '../types/Permissions'; import { validateRequired, validateStringLength, @@ -22,13 +23,18 @@ const API_KEY_NAME_MAX_LENGTH = 100; /** Maximum number of simultaneously active keys per user. */ const MAX_ACTIVE_KEYS_PER_USER = 20; +/** Rate-limit tiers assignable to a key; self-service issuance stays "standard". */ +export const API_KEY_RATE_LIMIT_TIERS = ['standard', 'high', 'unlimited'] as const; +export type ApiKeyRateLimitTier = (typeof API_KEY_RATE_LIMIT_TIERS)[number]; + /** An API key as returned to the client — never carries the hash. */ export interface ApiKeyView { id: string; name: string; keyPrefix?: string; - scopes?: string[]; + scopes?: ApiKeyScope[]; permissions: string[]; + rateLimitTier: string; lastUsedAt?: Date; revokedAt?: Date; createdAt: Date; @@ -66,15 +72,24 @@ export class ApiKeyService { * * @param userId - Owner of the key * @param name - Human-readable label for the key - * @param scopes - Requested scope strings - * @param permissions - Requested permission strings (defaults to none) + * @param scopes - Requested coarse-grained scopes (read-only / upload / admin) + * @param permissions - Requested fine-grained permission strings. Rejected if + * any permission exceeds what the owning user's role currently holds — a + * key can never grant more than its owner has (enforced again on every + * request in {@link keyHasPermission}, so a later role downgrade also + * revokes it). + * @param rateLimitTier - Rate-limit tier for the key. Self-service issuance + * (the public `POST /api/api-keys` route) always passes "standard"; higher + * tiers are only ever assigned by trusted internal callers (e.g. an admin + * flow), never taken directly from client input. * @returns The stored key view plus the one-time raw key */ async createApiKey( userId: string, name: string, - scopes: string[] = [], + scopes: ApiKeyScope[] = [], permissions: string[] = [], + rateLimitTier: ApiKeyRateLimitTier = 'standard', ): Promise { validateUUID(userId, 'userId'); validateRequired(name, 'name'); @@ -86,6 +101,8 @@ export class ApiKeyService { throw AppError.notFound(ERROR_MESSAGES.USER_NOT_FOUND); } + this.assertPermissionsAllowedForRole(user.role, permissions); + const activeKeyCount = await this.apiKeyRepo.count({ where: { userId, revokedAt: IsNull() }, }); @@ -105,6 +122,7 @@ export class ApiKeyService { keyPrefix, scopes, permissions, + rateLimitTier, }); const saved = await this.apiKeyRepo.save(apiKey); @@ -112,6 +130,30 @@ export class ApiKeyService { return { ...this.toView(saved), rawKey }; } + /** + * Rejects issuance when a requested permission is either unrecognized or + * exceeds what `role` currently holds — the "never exceed the owner's role" + * invariant, enforced at issue time. + */ + private assertPermissionsAllowedForRole(role: UserRole, permissions: string[]): void { + for (const permission of permissions) { + if (!Object.values(Permission).includes(permission as Permission)) { + throw AppError.validation( + `Unknown permission: ${permission}`, + undefined, + 'INVALID_PERMISSION', + ); + } + if (!roleHasPermission(role, permission as Permission)) { + throw AppError.authorization( + `Cannot issue a key with permission "${permission}": your role does not hold it`, + undefined, + 'PERMISSION_EXCEEDS_ROLE', + ); + } + } + } + /** * Lists a user's API keys. Hashes are never included. * @@ -190,18 +232,28 @@ export class ApiKeyService { return { apiKey, user: apiKey.user }; } - keyHasScope(apiKey: ApiKey, requiredScope: string): boolean { + /** + * A key must be explicitly granted `requiredScope` (or `admin`, which + * implies every scope). A key issued with no scopes holds none — it does + * not fall back to unrestricted access. + */ + keyHasScope(apiKey: ApiKey, requiredScope: ApiKeyScope): boolean { if (!apiKey.scopes || apiKey.scopes.length === 0) { - return true; + return false; } - return apiKey.scopes.includes(requiredScope) || apiKey.scopes.includes('admin'); + return apiKey.scopes.includes(requiredScope) || apiKey.scopes.includes(ApiKeyScope.ADMIN); } + /** + * A permission is granted only when the key lists it AND the owning user's + * role still holds it — so a role downgrade after issuance revokes the + * permission immediately, without needing to touch the key itself. + */ keyHasPermission(apiKey: ApiKey, user: User, permission: string): boolean { - if (apiKey.permissions && apiKey.permissions.includes(permission)) { - return true; + if (!apiKey.permissions || !apiKey.permissions.includes(permission)) { + return false; } - return false; + return roleHasPermission(user.role, permission as Permission); } private toView(apiKey: ApiKey): ApiKeyView { @@ -211,6 +263,7 @@ export class ApiKeyService { keyPrefix: apiKey.keyPrefix, scopes: apiKey.scopes, permissions: apiKey.permissions, + rateLimitTier: apiKey.rateLimitTier, lastUsedAt: apiKey.lastUsedAt, revokedAt: apiKey.revokedAt, createdAt: apiKey.createdAt, diff --git a/src/services/__tests__/ApiKeyService.test.ts b/src/services/__tests__/ApiKeyService.test.ts new file mode 100644 index 0000000..57addb2 --- /dev/null +++ b/src/services/__tests__/ApiKeyService.test.ts @@ -0,0 +1,267 @@ +import 'reflect-metadata'; + +jest.mock('../../config/db', () => ({ + __esModule: true, + default: { getRepository: jest.fn() }, +})); + +import AppDataSource from '../../config/db'; +import { ApiKeyService } from '../ApiKeyService'; +import { ApiKey, ApiKeyScope } from '../../entities/ApiKey'; +import { User, UserRole } from '../../entities/User'; +import { Permission } from '../../types/Permissions'; + +const USER_ID = '11111111-1111-4111-8111-111111111111'; +const KEY_ID = '22222222-2222-4222-8222-222222222222'; +const OTHER_USER_ID = '33333333-3333-4333-8333-333333333333'; + +const mockApiKeyRepo = { + count: jest.fn(), + create: jest.fn((entity) => entity), + save: jest.fn((entity) => Promise.resolve({ id: KEY_ID, ...entity })), + find: jest.fn(), + findOne: jest.fn(), +}; + +const mockUserRepo = { + findOne: jest.fn(), +}; + +function makeUser(role: UserRole = UserRole.ARTIST): User { + const user = new User(); + user.id = USER_ID; + user.role = role; + return user; +} + +function makeApiKey(overrides: Partial = {}): ApiKey { + const apiKey = new ApiKey(); + apiKey.id = KEY_ID; + apiKey.userId = USER_ID; + apiKey.name = 'Test key'; + apiKey.keyHash = 'hash'; + apiKey.scopes = []; + apiKey.permissions = []; + apiKey.rateLimitTier = 'standard'; + apiKey.revokedAt = undefined; + apiKey.createdAt = new Date(); + Object.assign(apiKey, overrides); + return apiKey; +} + +beforeEach(() => { + jest.clearAllMocks(); + (AppDataSource.getRepository as jest.Mock).mockImplementation((entity) => { + if (entity === ApiKey) return mockApiKeyRepo; + if (entity === User) return mockUserRepo; + return {}; + }); +}); + +describe('ApiKeyService.createApiKey', () => { + it('rejects issuance when a requested permission exceeds the caller role', async () => { + mockUserRepo.findOne.mockResolvedValue(makeUser(UserRole.LISTENER)); + const service = new ApiKeyService(); + + await expect( + service.createApiKey(USER_ID, 'Escalated key', [], [Permission.USER_MANAGE]), + ).rejects.toMatchObject({ statusCode: 403 }); + + expect(mockApiKeyRepo.save).not.toHaveBeenCalled(); + }); + + it('rejects issuance for an unrecognized permission string', async () => { + mockUserRepo.findOne.mockResolvedValue(makeUser(UserRole.ADMIN)); + const service = new ApiKeyService(); + + await expect( + service.createApiKey(USER_ID, 'Bad key', [], ['not-a-real-permission']), + ).rejects.toMatchObject({ statusCode: 400 }); + }); + + it('allows issuance when requested permissions are within the caller role', async () => { + mockUserRepo.findOne.mockResolvedValue(makeUser(UserRole.MODERATOR)); + mockApiKeyRepo.count.mockResolvedValue(0); + const service = new ApiKeyService(); + + const created = await service.createApiKey( + USER_ID, + 'Moderation key', + [], + [Permission.CONTENT_MODERATE], + ); + + expect(created.rawKey).toMatch(/^abk_/); + expect(created.permissions).toEqual([Permission.CONTENT_MODERATE]); + }); + + it('defaults new keys to the standard rate-limit tier', async () => { + mockUserRepo.findOne.mockResolvedValue(makeUser()); + mockApiKeyRepo.count.mockResolvedValue(0); + const service = new ApiKeyService(); + + const created = await service.createApiKey(USER_ID, 'Default tier key'); + + expect(created.rateLimitTier).toBe('standard'); + }); + + it('rejects issuance once the active-key limit is reached', async () => { + mockUserRepo.findOne.mockResolvedValue(makeUser()); + mockApiKeyRepo.count.mockResolvedValue(20); + const service = new ApiKeyService(); + + await expect(service.createApiKey(USER_ID, 'One too many')).rejects.toMatchObject({ + statusCode: 400, + }); + }); + + it('counts only non-revoked keys toward the active-key limit', async () => { + mockUserRepo.findOne.mockResolvedValue(makeUser()); + mockApiKeyRepo.count.mockResolvedValue(5); + const service = new ApiKeyService(); + + await service.createApiKey(USER_ID, 'Fine'); + + expect(mockApiKeyRepo.count).toHaveBeenCalledWith( + expect.objectContaining({ where: expect.objectContaining({ userId: USER_ID }) }), + ); + }); +}); + +describe('ApiKeyService.validateApiKey — revoked-key rejection', () => { + it('rejects a revoked key even when the hash matches', async () => { + const rawKey = 'abk_' + 'a'.repeat(64); + const { hashApiKey } = jest.requireActual('../../utils/apiKeyCrypto'); + const keyHash = hashApiKey(rawKey); + + mockApiKeyRepo.findOne.mockResolvedValue( + makeApiKey({ keyHash, revokedAt: new Date(), user: makeUser() } as Partial), + ); + const service = new ApiKeyService(); + + await expect(service.validateApiKey(rawKey)).rejects.toMatchObject({ statusCode: 401 }); + }); + + it('accepts an active (non-revoked) key', async () => { + const rawKey = 'abk_' + 'b'.repeat(64); + const { hashApiKey } = jest.requireActual('../../utils/apiKeyCrypto'); + const keyHash = hashApiKey(rawKey); + const user = makeUser(); + + mockApiKeyRepo.findOne.mockResolvedValue( + makeApiKey({ keyHash, revokedAt: undefined, user } as Partial), + ); + const service = new ApiKeyService(); + + const result = await service.validateApiKey(rawKey); + + expect(result.user).toBe(user); + }); + + it('rejects a malformed key before touching the database', async () => { + const service = new ApiKeyService(); + + await expect(service.validateApiKey('not-a-key')).rejects.toMatchObject({ statusCode: 401 }); + expect(mockApiKeyRepo.findOne).not.toHaveBeenCalled(); + }); + + it('rejects when no key matches the hash', async () => { + mockApiKeyRepo.findOne.mockResolvedValue(null); + const service = new ApiKeyService(); + + await expect(service.validateApiKey('abk_' + 'c'.repeat(64))).rejects.toMatchObject({ + statusCode: 401, + }); + }); +}); + +describe('ApiKeyService.revokeApiKey', () => { + it('revokes a key the caller owns', async () => { + mockApiKeyRepo.findOne.mockResolvedValue(makeApiKey()); + const service = new ApiKeyService(); + + const revoked = await service.revokeApiKey(USER_ID, KEY_ID); + + expect(revoked.revokedAt).toBeInstanceOf(Date); + expect(mockApiKeyRepo.save).toHaveBeenCalled(); + }); + + it('is idempotent — revoking an already-revoked key does not error or re-save', async () => { + const alreadyRevokedAt = new Date('2026-01-01T00:00:00Z'); + mockApiKeyRepo.findOne.mockResolvedValue(makeApiKey({ revokedAt: alreadyRevokedAt })); + const service = new ApiKeyService(); + + const result = await service.revokeApiKey(USER_ID, KEY_ID); + + expect(result.revokedAt).toBe(alreadyRevokedAt); + expect(mockApiKeyRepo.save).not.toHaveBeenCalled(); + }); + + it('refuses to revoke a key owned by a different user', async () => { + mockApiKeyRepo.findOne.mockResolvedValue(makeApiKey({ userId: OTHER_USER_ID })); + const service = new ApiKeyService(); + + await expect(service.revokeApiKey(USER_ID, KEY_ID)).rejects.toMatchObject({ + statusCode: 404, + }); + expect(mockApiKeyRepo.save).not.toHaveBeenCalled(); + }); + + it('returns not-found for a key that does not exist', async () => { + mockApiKeyRepo.findOne.mockResolvedValue(null); + const service = new ApiKeyService(); + + await expect(service.revokeApiKey(USER_ID, KEY_ID)).rejects.toMatchObject({ + statusCode: 404, + }); + }); +}); + +describe('ApiKeyService.listApiKeys', () => { + it('excludes revoked keys by default', async () => { + mockApiKeyRepo.find.mockResolvedValue([]); + const service = new ApiKeyService(); + + await service.listApiKeys(USER_ID); + + expect(mockApiKeyRepo.find).toHaveBeenCalledWith( + expect.objectContaining({ + where: expect.objectContaining({ userId: USER_ID }), + }), + ); + const call = mockApiKeyRepo.find.mock.calls[0][0]; + expect(call.where).toHaveProperty('revokedAt'); + }); + + it('includes revoked keys when explicitly requested', async () => { + mockApiKeyRepo.find.mockResolvedValue([]); + const service = new ApiKeyService(); + + await service.listApiKeys(USER_ID, true); + + const call = mockApiKeyRepo.find.mock.calls[0][0]; + expect(call.where).toEqual({ userId: USER_ID }); + }); +}); + +describe('ApiKeyService.keyHasScope', () => { + it('denies every scope for a key issued with none (fails closed)', () => { + const service = new ApiKeyService(); + expect(service.keyHasScope(makeApiKey({ scopes: [] }), ApiKeyScope.READ_ONLY)).toBe(false); + }); + + it('grants only the scopes a key was issued', () => { + const service = new ApiKeyService(); + const apiKey = makeApiKey({ scopes: [ApiKeyScope.READ_ONLY] }); + + expect(service.keyHasScope(apiKey, ApiKeyScope.READ_ONLY)).toBe(true); + expect(service.keyHasScope(apiKey, ApiKeyScope.UPLOAD)).toBe(false); + }); + + it('the admin scope grants every scope', () => { + const service = new ApiKeyService(); + const apiKey = makeApiKey({ scopes: [ApiKeyScope.ADMIN] }); + + expect(service.keyHasScope(apiKey, ApiKeyScope.UPLOAD)).toBe(true); + }); +}); From e92b2bee24bf1fdc702579c5cabff4444c35cf5d Mon Sep 17 00:00:00 2001 From: DooseWayo <“doosewayo94@gmail.com”> Date: Sun, 30 Aug 2026 18:43:49 +0100 Subject: [PATCH 3/4] feat(ai): queue slow AI generation jobs and add per-feature flags MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the provider abstraction ADR-007 calls for ("all third-party AI calls go through a thin internal service... a null/no-op provider is always available") and routes the two AI operations flagged as too-slow-for-sync — cover art and description generation — through JobQueueService instead of the request/response cycle: - src/services/ai/AiProvider.ts: vendor-neutral interface (generateCoverArt/generateDescription/draftTweet). - NoopAiProvider: the always-available default. No network call is made; outputs are deterministic templates, same spirit as the other "AI-adjacent" features in docs/AI_FEATURES.md. AI_PROVIDER selects the vendor; anything but "noop" logs a warning and falls back to it, since no real vendor is implemented yet. - AiGenerationService.requestGeneration enqueues a job via JobQueueService and returns a pending AiGenerationRecord immediately; AiJobHandlers.ts processes it on the existing job worker and only marks the record failed (and fires a webhook) once JobQueueService has exhausted retries, not on every transient attempt. - Completion is announced via the existing webhook system as ai.generation.completed (WebhookService — already implements Phase 3 of docs/WEBHOOK_IMPLEMENTATION_PLAN.md, whose status header was stale; corrected it). - POST /api/ai/songs/:songId/{cover-art,description} to request a generation, GET /api/ai/generations/:id to poll it. Replaces the single-global-AI_ENABLED-flag anti-pattern with per-feature flags (src/config/aiFeatureFlags.ts) checked at each call site — tags, descriptions, coverArt, moderationTriage, search, playlists, tweetDrafts — each independently toggleable and default-off. Only coverArt/descriptions have a wired call site today; the rest are reserved for when those features are built. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Uqn6VeNfzGEJsCDDxEJR7P --- .env.example | 13 ++ docs/AI_FEATURES.md | 25 +++ docs/WEBHOOK_IMPLEMENTATION_PLAN.md | 15 +- src/app.ts | 4 + src/config/__tests__/aiFeatureFlags.test.ts | 53 ++++++ src/config/aiFeatureFlags.ts | 52 ++++++ src/config/db.ts | 2 + src/controllers/AiController.ts | 52 ++++++ src/entities/AiGenerationRecord.ts | 47 +++++ src/index.ts | 2 + .../1754200000000-AddAiGenerationRecord.ts | 74 ++++++++ src/routes/aiRoutes.ts | 14 ++ src/services/ai/AiGenerationService.ts | 172 ++++++++++++++++++ src/services/ai/AiProvider.ts | 56 ++++++ src/services/ai/NoopAiProvider.ts | 48 +++++ .../ai/__tests__/AiGenerationService.test.ts | 148 +++++++++++++++ src/services/ai/index.ts | 34 ++++ src/types/WebhookPayloads.ts | 24 ++- src/workers/AiJobHandlers.ts | 37 ++++ 19 files changed, 868 insertions(+), 4 deletions(-) create mode 100644 src/config/__tests__/aiFeatureFlags.test.ts create mode 100644 src/config/aiFeatureFlags.ts create mode 100644 src/controllers/AiController.ts create mode 100644 src/entities/AiGenerationRecord.ts create mode 100644 src/migrations/1754200000000-AddAiGenerationRecord.ts create mode 100644 src/routes/aiRoutes.ts create mode 100644 src/services/ai/AiGenerationService.ts create mode 100644 src/services/ai/AiProvider.ts create mode 100644 src/services/ai/NoopAiProvider.ts create mode 100644 src/services/ai/__tests__/AiGenerationService.test.ts create mode 100644 src/services/ai/index.ts create mode 100644 src/workers/AiJobHandlers.ts diff --git a/.env.example b/.env.example index 8e90ad9..a274cb1 100644 --- a/.env.example +++ b/.env.example @@ -152,6 +152,19 @@ MANIFEST_CACHE_TTL=300 # Max allowed bytes for a single chunk upload (used by SongRoutes) CHUNK_UPLOAD_MAX_BYTES=8388608 +# ------------------ AI (see docs/AI_FEATURES.md, docs/adrs/007-ai-integration.md) ------------------ +# Concrete AI vendor behind the AiProvider interface. Only "noop" (default) is +# implemented today — any other value logs a warning and falls back to noop. +AI_PROVIDER=noop +# Per-feature kill switches, independent of each other (default: all false/off) +AI_FEATURE_TAGS_ENABLED=false +AI_FEATURE_DESCRIPTIONS_ENABLED=false +AI_FEATURE_COVER_ART_ENABLED=false +AI_FEATURE_MODERATION_TRIAGE_ENABLED=false +AI_FEATURE_SEARCH_ENABLED=false +AI_FEATURE_PLAYLISTS_ENABLED=false +AI_FEATURE_TWEET_DRAFTS_ENABLED=false + # ------------------ Misc ------------------ # Comma-separated list of allowed CORS origins for the API ALLOWED_ORIGINS= diff --git a/docs/AI_FEATURES.md b/docs/AI_FEATURES.md index e55677e..f6b10ee 100644 --- a/docs/AI_FEATURES.md +++ b/docs/AI_FEATURES.md @@ -43,6 +43,31 @@ and run on either the node process or the SQL database. ### 4. Recommendations / discovery - No collaborative-filtering or ML recommendation engine is present today. +### 5. Async AI-assisted generation (cover art, descriptions) +- **Where:** `src/services/ai/` (`AiProvider` interface, `NoopAiProvider`, + `AiGenerationService`), `src/workers/AiJobHandlers.ts`, + `POST /api/ai/songs/:songId/cover-art` and `/description`. +- **What it does:** these routes queue a generation job via `JobQueueService` + instead of running it inline (cover art / description generation may be + slow), and announce completion via the existing webhook system as + `ai.generation.completed` (see `docs/WEBHOOK_IMPLEMENTATION_PLAN.md`). + The provider actually called is `NoopAiProvider` — a deterministic, + rule-based template, not a live model — until a real vendor is wired up + behind the `AiProvider` interface per ADR-007. +- **Data sent:** none to third parties; the no-op provider makes no network + call. Only the song title (no audio, lyrics, or files) is used to build the + placeholder output, and only the generated output — never raw content — is + persisted, in `ai_generation_records`. +- **Feature flags:** each AI feature is gated by its own env-var flag + (`src/config/aiFeatureFlags.ts`) rather than one global `AI_ENABLED` switch, + so a misbehaving feature can be disabled independently: + `AI_FEATURE_TAGS_ENABLED`, `AI_FEATURE_DESCRIPTIONS_ENABLED`, + `AI_FEATURE_COVER_ART_ENABLED`, `AI_FEATURE_MODERATION_TRIAGE_ENABLED`, + `AI_FEATURE_SEARCH_ENABLED`, `AI_FEATURE_PLAYLISTS_ENABLED`, + `AI_FEATURE_TWEET_DRAFTS_ENABLED`. All default OFF. Only `coverArt` and + `descriptions` have a call site wired up today; the rest are reserved for + when those features are built. + --- ## What data is sent to third-party providers (non-AI) diff --git a/docs/WEBHOOK_IMPLEMENTATION_PLAN.md b/docs/WEBHOOK_IMPLEMENTATION_PLAN.md index bd565c2..2b3bc64 100644 --- a/docs/WEBHOOK_IMPLEMENTATION_PLAN.md +++ b/docs/WEBHOOK_IMPLEMENTATION_PLAN.md @@ -1,8 +1,17 @@ # Webhook & Event System Implementation Plan -## Status: NOT YET IMPLEMENTED ⚠️ - -This document outlines the planned implementation for asynchronous event delivery to frontends. The webhook/event system is currently **not implemented** - frontends must use polling as a temporary workaround. +## Status: Phase 3 (HTTP webhook delivery) implemented + +This document originally outlined the planned implementation for asynchronous +event delivery to frontends. **Phase 3 — HTTP webhook delivery with +HMAC-SHA256 signing and exponential-backoff retries — is now implemented**: +see `WebhookService` (`src/services/WebhookService.ts`), its subscription +routes (`src/routes/webhookRoutes.ts`, `POST /api/webhooks/register`), and +`src/types/WebhookPayloads.ts` for the current payload shapes (including +`ai.generation.completed`, emitted by the async AI generation jobs described +in `docs/AI_FEATURES.md`). Phases 2 (WebSocket server) and 4 (event +persistence/replay API) below remain **not implemented** — frontends without +a registered webhook endpoint still need to poll for those event types. ## Current Workaround (Polling) diff --git a/src/app.ts b/src/app.ts index 772deb7..2732f9b 100644 --- a/src/app.ts +++ b/src/app.ts @@ -25,6 +25,7 @@ import userRoutes from "./routes/userRoutes"; import commentRoutes from "./routes/commentRoutes"; import commentReactionRoutes from "./routes/commentReactionRoutes"; import subscriptionRoutes from "./routes/subscriptionRoutes"; +import aiRoutes from "./routes/aiRoutes"; // Route imports @@ -140,6 +141,9 @@ app.use("/api/comments", commentReactionRoutes); // Subscriptions: tiered plans, gifting, trial periods (Issues #413, #414, #415, #416) app.use("/api/subscriptions", subscriptionRoutes); +// AI-assisted generation (cover art, descriptions) — async, queued via JobQueueService +app.use("/api/ai", aiRoutes); + // Error handling middleware const customErrorHandler: ErrorRequestHandler = (err, req, res, _next) => { diff --git a/src/config/__tests__/aiFeatureFlags.test.ts b/src/config/__tests__/aiFeatureFlags.test.ts new file mode 100644 index 0000000..777db31 --- /dev/null +++ b/src/config/__tests__/aiFeatureFlags.test.ts @@ -0,0 +1,53 @@ +import { isAiFeatureEnabled, aiFeatureEnvVar, listAiFeatureFlags } from '../aiFeatureFlags'; + +const ALL_ENV_VARS = [ + 'AI_FEATURE_TAGS_ENABLED', + 'AI_FEATURE_DESCRIPTIONS_ENABLED', + 'AI_FEATURE_COVER_ART_ENABLED', + 'AI_FEATURE_MODERATION_TRIAGE_ENABLED', + 'AI_FEATURE_SEARCH_ENABLED', + 'AI_FEATURE_PLAYLISTS_ENABLED', + 'AI_FEATURE_TWEET_DRAFTS_ENABLED', +]; + +describe('aiFeatureFlags', () => { + const originalEnv = { ...process.env }; + + afterEach(() => { + for (const key of ALL_ENV_VARS) delete process.env[key]; + Object.assign(process.env, originalEnv); + }); + + it('defaults every feature to disabled', () => { + for (const key of ALL_ENV_VARS) delete process.env[key]; + + expect(listAiFeatureFlags()).toEqual({ + tags: false, + descriptions: false, + coverArt: false, + moderationTriage: false, + search: false, + playlists: false, + tweetDrafts: false, + }); + }); + + it('enables only the feature whose flag is set to "true"', () => { + process.env.AI_FEATURE_COVER_ART_ENABLED = 'true'; + + expect(isAiFeatureEnabled('coverArt')).toBe(true); + expect(isAiFeatureEnabled('descriptions')).toBe(false); + }); + + it('one feature failing closed does not disable the others', () => { + process.env.AI_FEATURE_DESCRIPTIONS_ENABLED = 'true'; + process.env.AI_FEATURE_TAGS_ENABLED = 'garbage'; + + expect(isAiFeatureEnabled('descriptions')).toBe(true); + expect(isAiFeatureEnabled('tags')).toBe(false); + }); + + it('exposes the backing env var name for each feature', () => { + expect(aiFeatureEnvVar('tweetDrafts')).toBe('AI_FEATURE_TWEET_DRAFTS_ENABLED'); + }); +}); diff --git a/src/config/aiFeatureFlags.ts b/src/config/aiFeatureFlags.ts new file mode 100644 index 0000000..5d7762c --- /dev/null +++ b/src/config/aiFeatureFlags.ts @@ -0,0 +1,52 @@ +/** + * Per-feature AI kill switches (ADR-007: docs/adrs/007-ai-integration.md). + * + * A single global `AI_ENABLED` flag is too coarse: if one AI feature + * misbehaves (bad output, runaway cost, a provider incident) there is no way + * to disable it without also disabling every other AI feature. Each AI call + * site instead checks its own flag here. + * + * All flags default OFF (fail closed) — a feature must be explicitly enabled + * via its env var before it calls into the AI provider abstraction + * (`src/services/ai`). + */ + +export type AiFeature = + | 'tags' + | 'descriptions' + | 'coverArt' + | 'moderationTriage' + | 'search' + | 'playlists' + | 'tweetDrafts'; + +const FEATURE_ENV_VAR: Record = { + tags: 'AI_FEATURE_TAGS_ENABLED', + descriptions: 'AI_FEATURE_DESCRIPTIONS_ENABLED', + coverArt: 'AI_FEATURE_COVER_ART_ENABLED', + moderationTriage: 'AI_FEATURE_MODERATION_TRIAGE_ENABLED', + search: 'AI_FEATURE_SEARCH_ENABLED', + playlists: 'AI_FEATURE_PLAYLISTS_ENABLED', + tweetDrafts: 'AI_FEATURE_TWEET_DRAFTS_ENABLED', +}; + +/** Whether `feature` is enabled. Checked at each AI call site. */ +export function isAiFeatureEnabled(feature: AiFeature): boolean { + return (process.env[FEATURE_ENV_VAR[feature]] || '').toLowerCase() === 'true'; +} + +/** The env var name backing `feature`'s flag, for error messages/docs. */ +export function aiFeatureEnvVar(feature: AiFeature): string { + return FEATURE_ENV_VAR[feature]; +} + +/** Current on/off state of every AI feature flag, e.g. for an admin/status endpoint. */ +export function listAiFeatureFlags(): Record { + return (Object.keys(FEATURE_ENV_VAR) as AiFeature[]).reduce( + (acc, feature) => { + acc[feature] = isAiFeatureEnabled(feature); + return acc; + }, + {} as Record, + ); +} diff --git a/src/config/db.ts b/src/config/db.ts index 2b37e7e..a73512a 100644 --- a/src/config/db.ts +++ b/src/config/db.ts @@ -10,6 +10,7 @@ import { RoyaltyPayout } from '../entities/RoyaltyPayout'; import { WebhookSubscription } from '../entities/WebhookSubscription'; import { TakedownRequest } from '../entities/TakedownRequest'; import { ApiKey } from '../entities/ApiKey'; +import { AiGenerationRecord } from '../entities/AiGenerationRecord'; dotenv.config(); @@ -34,6 +35,7 @@ const AppDataSource = new DataSource({ WebhookSubscription, TakedownRequest, ApiKey, + AiGenerationRecord, ], migrations: [__dirname + '/../migrations/*.{js,ts}'], migrationsTableName: 'migrations', diff --git a/src/controllers/AiController.ts b/src/controllers/AiController.ts new file mode 100644 index 0000000..e7a8403 --- /dev/null +++ b/src/controllers/AiController.ts @@ -0,0 +1,52 @@ +import { Request, Response } from 'express'; +import { AiGenerationService } from '../services/ai/AiGenerationService'; +import { handleError } from '../utils/helpers'; +import { HTTP_STATUS } from '../config/constants'; +import { routeParam } from '../utils/routeParams'; + +const aiGenerationService = new AiGenerationService(); + +/** + * AI-assisted generation endpoints (cover art, descriptions). Slow AI + * operations are queued via JobQueueService rather than run synchronously — + * these routes only enqueue the job and return its pending record; the + * result arrives via the `ai.generation.completed` webhook event or by + * polling GET /api/ai/generations/:id. + */ +export class AiController { + requestCoverArt = async (req: Request, res: Response): Promise => { + try { + const userId = (req as any).user.id as string; + const songId = routeParam(req.params.songId); + + const record = await aiGenerationService.requestGeneration('coverArt', songId, userId); + res.status(HTTP_STATUS.CREATED).json({ success: true, data: record }); + } catch (error) { + handleError(req, res, error); + } + }; + + requestDescription = async (req: Request, res: Response): Promise => { + try { + const userId = (req as any).user.id as string; + const songId = routeParam(req.params.songId); + + const record = await aiGenerationService.requestGeneration('descriptions', songId, userId); + res.status(HTTP_STATUS.CREATED).json({ success: true, data: record }); + } catch (error) { + handleError(req, res, error); + } + }; + + getGeneration = async (req: Request, res: Response): Promise => { + try { + const userId = (req as any).user.id as string; + const recordId = routeParam(req.params.id); + + const record = await aiGenerationService.getRecord(recordId, userId); + res.status(HTTP_STATUS.OK).json({ success: true, data: record }); + } catch (error) { + handleError(req, res, error); + } + }; +} diff --git a/src/entities/AiGenerationRecord.ts b/src/entities/AiGenerationRecord.ts new file mode 100644 index 0000000..228142c --- /dev/null +++ b/src/entities/AiGenerationRecord.ts @@ -0,0 +1,47 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm'; + +/** + * A minimal record of one AI generation request (ADR-007, point 3: "each AI + * feature records, in its own table, a minimal event: the provider used, + * what was sent (kind/scope descriptor, not the raw content), the date"). + * + * This does NOT store raw song audio, lyrics, or files — only the generated + * output (a description string or a result image URI) and enough metadata + * to track and notify completion of the async job that produced it. + */ +@Entity('ai_generation_records') +export class AiGenerationRecord { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column() + songId!: string; + + @Column() + userId!: string; + + /** Which AI feature produced this record — matches `AiFeature` in aiFeatureFlags.ts. */ + @Column() + feature!: 'coverArt' | 'descriptions'; + + @Column({ default: 'pending' }) + status!: 'pending' | 'completed' | 'failed'; + + @Column({ nullable: true }) + provider?: string; + + @Column({ type: 'text', nullable: true }) + resultText?: string; + + @Column({ nullable: true }) + resultUrl?: string; + + @Column({ type: 'text', nullable: true }) + errorMessage?: string; + + @CreateDateColumn() + createdAt!: Date; + + @Column({ type: 'timestamp', nullable: true }) + completedAt?: Date; +} diff --git a/src/index.ts b/src/index.ts index 3dc68e3..27fd62e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,7 @@ import { validateSorobanConfig } from './config/soroban'; import { validateEnvironment } from './config/env'; import { startDbPoolMonitor } from './services/DbPoolMonitor'; import { startJobQueueWorker, startJobQueueMonitor } from './workers/JobQueueWorker'; +import { registerAiJobHandlers } from './workers/AiJobHandlers'; import logger from './config/logger'; import { startConnectionStateLogger } from './services/DatabaseConnectionManager'; import { @@ -67,6 +68,7 @@ async function main() { process.exit(1); }); + registerAiJobHandlers(); startJobQueueWorker(); startJobQueueMonitor(); diff --git a/src/migrations/1754200000000-AddAiGenerationRecord.ts b/src/migrations/1754200000000-AddAiGenerationRecord.ts new file mode 100644 index 0000000..ae4ed94 --- /dev/null +++ b/src/migrations/1754200000000-AddAiGenerationRecord.ts @@ -0,0 +1,74 @@ +import { MigrationInterface, QueryRunner, Table, TableForeignKey, TableIndex } from 'typeorm'; + +/** + * Creates the ai_generation_records table: a minimal audit trail for async + * AI-assisted generation jobs (cover art / description), per ADR-007's data + * retention requirement. See src/entities/AiGenerationRecord.ts. + */ +export class AddAiGenerationRecord1754200000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + name: 'ai_generation_records', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + generationStrategy: 'uuid', + default: 'uuid_generate_v4()', + }, + { name: 'songId', type: 'uuid' }, + { name: 'userId', type: 'uuid' }, + { name: 'feature', type: 'varchar' }, + { name: 'status', type: 'varchar', default: "'pending'" }, + { name: 'provider', type: 'varchar', isNullable: true }, + { name: 'resultText', type: 'text', isNullable: true }, + { name: 'resultUrl', type: 'varchar', isNullable: true }, + { name: 'errorMessage', type: 'text', isNullable: true }, + { name: 'createdAt', type: 'timestamp', default: 'CURRENT_TIMESTAMP' }, + { name: 'completedAt', type: 'timestamp', isNullable: true }, + ], + }), + true, + ); + + await queryRunner.createForeignKey( + 'ai_generation_records', + new TableForeignKey({ + columnNames: ['songId'], + referencedColumnNames: ['id'], + referencedTableName: 'songs', + onDelete: 'CASCADE', + }), + ); + + await queryRunner.createForeignKey( + 'ai_generation_records', + new TableForeignKey({ + columnNames: ['userId'], + referencedColumnNames: ['id'], + referencedTableName: 'users', + onDelete: 'CASCADE', + }), + ); + + await queryRunner.createIndex( + 'ai_generation_records', + new TableIndex({ name: 'IDX_ai_generation_records_songId', columnNames: ['songId'] }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropIndex('ai_generation_records', 'IDX_ai_generation_records_songId'); + + const table = await queryRunner.getTable('ai_generation_records'); + if (table) { + for (const fk of table.foreignKeys) { + await queryRunner.dropForeignKey('ai_generation_records', fk); + } + } + + await queryRunner.dropTable('ai_generation_records'); + } +} diff --git a/src/routes/aiRoutes.ts b/src/routes/aiRoutes.ts new file mode 100644 index 0000000..210894c --- /dev/null +++ b/src/routes/aiRoutes.ts @@ -0,0 +1,14 @@ +import { Router } from 'express'; +import { AiController } from '../controllers/AiController'; +import { authArtistMiddleware } from '../middlewares/authMiddleware'; + +const router = Router(); +const aiController = new AiController(); + +router.use(authArtistMiddleware); + +router.post('/songs/:songId/cover-art', aiController.requestCoverArt); +router.post('/songs/:songId/description', aiController.requestDescription); +router.get('/generations/:id', aiController.getGeneration); + +export default router; diff --git a/src/services/ai/AiGenerationService.ts b/src/services/ai/AiGenerationService.ts new file mode 100644 index 0000000..6f9f6fd --- /dev/null +++ b/src/services/ai/AiGenerationService.ts @@ -0,0 +1,172 @@ +import { Repository } from 'typeorm'; +import AppDataSource from '../../config/db'; +import { AiGenerationRecord } from '../../entities/AiGenerationRecord'; +import { Song } from '../../entities/Song'; +import { AppError } from '../../errors/AppError'; +import { isAiFeatureEnabled } from '../../config/aiFeatureFlags'; +import { JobQueueService } from '../JobQueueService'; +import { getAiProvider } from './index'; +import logger from '../../config/logger'; + +export type AiGenerationFeature = 'coverArt' | 'descriptions'; + +/** JobQueueService job `type` used for each feature — handlers register against these. */ +export const AI_JOB_TYPE: Record = { + coverArt: 'ai.generate_cover_art', + descriptions: 'ai.generate_description', +}; + +export interface AiGenerationJobPayload { + recordId: string; +} + +/** + * Routes slow AI operations (cover art, long descriptions — issue: "may be + * too slow for a synchronous request/response cycle") through + * JobQueueService instead of the request/response cycle, and announces + * completion over the existing webhook system (see WebhookService / + * docs/WEBHOOK_IMPLEMENTATION_PLAN.md) as `ai.generation.completed`. + */ +export class AiGenerationService { + private recordRepo: Repository; + private songRepo: Repository; + + constructor() { + this.recordRepo = AppDataSource.getRepository(AiGenerationRecord); + this.songRepo = AppDataSource.getRepository(Song); + } + + /** + * Creates a pending generation record and enqueues the async job. Returns + * immediately — the caller polls `getRecord` or subscribes to the + * `ai.generation.completed` webhook event for the result. + */ + async requestGeneration( + feature: AiGenerationFeature, + songId: string, + userId: string, + ): Promise { + if (!isAiFeatureEnabled(feature)) { + throw AppError.businessLogic( + `The "${feature}" AI feature is not enabled`, + undefined, + 'AI_FEATURE_DISABLED', + ); + } + + const song = await this.songRepo.findOneBy({ id: songId }); + if (!song) { + throw AppError.notFound('Song not found', undefined, 'SONG_NOT_FOUND'); + } + if (song.artistId !== userId) { + throw AppError.authorization( + 'You can only request AI generation for your own songs', + undefined, + 'NOT_SONG_OWNER', + ); + } + + const record = this.recordRepo.create({ + songId, + userId, + feature, + status: 'pending', + }); + const saved = await this.recordRepo.save(record); + + const payload: AiGenerationJobPayload = { recordId: saved.id }; + await JobQueueService.enqueue(AI_JOB_TYPE[feature], payload, { priority: 'low' }); + + return saved; + } + + /** Fetches a generation record, scoped to its owner. */ + async getRecord(recordId: string, userId: string): Promise { + const record = await this.recordRepo.findOneBy({ id: recordId }); + if (!record || record.userId !== userId) { + throw AppError.notFound('AI generation record not found'); + } + return record; + } + + /** + * Runs the provider call for a queued job and persists the result. Throws + * on failure so the JobQueueService worker retries with backoff — callers + * should only call {@link markFailed} once retries are exhausted. + */ + async generate(feature: AiGenerationFeature, recordId: string): Promise { + const record = await this.recordRepo.findOneBy({ id: recordId }); + if (!record) { + logger.warn({ recordId, feature }, 'AI generation record no longer exists; skipping job'); + return; + } + + const song = await this.songRepo.findOneBy({ id: record.songId }); + if (!song) { + throw new Error(`Song ${record.songId} no longer exists`); + } + + const provider = getAiProvider(); + + if (feature === 'coverArt') { + const result = await provider.generateCoverArt({ songId: song.id, title: song.title }); + await this.markCompleted(record, provider.name, { resultUrl: result.imageUrl }); + } else { + const result = await provider.generateDescription({ + songId: song.id, + title: song.title, + }); + await this.markCompleted(record, provider.name, { resultText: result.description }); + } + } + + /** Marks a job permanently failed (retries exhausted) and notifies via webhook. */ + async markFailed(recordId: string, errorMessage: string): Promise { + const record = await this.recordRepo.findOneBy({ id: recordId }); + if (!record) return; + + record.status = 'failed'; + record.errorMessage = errorMessage; + record.completedAt = new Date(); + await this.recordRepo.save(record); + + await this.emitWebhook(record); + } + + private async markCompleted( + record: AiGenerationRecord, + provider: string, + result: { resultText?: string; resultUrl?: string }, + ): Promise { + record.status = 'completed'; + record.provider = provider; + record.resultText = result.resultText; + record.resultUrl = result.resultUrl; + record.completedAt = new Date(); + await this.recordRepo.save(record); + + await this.emitWebhook(record); + } + + private async emitWebhook(record: AiGenerationRecord): Promise { + try { + const { WebhookService } = await import('../WebhookService'); + const webhook = new WebhookService(); + await webhook.publish('ai.generation.completed', { + recordId: record.id, + songId: record.songId, + userId: record.userId, + feature: record.feature, + status: record.status, + resultText: record.resultText, + resultUrl: record.resultUrl, + errorMessage: record.errorMessage, + }); + } catch (webhookErr) { + logger.error( + { recordId: record.id, err: webhookErr }, + 'Failed to publish AI generation webhook event', + ); + } + } +} diff --git a/src/services/ai/AiProvider.ts b/src/services/ai/AiProvider.ts new file mode 100644 index 0000000..1eefe68 --- /dev/null +++ b/src/services/ai/AiProvider.ts @@ -0,0 +1,56 @@ +/** + * Provider-neutral AI domain interface (ADR-007: docs/adrs/007-ai-integration.md). + * + * Business logic depends only on this interface, never on a vendor SDK + * directly — swapping providers means implementing this interface, not + * touching call sites. See `NoopAiProvider` for the always-available + * no-vendor default. + */ + +export interface CoverArtGenerationInput { + songId: string; + title: string; + genre?: string; + mood?: string; +} + +export interface CoverArtGenerationResult { + /** URI of the generated artwork. Provider-specific; may be a placeholder. */ + imageUrl: string; + provider: string; +} + +export interface DescriptionGenerationInput { + songId: string; + title: string; + artistName?: string; + genre?: string; +} + +export interface DescriptionGenerationResult { + description: string; + provider: string; +} + +export interface TweetDraftInput { + songId?: string; + title: string; + artistName: string; + releaseUrl?: string; +} + +export interface TweetDraftResult { + text: string; + provider: string; +} + +export interface AiProvider { + /** Identifies the concrete provider in logs and stored generation records. */ + readonly name: string; + + generateCoverArt(input: CoverArtGenerationInput): Promise; + + generateDescription(input: DescriptionGenerationInput): Promise; + + draftTweet(input: TweetDraftInput): Promise; +} diff --git a/src/services/ai/NoopAiProvider.ts b/src/services/ai/NoopAiProvider.ts new file mode 100644 index 0000000..9067243 --- /dev/null +++ b/src/services/ai/NoopAiProvider.ts @@ -0,0 +1,48 @@ +import { + AiProvider, + CoverArtGenerationInput, + CoverArtGenerationResult, + DescriptionGenerationInput, + DescriptionGenerationResult, + TweetDraftInput, + TweetDraftResult, +} from './AiProvider'; + +/** + * The always-available default provider (ADR-007, point 1): "a null/no-op + * provider is always available so the platform runs fully without any AI + * dependency." No network call is made and no user content leaves the + * process — outputs are deterministic, rule-based templates, in the same + * spirit as the other "AI-adjacent" features listed in docs/AI_FEATURES.md. + * + * This is what runs until a real vendor is wired up behind `AiProvider`. + */ +export class NoopAiProvider implements AiProvider { + readonly name = 'noop'; + + async generateCoverArt(input: CoverArtGenerationInput): Promise { + return { + imageUrl: `ai://noop/cover-art/${input.songId}`, + provider: this.name, + }; + } + + async generateDescription( + input: DescriptionGenerationInput, + ): Promise { + const artist = input.artistName ? ` by ${input.artistName}` : ''; + const genre = input.genre ? ` in the ${input.genre} genre` : ''; + return { + description: `"${input.title}"${artist} is a track${genre} on AudioBlock.`, + provider: this.name, + }; + } + + async draftTweet(input: TweetDraftInput): Promise { + const url = input.releaseUrl ? ` ${input.releaseUrl}` : ''; + return { + text: `${input.artistName} just released "${input.title}"! Listen now.${url}`, + provider: this.name, + }; + } +} diff --git a/src/services/ai/__tests__/AiGenerationService.test.ts b/src/services/ai/__tests__/AiGenerationService.test.ts new file mode 100644 index 0000000..594d66b --- /dev/null +++ b/src/services/ai/__tests__/AiGenerationService.test.ts @@ -0,0 +1,148 @@ +import 'reflect-metadata'; + +jest.mock('../../../config/db', () => ({ + __esModule: true, + default: { getRepository: jest.fn() }, +})); + +jest.mock('../../JobQueueService', () => ({ + JobQueueService: { enqueue: jest.fn() }, +})); + +const mockPublish = jest.fn(); +jest.mock('../../WebhookService', () => ({ + WebhookService: jest.fn().mockImplementation(() => ({ publish: mockPublish })), +})); + +import AppDataSource from '../../../config/db'; +import { AiGenerationService } from '../AiGenerationService'; +import { AiGenerationRecord } from '../../../entities/AiGenerationRecord'; +import { Song } from '../../../entities/Song'; +import { JobQueueService } from '../../JobQueueService'; +import { getAiProvider, resetAiProviderForTests } from '..'; + +const SONG_ID = 'song-1'; +const USER_ID = 'user-1'; +const OTHER_USER_ID = 'user-2'; +const RECORD_ID = 'record-1'; + +const mockRecordRepo = { + create: jest.fn((entity) => entity), + save: jest.fn((entity) => Promise.resolve({ id: RECORD_ID, ...entity })), + findOneBy: jest.fn(), +}; + +const mockSongRepo = { + findOneBy: jest.fn(), +}; + +beforeEach(() => { + jest.clearAllMocks(); + resetAiProviderForTests(); + delete process.env.AI_FEATURE_COVER_ART_ENABLED; + delete process.env.AI_FEATURE_DESCRIPTIONS_ENABLED; + + (AppDataSource.getRepository as jest.Mock).mockImplementation((entity) => { + if (entity === AiGenerationRecord) return mockRecordRepo; + if (entity === Song) return mockSongRepo; + return {}; + }); +}); + +describe('AiGenerationService.requestGeneration', () => { + it('rejects when the feature flag is disabled', async () => { + const service = new AiGenerationService(); + + await expect(service.requestGeneration('coverArt', SONG_ID, USER_ID)).rejects.toMatchObject({ + statusCode: 400, + code: 'AI_FEATURE_DISABLED', + }); + expect(JobQueueService.enqueue).not.toHaveBeenCalled(); + }); + + it('rejects for a song the caller does not own', async () => { + process.env.AI_FEATURE_COVER_ART_ENABLED = 'true'; + mockSongRepo.findOneBy.mockResolvedValue({ id: SONG_ID, artistId: OTHER_USER_ID, title: 'X' }); + const service = new AiGenerationService(); + + await expect(service.requestGeneration('coverArt', SONG_ID, USER_ID)).rejects.toMatchObject({ + statusCode: 403, + }); + }); + + it('creates a pending record and routes the job through JobQueueService', async () => { + process.env.AI_FEATURE_DESCRIPTIONS_ENABLED = 'true'; + mockSongRepo.findOneBy.mockResolvedValue({ id: SONG_ID, artistId: USER_ID, title: 'X' }); + const service = new AiGenerationService(); + + const record = await service.requestGeneration('descriptions', SONG_ID, USER_ID); + + expect(record.status).toBe('pending'); + expect(JobQueueService.enqueue).toHaveBeenCalledWith( + 'ai.generate_description', + { recordId: RECORD_ID }, + { priority: 'low' }, + ); + }); +}); + +describe('AiGenerationService.generate', () => { + it('runs the configured provider and marks the record completed, then publishes a webhook', async () => { + mockRecordRepo.findOneBy.mockResolvedValue({ + id: RECORD_ID, + songId: SONG_ID, + userId: USER_ID, + feature: 'coverArt', + status: 'pending', + }); + mockSongRepo.findOneBy.mockResolvedValue({ id: SONG_ID, artistId: USER_ID, title: 'My Song' }); + const service = new AiGenerationService(); + + await service.generate('coverArt', RECORD_ID); + + expect(mockRecordRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ status: 'completed', provider: getAiProvider().name }), + ); + expect(mockPublish).toHaveBeenCalledWith( + 'ai.generation.completed', + expect.objectContaining({ recordId: RECORD_ID, status: 'completed', feature: 'coverArt' }), + ); + }); + + it('throws when the song backing the record has been deleted', async () => { + mockRecordRepo.findOneBy.mockResolvedValue({ + id: RECORD_ID, + songId: SONG_ID, + userId: USER_ID, + feature: 'descriptions', + }); + mockSongRepo.findOneBy.mockResolvedValue(null); + const service = new AiGenerationService(); + + await expect(service.generate('descriptions', RECORD_ID)).rejects.toThrow(); + expect(mockRecordRepo.save).not.toHaveBeenCalled(); + }); +}); + +describe('AiGenerationService.markFailed', () => { + it('marks the record failed and publishes a webhook event', async () => { + mockRecordRepo.findOneBy.mockResolvedValue({ + id: RECORD_ID, + songId: SONG_ID, + userId: USER_ID, + feature: 'coverArt', + status: 'pending', + }); + const service = new AiGenerationService(); + + await service.markFailed(RECORD_ID, 'provider unavailable'); + + expect(mockRecordRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ status: 'failed', errorMessage: 'provider unavailable' }), + ); + expect(mockPublish).toHaveBeenCalledWith( + 'ai.generation.completed', + expect.objectContaining({ status: 'failed', errorMessage: 'provider unavailable' }), + ); + }); +}); diff --git a/src/services/ai/index.ts b/src/services/ai/index.ts new file mode 100644 index 0000000..75edfee --- /dev/null +++ b/src/services/ai/index.ts @@ -0,0 +1,34 @@ +import logger from '../../config/logger'; +import { AiProvider } from './AiProvider'; +import { NoopAiProvider } from './NoopAiProvider'; + +export * from './AiProvider'; +export { NoopAiProvider }; + +let cachedProvider: AiProvider | null = null; + +/** + * Resolves the configured AI provider (`AI_PROVIDER` env var). No real vendor + * is implemented yet (see ADR-007) — any value other than the default falls + * back to the no-op provider with a warning, so misconfiguration never takes + * the platform down. + */ +export function getAiProvider(): AiProvider { + if (cachedProvider) return cachedProvider; + + const configured = (process.env.AI_PROVIDER || 'noop').toLowerCase(); + if (configured !== 'noop') { + logger.warn( + { configured }, + 'AI_PROVIDER names a provider with no implementation yet; falling back to the no-op provider (see docs/adrs/007-ai-integration.md)', + ); + } + + cachedProvider = new NoopAiProvider(); + return cachedProvider; +} + +/** Test-only: clears the cached provider so tests can re-resolve it. */ +export function resetAiProviderForTests(): void { + cachedProvider = null; +} diff --git a/src/types/WebhookPayloads.ts b/src/types/WebhookPayloads.ts index 6487568..818631b 100644 --- a/src/types/WebhookPayloads.ts +++ b/src/types/WebhookPayloads.ts @@ -67,11 +67,33 @@ export interface SongProcessingCompletedPayload { errorMessage?: string; } +/** + * Fired when an async AI generation job (cover art / description) finishes — + * see AiGenerationService and src/workers/AiJobHandlers.ts. `status` is + * "failed" only once JobQueueService has exhausted its retries. + */ +export interface AiGenerationCompletedPayload { + eventId: string; + eventType: 'ai.generation.completed'; + timestamp: string; + recordId: string; + songId: string; + userId: string; + feature: 'coverArt' | 'descriptions'; + status: 'completed' | 'failed'; + resultText?: string; + resultUrl?: string; + errorMessage?: string; +} + /** * Union type of all possible webhook payloads for type discrimination. */ export type WebhookPayload = - MintStatusChangedPayload | ArtistSetupCompletedPayload | SongProcessingCompletedPayload; + | MintStatusChangedPayload + | ArtistSetupCompletedPayload + | SongProcessingCompletedPayload + | AiGenerationCompletedPayload; /** * Delivery method configuration for webhook payloads. diff --git a/src/workers/AiJobHandlers.ts b/src/workers/AiJobHandlers.ts new file mode 100644 index 0000000..5e4cfa2 --- /dev/null +++ b/src/workers/AiJobHandlers.ts @@ -0,0 +1,37 @@ +/** + * Registers JobQueueWorker handlers for the async AI jobs enqueued by + * AiGenerationService (cover art / description generation). A generation + * record only flips to "failed" — and only then fires the + * ai.generation.completed webhook — once JobQueueService has exhausted all + * retries; earlier attempt failures just let the queue's own backoff/retry + * do its job. + */ +import { Job } from '../services/JobQueueService'; +import { + AI_JOB_TYPE, + AiGenerationJobPayload, + AiGenerationService, +} from '../services/ai/AiGenerationService'; +import { registerJobHandler } from './JobQueueWorker'; + +export function registerAiJobHandlers(): void { + const aiGenerationService = new AiGenerationService(); + + const handle = + (feature: 'coverArt' | 'descriptions') => async (job: Job) => { + try { + await aiGenerationService.generate(feature, job.payload.recordId); + } catch (err) { + if (job.attempts >= job.maxAttempts) { + await aiGenerationService.markFailed( + job.payload.recordId, + err instanceof Error ? err.message : String(err), + ); + } + throw err; + } + }; + + registerJobHandler(AI_JOB_TYPE.coverArt, handle('coverArt')); + registerJobHandler(AI_JOB_TYPE.descriptions, handle('descriptions')); +} From 6a36130c8874d866988c48101b3cc70b2179630c Mon Sep 17 00:00:00 2001 From: DooseWayo <“doosewayo94@gmail.com”> Date: Sun, 30 Aug 2026 18:46:49 +0100 Subject: [PATCH 4/4] feat(twitter): add draft-tweet endpoints pending artist approval MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit twitterRoutes.ts supported connect/disconnect but had no drafting assistance for announcing a release. Adds: - TweetDraftService.createDraft: uses the AiProvider abstraction (draftTweet) to draft announcement text for a song (or a generic release), gated by the AI_FEATURE_TWEET_DRAFTS_ENABLED flag. - POST /api/auth/twitter/draft, GET .../drafts, POST .../draft/:id/approve, DELETE .../draft/:id. Approving a draft only marks it reviewed — it does not post to Twitter. twitterRoutes.ts's OAuth callback deliberately never persists an access/refresh token (documented there: Twitter is used for profile enrichment only), so there is no credential to post with. Wiring actual posting would mean persisting tokens and implementing the OAuth2 refresh flow, which is a separate, larger security-relevant change than "drafting assistance" calls for. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01Uqn6VeNfzGEJsCDDxEJR7P --- docs/AI_FEATURES.md | 20 ++- src/config/db.ts | 2 + src/controllers/TweetDraftController.ts | 64 +++++++++ src/entities/TweetDraft.ts | 35 +++++ src/migrations/1754300000000-AddTweetDraft.ts | 60 +++++++++ src/routes/twitterRoutes.ts | 12 ++ src/services/TweetDraftService.ts | 111 +++++++++++++++ .../__tests__/TweetDraftService.test.ts | 127 ++++++++++++++++++ 8 files changed, 428 insertions(+), 3 deletions(-) create mode 100644 src/controllers/TweetDraftController.ts create mode 100644 src/entities/TweetDraft.ts create mode 100644 src/migrations/1754300000000-AddTweetDraft.ts create mode 100644 src/services/TweetDraftService.ts create mode 100644 src/services/__tests__/TweetDraftService.test.ts diff --git a/docs/AI_FEATURES.md b/docs/AI_FEATURES.md index f6b10ee..dea100b 100644 --- a/docs/AI_FEATURES.md +++ b/docs/AI_FEATURES.md @@ -64,9 +64,23 @@ and run on either the node process or the SQL database. `AI_FEATURE_TAGS_ENABLED`, `AI_FEATURE_DESCRIPTIONS_ENABLED`, `AI_FEATURE_COVER_ART_ENABLED`, `AI_FEATURE_MODERATION_TRIAGE_ENABLED`, `AI_FEATURE_SEARCH_ENABLED`, `AI_FEATURE_PLAYLISTS_ENABLED`, - `AI_FEATURE_TWEET_DRAFTS_ENABLED`. All default OFF. Only `coverArt` and - `descriptions` have a call site wired up today; the rest are reserved for - when those features are built. + `AI_FEATURE_TWEET_DRAFTS_ENABLED`. All default OFF. `coverArt`, + `descriptions`, and `tweetDrafts` have a call site wired up today; the rest + are reserved for when those features are built. + +### 6. Release-announcement tweet drafts +- **Where:** `src/services/TweetDraftService.ts`, + `POST /api/auth/twitter/draft`, `GET /api/auth/twitter/drafts`, + `POST /api/auth/twitter/draft/:id/approve`, + `DELETE /api/auth/twitter/draft/:id`. +- **What it does:** drafts announcement text for a release via the same + `AiProvider` abstraction, stored as a `pending_review` `TweetDraft` for the + artist to review. Approving a draft only marks it reviewed — AudioBlock + does not post to Twitter on the artist's behalf, because `twitterRoutes.ts` + deliberately never persists a Twitter access/refresh token (see the + `/callback` handler there); the artist copies the approved text and posts + it themselves. +- **Data sent:** none to third parties; gated by `AI_FEATURE_TWEET_DRAFTS_ENABLED`. --- diff --git a/src/config/db.ts b/src/config/db.ts index a73512a..8333af5 100644 --- a/src/config/db.ts +++ b/src/config/db.ts @@ -11,6 +11,7 @@ import { WebhookSubscription } from '../entities/WebhookSubscription'; import { TakedownRequest } from '../entities/TakedownRequest'; import { ApiKey } from '../entities/ApiKey'; import { AiGenerationRecord } from '../entities/AiGenerationRecord'; +import { TweetDraft } from '../entities/TweetDraft'; dotenv.config(); @@ -36,6 +37,7 @@ const AppDataSource = new DataSource({ TakedownRequest, ApiKey, AiGenerationRecord, + TweetDraft, ], migrations: [__dirname + '/../migrations/*.{js,ts}'], migrationsTableName: 'migrations', diff --git a/src/controllers/TweetDraftController.ts b/src/controllers/TweetDraftController.ts new file mode 100644 index 0000000..dcdbc67 --- /dev/null +++ b/src/controllers/TweetDraftController.ts @@ -0,0 +1,64 @@ +import { Request, Response } from 'express'; +import { TweetDraftService } from '../services/TweetDraftService'; +import { handleError } from '../utils/helpers'; +import { HTTP_STATUS } from '../config/constants'; +import { routeParam } from '../utils/routeParams'; + +const tweetDraftService = new TweetDraftService(); + +/** + * Draft-tweet endpoints for twitterRoutes.ts (issue: "add drafting + * assistance... requiring explicit artist approval before posting"). + */ +export class TweetDraftController { + createDraft = async (req: Request, res: Response): Promise => { + try { + const userId = (req as any).user.id as string; + const songId = req.body?.songId as string | undefined; + + const draft = await tweetDraftService.createDraft(userId, songId); + res.status(HTTP_STATUS.CREATED).json({ success: true, data: draft }); + } catch (error) { + handleError(req, res, error); + } + }; + + listDrafts = async (req: Request, res: Response): Promise => { + try { + const userId = (req as any).user.id as string; + const drafts = await tweetDraftService.listDrafts(userId); + res.status(HTTP_STATUS.OK).json({ success: true, data: drafts }); + } catch (error) { + handleError(req, res, error); + } + }; + + approveDraft = async (req: Request, res: Response): Promise => { + try { + const userId = (req as any).user.id as string; + const draftId = routeParam(req.params.id); + + const draft = await tweetDraftService.approveDraft(userId, draftId); + res.status(HTTP_STATUS.OK).json({ + success: true, + message: + 'Draft approved. Post it from your Twitter account — AudioBlock does not post on your behalf.', + data: draft, + }); + } catch (error) { + handleError(req, res, error); + } + }; + + discardDraft = async (req: Request, res: Response): Promise => { + try { + const userId = (req as any).user.id as string; + const draftId = routeParam(req.params.id); + + await tweetDraftService.discardDraft(userId, draftId); + res.status(HTTP_STATUS.OK).json({ success: true, message: 'Draft discarded' }); + } catch (error) { + handleError(req, res, error); + } + }; +} diff --git a/src/entities/TweetDraft.ts b/src/entities/TweetDraft.ts new file mode 100644 index 0000000..543b889 --- /dev/null +++ b/src/entities/TweetDraft.ts @@ -0,0 +1,35 @@ +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm'; + +/** + * An AI-drafted tweet awaiting artist review (issue: "add drafting + * assistance to twitterRoutes.ts, requiring explicit artist approval before + * posting"). This only stores draft text for the artist to review/copy — + * see src/routes/twitterRoutes.ts for why actual posting is out of scope: + * Twitter access/refresh tokens are deliberately never persisted today. + */ +@Entity('tweet_drafts') +export class TweetDraft { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @Column() + userId!: string; + + @Column({ nullable: true }) + songId?: string; + + @Column({ type: 'text' }) + text!: string; + + @Column({ default: 'pending_review' }) + status!: 'pending_review' | 'approved'; + + @Column({ nullable: true }) + provider?: string; + + @CreateDateColumn() + createdAt!: Date; + + @Column({ type: 'timestamp', nullable: true }) + approvedAt?: Date; +} diff --git a/src/migrations/1754300000000-AddTweetDraft.ts b/src/migrations/1754300000000-AddTweetDraft.ts new file mode 100644 index 0000000..152bdd2 --- /dev/null +++ b/src/migrations/1754300000000-AddTweetDraft.ts @@ -0,0 +1,60 @@ +import { MigrationInterface, QueryRunner, Table, TableForeignKey, TableIndex } from 'typeorm'; + +/** + * Creates the tweet_drafts table backing the draft-tweet endpoints added to + * twitterRoutes.ts. See src/entities/TweetDraft.ts. + */ +export class AddTweetDraft1754300000000 implements MigrationInterface { + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + name: 'tweet_drafts', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + generationStrategy: 'uuid', + default: 'uuid_generate_v4()', + }, + { name: 'userId', type: 'uuid' }, + { name: 'songId', type: 'uuid', isNullable: true }, + { name: 'text', type: 'text' }, + { name: 'status', type: 'varchar', default: "'pending_review'" }, + { name: 'provider', type: 'varchar', isNullable: true }, + { name: 'createdAt', type: 'timestamp', default: 'CURRENT_TIMESTAMP' }, + { name: 'approvedAt', type: 'timestamp', isNullable: true }, + ], + }), + true, + ); + + await queryRunner.createForeignKey( + 'tweet_drafts', + new TableForeignKey({ + columnNames: ['userId'], + referencedColumnNames: ['id'], + referencedTableName: 'users', + onDelete: 'CASCADE', + }), + ); + + await queryRunner.createIndex( + 'tweet_drafts', + new TableIndex({ name: 'IDX_tweet_drafts_userId', columnNames: ['userId'] }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropIndex('tweet_drafts', 'IDX_tweet_drafts_userId'); + + const table = await queryRunner.getTable('tweet_drafts'); + if (table) { + for (const fk of table.foreignKeys) { + await queryRunner.dropForeignKey('tweet_drafts', fk); + } + } + + await queryRunner.dropTable('tweet_drafts'); + } +} diff --git a/src/routes/twitterRoutes.ts b/src/routes/twitterRoutes.ts index 690b653..d2882f2 100644 --- a/src/routes/twitterRoutes.ts +++ b/src/routes/twitterRoutes.ts @@ -6,8 +6,10 @@ import { User } from '../entities/User'; import { generateCodeVerifier, generateCodeChallenge } from '../utils/helpers'; import { authArtistMiddleware } from '../middlewares/authMiddleware'; import redis from '../config/redis'; +import { TweetDraftController } from '../controllers/TweetDraftController'; const router = Router(); +const tweetDraftController = new TweetDraftController(); // TTL for state in seconds (5 minutes) const STATE_TTL = 300; @@ -193,4 +195,14 @@ router.post('/disconnect', authArtistMiddleware, async (req: Request, res: Respo } }); +// ---------- draft tweet endpoints (authenticated) ---------- +// Drafts a tweet for a new release via the AI provider abstraction. Approving +// a draft is an explicit artist action; AudioBlock never posts on the +// artist's behalf, since no Twitter access token is persisted (see the +// /callback handler above). +router.post('/draft', authArtistMiddleware, tweetDraftController.createDraft); +router.get('/drafts', authArtistMiddleware, tweetDraftController.listDrafts); +router.post('/draft/:id/approve', authArtistMiddleware, tweetDraftController.approveDraft); +router.delete('/draft/:id', authArtistMiddleware, tweetDraftController.discardDraft); + export default router; diff --git a/src/services/TweetDraftService.ts b/src/services/TweetDraftService.ts new file mode 100644 index 0000000..5e88815 --- /dev/null +++ b/src/services/TweetDraftService.ts @@ -0,0 +1,111 @@ +import { Repository } from 'typeorm'; +import AppDataSource from '../config/db'; +import { TweetDraft } from '../entities/TweetDraft'; +import { Song } from '../entities/Song'; +import { User } from '../entities/User'; +import { AppError } from '../errors/AppError'; +import { isAiFeatureEnabled } from '../config/aiFeatureFlags'; +import { getAiProvider } from './ai'; + +function buildReleaseUrl(songId: string): string { + const baseUrl = + process.env.APP_URL || + process.env.FRONTEND_URLS?.split(',')[0] || + 'https://audioblock.example.com'; + return `${baseUrl.replace(/\/$/, '')}/song/${songId}`; +} + +/** + * Drafts a tweet for a new release using the AI provider abstraction, for + * the artist to review and approve before posting it themselves. + * + * Deliberately does NOT post to Twitter: twitterRoutes.ts never persists + * Twitter access/refresh tokens (see the OAuth callback there), so there is + * no credential to post with. Approving a draft only marks it reviewed — + * the artist copies the approved text and posts it manually. + */ +export class TweetDraftService { + private draftRepo: Repository; + private songRepo: Repository; + private userRepo: Repository; + + constructor() { + this.draftRepo = AppDataSource.getRepository(TweetDraft); + this.songRepo = AppDataSource.getRepository(Song); + this.userRepo = AppDataSource.getRepository(User); + } + + async createDraft(userId: string, songId?: string): Promise { + if (!isAiFeatureEnabled('tweetDrafts')) { + throw AppError.businessLogic( + 'The tweet-draft AI feature is not enabled', + undefined, + 'AI_FEATURE_DISABLED', + ); + } + + const user = await this.userRepo.findOne({ where: { id: userId } }); + if (!user) { + throw AppError.notFound('User not found'); + } + + let title = 'a new release'; + let releaseUrl: string | undefined; + + if (songId) { + const song = await this.songRepo.findOneBy({ id: songId }); + if (!song) { + throw AppError.notFound('Song not found', undefined, 'SONG_NOT_FOUND'); + } + if (song.artistId !== userId) { + throw AppError.authorization( + 'You can only draft a tweet for your own song', + undefined, + 'NOT_SONG_OWNER', + ); + } + title = song.title; + releaseUrl = buildReleaseUrl(song.id); + } + + const artistName = user.name || user.twitterDisplayName || user.username || 'The artist'; + const provider = getAiProvider(); + const { text } = await provider.draftTweet({ songId, title, artistName, releaseUrl }); + + const draft = this.draftRepo.create({ + userId, + songId, + text, + status: 'pending_review', + provider: provider.name, + }); + + return this.draftRepo.save(draft); + } + + async listDrafts(userId: string): Promise { + return this.draftRepo.find({ where: { userId }, order: { createdAt: 'DESC' } }); + } + + /** Marks a draft approved by the artist. Does not post it — see class docs. */ + async approveDraft(userId: string, draftId: string): Promise { + const draft = await this.getOwnedDraft(userId, draftId); + + draft.status = 'approved'; + draft.approvedAt = new Date(); + return this.draftRepo.save(draft); + } + + async discardDraft(userId: string, draftId: string): Promise { + const draft = await this.getOwnedDraft(userId, draftId); + await this.draftRepo.remove(draft); + } + + private async getOwnedDraft(userId: string, draftId: string): Promise { + const draft = await this.draftRepo.findOneBy({ id: draftId }); + if (!draft || draft.userId !== userId) { + throw AppError.notFound('Tweet draft not found'); + } + return draft; + } +} diff --git a/src/services/__tests__/TweetDraftService.test.ts b/src/services/__tests__/TweetDraftService.test.ts new file mode 100644 index 0000000..65ace3d --- /dev/null +++ b/src/services/__tests__/TweetDraftService.test.ts @@ -0,0 +1,127 @@ +import 'reflect-metadata'; + +jest.mock('../../config/db', () => ({ + __esModule: true, + default: { getRepository: jest.fn() }, +})); + +import AppDataSource from '../../config/db'; +import { TweetDraftService } from '../TweetDraftService'; +import { TweetDraft } from '../../entities/TweetDraft'; +import { Song } from '../../entities/Song'; +import { User } from '../../entities/User'; +import { resetAiProviderForTests } from '../ai'; + +const USER_ID = 'user-1'; +const OTHER_USER_ID = 'user-2'; +const SONG_ID = 'song-1'; +const DRAFT_ID = 'draft-1'; + +const mockDraftRepo = { + create: jest.fn((entity) => entity), + save: jest.fn((entity) => Promise.resolve({ id: DRAFT_ID, ...entity })), + find: jest.fn(), + findOneBy: jest.fn(), + remove: jest.fn(), +}; + +const mockSongRepo = { findOneBy: jest.fn() }; +const mockUserRepo = { findOne: jest.fn() }; + +beforeEach(() => { + jest.clearAllMocks(); + resetAiProviderForTests(); + delete process.env.AI_FEATURE_TWEET_DRAFTS_ENABLED; + + (AppDataSource.getRepository as jest.Mock).mockImplementation((entity) => { + if (entity === TweetDraft) return mockDraftRepo; + if (entity === Song) return mockSongRepo; + if (entity === User) return mockUserRepo; + return {}; + }); +}); + +describe('TweetDraftService.createDraft', () => { + it('rejects when the tweetDrafts feature flag is disabled', async () => { + const service = new TweetDraftService(); + + await expect(service.createDraft(USER_ID, SONG_ID)).rejects.toMatchObject({ + statusCode: 400, + code: 'AI_FEATURE_DISABLED', + }); + }); + + it('rejects drafting for a song the caller does not own', async () => { + process.env.AI_FEATURE_TWEET_DRAFTS_ENABLED = 'true'; + mockUserRepo.findOne.mockResolvedValue({ id: USER_ID, name: 'Artist' }); + mockSongRepo.findOneBy.mockResolvedValue({ id: SONG_ID, artistId: OTHER_USER_ID, title: 'X' }); + const service = new TweetDraftService(); + + await expect(service.createDraft(USER_ID, SONG_ID)).rejects.toMatchObject({ + statusCode: 403, + }); + }); + + it('creates a pending_review draft referencing the song title', async () => { + process.env.AI_FEATURE_TWEET_DRAFTS_ENABLED = 'true'; + mockUserRepo.findOne.mockResolvedValue({ id: USER_ID, name: 'Cool Artist' }); + mockSongRepo.findOneBy.mockResolvedValue({ + id: SONG_ID, + artistId: USER_ID, + title: 'New Track', + }); + const service = new TweetDraftService(); + + const draft = await service.createDraft(USER_ID, SONG_ID); + + expect(draft.status).toBe('pending_review'); + expect(draft.text).toContain('New Track'); + expect(draft.text).toContain('Cool Artist'); + }); + + it('drafts without a specific song when none is given', async () => { + process.env.AI_FEATURE_TWEET_DRAFTS_ENABLED = 'true'; + mockUserRepo.findOne.mockResolvedValue({ id: USER_ID, name: 'Cool Artist' }); + const service = new TweetDraftService(); + + const draft = await service.createDraft(USER_ID); + + expect(mockSongRepo.findOneBy).not.toHaveBeenCalled(); + expect(draft.status).toBe('pending_review'); + }); +}); + +describe('TweetDraftService.approveDraft / discardDraft', () => { + it('approves a draft the caller owns', async () => { + mockDraftRepo.findOneBy.mockResolvedValue({ + id: DRAFT_ID, + userId: USER_ID, + status: 'pending_review', + }); + const service = new TweetDraftService(); + + const approved = await service.approveDraft(USER_ID, DRAFT_ID); + + expect(approved.status).toBe('approved'); + expect(approved.approvedAt).toBeInstanceOf(Date); + }); + + it('refuses to approve a draft owned by a different user', async () => { + mockDraftRepo.findOneBy.mockResolvedValue({ id: DRAFT_ID, userId: OTHER_USER_ID }); + const service = new TweetDraftService(); + + await expect(service.approveDraft(USER_ID, DRAFT_ID)).rejects.toMatchObject({ + statusCode: 404, + }); + }); + + it('discards a draft the caller owns', async () => { + const draft = { id: DRAFT_ID, userId: USER_ID }; + mockDraftRepo.findOneBy.mockResolvedValue(draft); + const service = new TweetDraftService(); + + await service.discardDraft(USER_ID, DRAFT_ID); + + expect(mockDraftRepo.remove).toHaveBeenCalledWith(draft); + }); +});