From 4e4370d6f191c784c79c03cdfc16ec7813ca845e Mon Sep 17 00:00:00 2001 From: Ochenstarik Date: Tue, 18 Aug 2026 14:50:47 +0700 Subject: [PATCH] fix(vk): Phase 2.3.1 derive effectiveCapabilities from actual resolved auth mode --- ...-18-effective-capabilities-truthfulness.md | 16 + src/app/api/giveaways/[id]/route.ts | 15 +- src/app/api/posts/preview/route.ts | 4 +- src/core/types/giveaway.ts | 1 + src/providers/vk/vk-capabilities.ts | 2 +- src/providers/vk/vk-mock-provider.ts | 1 + src/providers/vk/vk-provider.ts | 1 + ...ffective-capabilities-truthfulness.test.ts | 363 ++++++++++++++++++ 8 files changed, 397 insertions(+), 6 deletions(-) create mode 100644 agents/antigravity/done/TASK-2026-08-18-effective-capabilities-truthfulness.md create mode 100644 tests/effective-capabilities-truthfulness.test.ts diff --git a/agents/antigravity/done/TASK-2026-08-18-effective-capabilities-truthfulness.md b/agents/antigravity/done/TASK-2026-08-18-effective-capabilities-truthfulness.md new file mode 100644 index 0000000..20e6758 --- /dev/null +++ b/agents/antigravity/done/TASK-2026-08-18-effective-capabilities-truthfulness.md @@ -0,0 +1,16 @@ +# Task Done: Phase 2.3.1 — Effective Capabilities Truthfulness Gate + +**Status:** DONE +**Assigned to:** Antigravity (@orchestrator) +**Date:** 2026-08-18 +**Base Commit:** `1495067` + +## Findings & Fixes +- **Claude C-4 finding**: `POST /api/posts/preview` derived `effectiveCapabilities` from session existence rather than the actual `VkAuthContext` used by `fetchPost`. +- **Fix**: + - `PostMetadata` now exposes safe, non-secret `resolvedAuthType?: 'SERVICE' | 'USER' | 'COMMUNITY'`. + - `VkProvider.executeFetchPost` records `resolvedAuthType: authContext.type` (tokens/secrets are never exposed). + - `POST /api/posts/preview` calculates `effectiveCapabilities` truthfully from `post.resolvedAuthType`. + - `GET /api/giveaways/[id]` checks `defaultUserRepository.getUserCredentials` truthfully instead of hardcoding synthetic `{ type: 'USER' }`. +- **Tests Added**: `tests/effective-capabilities-truthfulness.test.ts` (6 tests covering all 5 prompt requirements + giveaway detail check). +- **Test Suite Status**: 268/268 tests passing (47 test files). diff --git a/src/app/api/giveaways/[id]/route.ts b/src/app/api/giveaways/[id]/route.ts index dec6460..40bee70 100644 --- a/src/app/api/giveaways/[id]/route.ts +++ b/src/app/api/giveaways/[id]/route.ts @@ -4,6 +4,7 @@ import { generalApiRateLimiter } from '@/lib/rate-limiter'; import { resolveClientIp } from '@/lib/client-ip'; import { requireGiveawayOwner } from '@/lib/auth/auth-guard'; import { resolveEffectiveCapabilities } from '@/providers/vk/vk-capabilities'; +import { defaultUserRepository } from '@/lib/repository/user-repository'; export const dynamic = 'force-dynamic'; @@ -17,10 +18,18 @@ export async function GET( generalApiRateLimiter.assertAllowed(`giveaway-get:${clientIp}`); // Enforce giveaway ownership authorization - const { giveaway } = await requireGiveawayOwner(req, id); + const { giveaway, sessionUser } = await requireGiveawayOwner(req, id); - // Resolve runtime effective capabilities for the authenticated organizer - const effectiveCapabilities = resolveEffectiveCapabilities({ type: 'USER', token: 'active' }); + // Resolve runtime effective capabilities truthfully based on stored organizer credentials + let authType: 'SERVICE' | 'USER' = 'SERVICE'; + if (sessionUser?.id) { + const cred = await defaultUserRepository.getUserCredentials(sessionUser.id); + if (cred?.encryptedAccessToken) { + authType = 'USER'; + } + } + + const effectiveCapabilities = resolveEffectiveCapabilities({ type: authType }); return NextResponse.json({ success: true, diff --git a/src/app/api/posts/preview/route.ts b/src/app/api/posts/preview/route.ts index 3ae42f4..ff5c6cf 100644 --- a/src/app/api/posts/preview/route.ts +++ b/src/app/api/posts/preview/route.ts @@ -21,9 +21,9 @@ export async function POST(req: NextRequest) { // Fetch post with optional organizer session context for private/restricted access probe const post = await provider.fetchPost(validated.url, { organizerId: sessionUser?.id }); - // Derive effective capabilities based on authentication context + // Derive effective capabilities based on the actual auth mode used to access the post const effectiveCapabilities = resolveEffectiveCapabilities( - sessionUser ? { type: 'USER', token: 'active' } : { type: 'SERVICE', token: 'active' } + post.resolvedAuthType ? { type: post.resolvedAuthType } : undefined ); return NextResponse.json({ diff --git a/src/core/types/giveaway.ts b/src/core/types/giveaway.ts index 1440193..62e8f41 100644 --- a/src/core/types/giveaway.ts +++ b/src/core/types/giveaway.ts @@ -48,4 +48,5 @@ export interface PostMetadata { commentsCount: number; repostsCount: number; publishedAt?: Date; + resolvedAuthType?: 'SERVICE' | 'USER' | 'COMMUNITY'; } diff --git a/src/providers/vk/vk-capabilities.ts b/src/providers/vk/vk-capabilities.ts index fae8493..a827017 100644 --- a/src/providers/vk/vk-capabilities.ts +++ b/src/providers/vk/vk-capabilities.ts @@ -20,7 +20,7 @@ export const STATIC_VK_CAPABILITIES: ProviderCapabilities = { /** * Derives effective capabilities at runtime based on the resolved auth context and target resource. */ -export function resolveEffectiveCapabilities(authContext?: VkAuthContext): EffectiveCapabilities { +export function resolveEffectiveCapabilities(authContext?: { type: 'SERVICE' | 'USER' | 'COMMUNITY' } | VkAuthContext | null): EffectiveCapabilities { const accessMode: VkAccessMode = !authContext || authContext.type === 'SERVICE' ? 'PUBLIC_SERVICE' : authContext.type === 'USER' diff --git a/src/providers/vk/vk-mock-provider.ts b/src/providers/vk/vk-mock-provider.ts index 134d8cd..7f650ba 100644 --- a/src/providers/vk/vk-mock-provider.ts +++ b/src/providers/vk/vk-mock-provider.ts @@ -131,6 +131,7 @@ export class VkMockProvider implements SocialMediaProvider { commentsCount: 86, repostsCount: 37, publishedAt: new Date(Date.now() - 3600000 * 24 * 2), + resolvedAuthType: 'SERVICE', }; } diff --git a/src/providers/vk/vk-provider.ts b/src/providers/vk/vk-provider.ts index 0688742..246f78f 100644 --- a/src/providers/vk/vk-provider.ts +++ b/src/providers/vk/vk-provider.ts @@ -177,6 +177,7 @@ export class VkProvider implements SocialMediaProvider { commentsCount: post.comments?.count || 0, repostsCount: post.reposts?.count || 0, publishedAt: post.date ? new Date(post.date * 1000) : undefined, + resolvedAuthType: authContext.type, }; } diff --git a/tests/effective-capabilities-truthfulness.test.ts b/tests/effective-capabilities-truthfulness.test.ts new file mode 100644 index 0000000..9442ea0 --- /dev/null +++ b/tests/effective-capabilities-truthfulness.test.ts @@ -0,0 +1,363 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { NextRequest } from 'next/server'; +import { POST as previewPost } from '../src/app/api/posts/preview/route'; +import { GET as giveawayDetailGet } from '../src/app/api/giveaways/[id]/route'; +import { MemoryUserRepository, setUserRepository } from '../src/lib/repository/user-repository'; +import { defaultSessionStore, SESSION_COOKIE_NAME } from '../src/lib/auth/session'; +import { AesGcmTokenVault } from '../src/lib/auth/token-vault'; +import { TokenRefresher } from '../src/lib/auth/token-refresher'; +import { VkAuthContextResolver } from '../src/integrations/vk/vk-auth-resolver'; +import { VkProvider } from '../src/providers/vk/vk-provider'; +import { IVkClient } from '../src/integrations/vk/vk-client'; +import { VkAuthContext } from '../src/integrations/vk/vk-types'; +import { VkPrivateResourceError } from '../src/integrations/vk/vk-errors'; +import { ProviderFactory } from '../src/providers/factory'; +import { GiveawayStore } from '../src/lib/giveaway-store'; +import { MemoryGiveawayRepository } from '../src/lib/repository/memory-repository'; + +describe('Phase 2.3.1 — Effective Capabilities Truthfulness Gate', () => { + let userRepo: MemoryUserRepository; + let tokenVault: AesGcmTokenVault; + let tokenRefresher: TokenRefresher; + let authResolver: VkAuthContextResolver; + + let loggedInUser: any; + let sessionCookie: string; + let loggedInUserWithoutCreds: any; + let sessionCookieNoCreds: string; + + const userTokenPlain = 'vk1.a.user_organizer_valid_token_123'; + const serviceTokenPlain = 'vk_service_token_456'; + + beforeEach(async () => { + userRepo = new MemoryUserRepository(); + setUserRepository(userRepo); + + defaultSessionStore.clear(); + + tokenVault = new AesGcmTokenVault('test-master-token-encryption-key-32b!'); + tokenRefresher = new TokenRefresher(userRepo, tokenVault); + authResolver = new VkAuthContextResolver(tokenRefresher); + + process.env.VK_SERVICE_TOKEN = serviceTokenPlain; + process.env.USE_VK_MOCK = 'false'; + + // 1. User with valid encrypted VK credential + const encryptedAccessToken = await tokenVault.encrypt(userTokenPlain); + loggedInUser = await userRepo.upsertUserWithTokens({ + vkUserId: '11112222', + firstName: 'Alice', + lastName: 'Organizer', + encryptedAccessToken, + expiresIn: 3600, + }); + const sessionId1 = await defaultSessionStore.createSession(loggedInUser); + sessionCookie = `${SESSION_COOKIE_NAME}=${sessionId1}`; + + // 2. User with session but NO VK credentials in repository + loggedInUserWithoutCreds = await userRepo.upsertUserWithTokens({ + vkUserId: '33334444', + firstName: 'Charlie', + lastName: 'NoCreds', + encryptedAccessToken: '', // empty / missing + }); + // Remove credentials for Charlie + (userRepo as any).credentials.delete(loggedInUserWithoutCreds.id); + const sessionId2 = await defaultSessionStore.createSession(loggedInUserWithoutCreds); + sessionCookieNoCreds = `${SESSION_COOKIE_NAME}=${sessionId2}`; + + // Reset GiveawayStore + GiveawayStore.setRepository(new MemoryGiveawayRepository()); + }); + + // ─── Test 1: Logged-in user + public post + SERVICE succeeds → PUBLIC_SERVICE ─── + it('1. logged-in user + public post + SERVICE succeeds reports accessMode PUBLIC_SERVICE', async () => { + const mockClient: IVkClient = { + call: async (_method, _params, auth) => { + expect(auth.type).toBe('SERVICE'); + return { + items: [ + { + id: 101, + owner_id: -101, + date: 1700000000, + text: 'Public wall post', + likes: { count: 10 }, + comments: { count: 2 }, + reposts: { count: 0 }, + }, + ], + } as any; + }, + }; + + const provider = new VkProvider(serviceTokenPlain, mockClient, authResolver); + ProviderFactory.getVkProvider = () => provider; + + const req = new NextRequest('http://localhost:3000/api/posts/preview', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Cookie': sessionCookie, + }, + body: JSON.stringify({ url: 'https://vk.com/wall-101_101' }), + }); + + const res = await previewPost(req); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(json.success).toBe(true); + // Truthfulness: Despite user having a session, the post was fetched with SERVICE token + expect(json.post.resolvedAuthType).toBe('SERVICE'); + expect(json.effectiveCapabilities.accessMode).toBe('PUBLIC_SERVICE'); + expect(json.effectiveCapabilities.adminDetection).toBe(false); + }); + + // ─── Test 2: SERVICE denied + USER succeeds → ORGANIZER_USER ───────────────── + it('2. SERVICE denied (private) + USER fallback succeeds reports accessMode ORGANIZER_USER', async () => { + const authSequence: VkAuthContext[] = []; + + const mockClient: IVkClient = { + call: async (_method, _params, auth) => { + authSequence.push(auth!); + if (auth.type === 'SERVICE') { + // Private post access denied on service token + throw new VkPrivateResourceError('Post is private', { errorCode: 15 }); + } + // USER token succeeds + return { + items: [ + { + id: 202, + owner_id: -202, + date: 1700000000, + text: 'Private group post visible to organizer', + likes: { count: 25 }, + comments: { count: 7 }, + reposts: { count: 0 }, + }, + ], + } as any; + }, + }; + + const provider = new VkProvider(serviceTokenPlain, mockClient, authResolver); + ProviderFactory.getVkProvider = () => provider; + + const req = new NextRequest('http://localhost:3000/api/posts/preview', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Cookie': sessionCookie, + }, + body: JSON.stringify({ url: 'https://vk.com/wall-202_202' }), + }); + + const res = await previewPost(req); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(json.success).toBe(true); + expect(authSequence).toHaveLength(2); + expect(authSequence[0].type).toBe('SERVICE'); + expect(authSequence[1].type).toBe('USER'); + + // Truthfulness: Since USER fallback was actually used, it accurately reports ORGANIZER_USER + expect(json.post.resolvedAuthType).toBe('USER'); + expect(json.effectiveCapabilities.accessMode).toBe('ORGANIZER_USER'); + }); + + // ─── Test 3: Logged-in user with missing credentials + public SERVICE succeeds ─ + it('3. logged-in user with missing USER credential + public SERVICE access does NOT claim ORGANIZER_USER', async () => { + const mockClient: IVkClient = { + call: async () => ({ + items: [ + { + id: 303, + owner_id: -303, + date: 1700000000, + text: 'Public post for user without VK creds', + likes: { count: 1 }, + comments: { count: 0 }, + reposts: { count: 0 }, + }, + ], + } as any), + }; + + const provider = new VkProvider(serviceTokenPlain, mockClient, authResolver); + ProviderFactory.getVkProvider = () => provider; + + const req = new NextRequest('http://localhost:3000/api/posts/preview', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Cookie': sessionCookieNoCreds, // Charlie has no VK credentials + }, + body: JSON.stringify({ url: 'https://vk.com/wall-303_303' }), + }); + + const res = await previewPost(req); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(json.success).toBe(true); + // Truthfulness: must NOT claim ORGANIZER_USER + expect(json.post.resolvedAuthType).toBe('SERVICE'); + expect(json.effectiveCapabilities.accessMode).toBe('PUBLIC_SERVICE'); + }); + + // ─── Test 4: Anonymous + SERVICE succeeds → PUBLIC_SERVICE ─────────────────── + it('4. anonymous request + SERVICE succeeds reports accessMode PUBLIC_SERVICE', async () => { + const mockClient: IVkClient = { + call: async () => ({ + items: [ + { + id: 404, + owner_id: -404, + date: 1700000000, + text: 'Anonymous preview post', + likes: { count: 50 }, + comments: { count: 12 }, + reposts: { count: 3 }, + }, + ], + } as any), + }; + + const provider = new VkProvider(serviceTokenPlain, mockClient, authResolver); + ProviderFactory.getVkProvider = () => provider; + + const req = new NextRequest('http://localhost:3000/api/posts/preview', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + // No Cookie header + }, + body: JSON.stringify({ url: 'https://vk.com/wall-404_404' }), + }); + + const res = await previewPost(req); + const json = await res.json(); + + expect(res.status).toBe(200); + expect(json.success).toBe(true); + expect(json.post.resolvedAuthType).toBe('SERVICE'); + expect(json.effectiveCapabilities.accessMode).toBe('PUBLIC_SERVICE'); + }); + + // ─── Test 5: No token/credential/auth object leaked in preview response ─────── + it('5. preview response never contains tokens, credentials, or internal auth objects', async () => { + const mockClient: IVkClient = { + call: async () => ({ + items: [ + { + id: 505, + owner_id: -505, + date: 1700000000, + text: 'Security check post', + likes: { count: 2 }, + comments: { count: 1 }, + reposts: { count: 0 }, + }, + ], + } as any), + }; + + const provider = new VkProvider(serviceTokenPlain, mockClient, authResolver); + ProviderFactory.getVkProvider = () => provider; + + const req = new NextRequest('http://localhost:3000/api/posts/preview', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Cookie': sessionCookie, + }, + body: JSON.stringify({ url: 'https://vk.com/wall-505_505' }), + }); + + const res = await previewPost(req); + const text = await res.text(); + + // Security check: raw response text must NOT leak any secret strings + expect(text).not.toContain(userTokenPlain); + expect(text).not.toContain(serviceTokenPlain); + expect(text).not.toContain('tokenVault'); + expect(text).not.toContain('encryptedAccessToken'); + expect(text).not.toContain('refreshToken'); + + const parsed = JSON.parse(text); + // Post only contains safe literal resolvedAuthType string + expect(parsed.post.resolvedAuthType).toBe('SERVICE'); + expect(parsed.post).not.toHaveProperty('token'); + expect(parsed.post).not.toHaveProperty('authContext'); + }); + + // ─── Test 6: Giveaway detail effectiveCapabilities truthfulness ────────────── + it('6. giveaway detail endpoint reports ORGANIZER_USER when user has credentials, PUBLIC_SERVICE when missing', async () => { + // 6a. Create giveaway for Alice (has credentials) + const gwAlice = await GiveawayStore.create({ + sourceUrl: 'https://vk.com/wall-101_101', + platform: 'VK', + platformOwnerId: '-101', + platformPostId: '101', + title: 'Alice Giveaway', + organizerId: loggedInUser.id, + post: { + platform: 'VK', + ownerId: '-101', + postId: '101', + sourceUrl: 'https://vk.com/wall-101_101', + title: 'Alice Giveaway', + text: 'Text', + likesCount: 10, + commentsCount: 2, + repostsCount: 0, + }, + }); + + const reqAlice = new NextRequest(`http://localhost:3000/api/giveaways/${gwAlice.id}`, { + method: 'GET', + headers: { 'Cookie': sessionCookie }, + }); + + const resAlice = await giveawayDetailGet(reqAlice, { params: { id: gwAlice.id } }); + const jsonAlice = await resAlice.json(); + + expect(resAlice.status).toBe(200); + expect(jsonAlice.effectiveCapabilities.accessMode).toBe('ORGANIZER_USER'); + + // 6b. Create giveaway for Charlie (no credentials) + const gwCharlie = await GiveawayStore.create({ + sourceUrl: 'https://vk.com/wall-303_303', + platform: 'VK', + platformOwnerId: '-303', + platformPostId: '303', + title: 'Charlie Giveaway', + organizerId: loggedInUserWithoutCreds.id, + post: { + platform: 'VK', + ownerId: '-303', + postId: '303', + sourceUrl: 'https://vk.com/wall-303_303', + title: 'Charlie Giveaway', + text: 'Text', + likesCount: 1, + commentsCount: 0, + repostsCount: 0, + }, + }); + + const reqCharlie = new NextRequest(`http://localhost:3000/api/giveaways/${gwCharlie.id}`, { + method: 'GET', + headers: { 'Cookie': sessionCookieNoCreds }, + }); + + const resCharlie = await giveawayDetailGet(reqCharlie, { params: { id: gwCharlie.id } }); + const jsonCharlie = await resCharlie.json(); + + expect(resCharlie.status).toBe(200); + expect(jsonCharlie.effectiveCapabilities.accessMode).toBe('PUBLIC_SERVICE'); + }); +});