See the best posts from any Bluesky account
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add like/repost API routes with engagement controller

Create POST/DELETE endpoints for /api/like and /api/repost behind auth
middleware. The controller uses DI for AtprotoOAuthService and validates
inputs with VineJS. Upstream Bluesky errors return 502.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+178
+1
.adonisjs/server/controllers.ts
··· 4 4 */ 5 5 6 6 export const controllers = { 7 + Engagement: () => import('#controllers/engagement_controller'), 7 8 HealthChecks: () => import('#controllers/health_checks_controller'), 8 9 Landing: () => import('#controllers/landing_controller'), 9 10 Oauth: () => import('#controllers/oauth_controller'),
+74
app/controllers/engagement_controller.ts
··· 1 + import { inject } from '@adonisjs/core' 2 + import type { HttpContext } from '@adonisjs/core/http' 3 + import vine from '@vinejs/vine' 4 + import AtprotoOAuthService from '#services/atproto_oauth' 5 + 6 + const createValidator = vine.compile( 7 + vine.object({ 8 + uri: vine.string().trim().minLength(1), 9 + cid: vine.string().trim().minLength(1), 10 + }) 11 + ) 12 + 13 + const deleteValidator = vine.compile( 14 + vine.object({ 15 + uri: vine.string().trim().minLength(1), 16 + }) 17 + ) 18 + 19 + @inject() 20 + export default class EngagementController { 21 + constructor(private readonly oauthService: AtprotoOAuthService) {} 22 + 23 + async like({ request, auth, response }: HttpContext) { 24 + const { uri, cid } = await request.validateUsing(createValidator) 25 + const did = auth.use('web').user!.did 26 + 27 + try { 28 + const agent = await this.oauthService.getAgent(did) 29 + const result = await agent.like(uri, cid) 30 + return response.json({ uri: result.uri }) 31 + } catch (error) { 32 + return response.status(502).json({ message: 'Failed to create like on Bluesky' }) 33 + } 34 + } 35 + 36 + async deleteLike({ request, auth, response }: HttpContext) { 37 + const { uri } = await request.validateUsing(deleteValidator) 38 + const did = auth.use('web').user!.did 39 + 40 + try { 41 + const agent = await this.oauthService.getAgent(did) 42 + await agent.deleteLike(uri) 43 + return response.json({}) 44 + } catch (error) { 45 + return response.status(502).json({ message: 'Failed to delete like on Bluesky' }) 46 + } 47 + } 48 + 49 + async repost({ request, auth, response }: HttpContext) { 50 + const { uri, cid } = await request.validateUsing(createValidator) 51 + const did = auth.use('web').user!.did 52 + 53 + try { 54 + const agent = await this.oauthService.getAgent(did) 55 + const result = await agent.repost(uri, cid) 56 + return response.json({ uri: result.uri }) 57 + } catch (error) { 58 + return response.status(502).json({ message: 'Failed to create repost on Bluesky' }) 59 + } 60 + } 61 + 62 + async deleteRepost({ request, auth, response }: HttpContext) { 63 + const { uri } = await request.validateUsing(deleteValidator) 64 + const did = auth.use('web').user!.did 65 + 66 + try { 67 + const agent = await this.oauthService.getAgent(did) 68 + await agent.deleteRepost(uri) 69 + return response.json({}) 70 + } catch (error) { 71 + return response.status(502).json({ message: 'Failed to delete repost on Bluesky' }) 72 + } 73 + } 74 + }
+15
start/routes.ts
··· 15 15 const TypeaheadController = () => import('#controllers/typeahead_controller') 16 16 const ProfileController = () => import('#controllers/profile_controller') 17 17 const OAuthController = () => import('#controllers/oauth_controller') 18 + const EngagementController = () => import('#controllers/engagement_controller') 18 19 const HealthChecksController = () => import('#controllers/health_checks_controller') 19 20 20 21 // --------------------------------------------------------------------------- ··· 76 77 .post('/oauth/logout', [OAuthController, 'logout']) 77 78 .use(middleware.auth()) 78 79 .as('oauth.logout') 80 + 81 + // --------------------------------------------------------------------------- 82 + // Engagement API (like / repost) 83 + // --------------------------------------------------------------------------- 84 + 85 + router 86 + .group(() => { 87 + router.post('/like', [EngagementController, 'like']).as('api.like.create') 88 + router.delete('/like', [EngagementController, 'deleteLike']).as('api.like.delete') 89 + router.post('/repost', [EngagementController, 'repost']).as('api.repost.create') 90 + router.delete('/repost', [EngagementController, 'deleteRepost']).as('api.repost.delete') 91 + }) 92 + .prefix('/api') 93 + .use(middleware.auth()) 79 94 80 95 // --------------------------------------------------------------------------- 81 96 // Health checks
+88
tests/functional/engagement.spec.ts
··· 1 + /** 2 + * Functional tests for engagement API routes (like/repost). 3 + * 4 + * We cannot test actual Bluesky API calls, so we focus on: 5 + * - Auth guard (401 for unauthenticated requests) 6 + * - Input validation (422 for missing/invalid body) 7 + */ 8 + import { test } from '@japa/runner' 9 + import testUtils from '@adonisjs/core/services/test_utils' 10 + import Account from '#models/account' 11 + 12 + test.group('Engagement API | auth guard', () => { 13 + test('POST /api/like without auth returns 401', async ({ client }) => { 14 + const response = await client 15 + .post('/api/like') 16 + .withCsrfToken() 17 + .header('Accept', 'application/json') 18 + .json({ uri: 'at://did:plc:abc/app.bsky.feed.post/123', cid: 'bafyreiabc123' }) 19 + response.assertStatus(401) 20 + }) 21 + 22 + test('DELETE /api/like without auth returns 401', async ({ client }) => { 23 + const response = await client 24 + .delete('/api/like') 25 + .withCsrfToken() 26 + .header('Accept', 'application/json') 27 + .json({ uri: 'at://did:plc:abc/app.bsky.feed.like/456' }) 28 + response.assertStatus(401) 29 + }) 30 + 31 + test('POST /api/repost without auth returns 401', async ({ client }) => { 32 + const response = await client 33 + .post('/api/repost') 34 + .withCsrfToken() 35 + .header('Accept', 'application/json') 36 + .json({ uri: 'at://did:plc:abc/app.bsky.feed.post/123', cid: 'bafyreiabc123' }) 37 + response.assertStatus(401) 38 + }) 39 + 40 + test('DELETE /api/repost without auth returns 401', async ({ client }) => { 41 + const response = await client 42 + .delete('/api/repost') 43 + .withCsrfToken() 44 + .header('Accept', 'application/json') 45 + .json({ uri: 'at://did:plc:abc/app.bsky.feed.repost/789' }) 46 + response.assertStatus(401) 47 + }) 48 + }) 49 + 50 + test.group('Engagement API | validation', (group) => { 51 + group.each.setup(() => testUtils.db().withGlobalTransaction()) 52 + 53 + test('POST /api/like with missing body returns 422', async ({ client }) => { 54 + const account = await Account.create({ 55 + did: 'did:plc:engtest1', 56 + handle: 'engtest.bsky.social', 57 + sessionData: '{}', 58 + createdAt: Date.now(), 59 + updatedAt: Date.now(), 60 + }) 61 + 62 + const response = await client 63 + .post('/api/like') 64 + .loginAs(account) 65 + .withCsrfToken() 66 + .header('Accept', 'application/json') 67 + .json({}) 68 + response.assertStatus(422) 69 + }) 70 + 71 + test('POST /api/repost with missing body returns 422', async ({ client }) => { 72 + const account = await Account.create({ 73 + did: 'did:plc:engtest2', 74 + handle: 'engtest2.bsky.social', 75 + sessionData: '{}', 76 + createdAt: Date.now(), 77 + updatedAt: Date.now(), 78 + }) 79 + 80 + const response = await client 81 + .post('/api/repost') 82 + .loginAs(account) 83 + .withCsrfToken() 84 + .header('Accept', 'application/json') 85 + .json({}) 86 + response.assertStatus(422) 87 + }) 88 + })