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..dea100b 100644 --- a/docs/AI_FEATURES.md +++ b/docs/AI_FEATURES.md @@ -43,6 +43,45 @@ 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. `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`. + --- ## 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/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": { 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/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 170c7d9..8333af5 100644 --- a/src/config/db.ts +++ b/src/config/db.ts @@ -9,6 +9,9 @@ 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'; +import { AiGenerationRecord } from '../entities/AiGenerationRecord'; +import { TweetDraft } from '../entities/TweetDraft'; dotenv.config(); @@ -32,6 +35,9 @@ const AppDataSource = new DataSource({ RoyaltyPayout, WebhookSubscription, TakedownRequest, + ApiKey, + AiGenerationRecord, + TweetDraft, ], 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/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/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/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/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/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/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/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/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/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/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/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/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/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__/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); + }); +}); 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); + }); +}); 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')); +}