Some checks failed
Deploy to Test Environment / deploy-to-test (push) Failing after 2m15s
397 lines
11 KiB
Markdown
397 lines
11 KiB
Markdown
# Integrations Subagent Guide
|
|
|
|
The **integrations-specialist** subagent handles third-party services, webhooks, and external API integrations in the Flyer Crawler project.
|
|
|
|
## Quick Reference
|
|
|
|
| Aspect | Details |
|
|
| --------------- | --------------------------------------------------------------------------- |
|
|
| **Primary Use** | External APIs, webhooks, OAuth, third-party services |
|
|
| **Key Files** | `src/services/external/`, `src/routes/webhooks.routes.ts` |
|
|
| **Key ADRs** | ADR-041 (AI Integration), ADR-016 (API Security), ADR-048 (Auth) |
|
|
| **MCP Tools** | `mcp__gitea-projectium__*`, `mcp__bugsink__*` |
|
|
| **Security** | API key storage, webhook signatures, OAuth state param |
|
|
| **Delegate To** | `coder` (implementation), `security-engineer` (review), `ai-usage` (Gemini) |
|
|
|
|
## When to Use
|
|
|
|
Use the **integrations-specialist** subagent when you need to:
|
|
|
|
- Integrate with external APIs (OAuth, REST, GraphQL)
|
|
- Implement webhook handlers
|
|
- Configure third-party services
|
|
- Debug external service connectivity
|
|
- Handle API authentication flows
|
|
- Manage external service rate limits
|
|
|
|
## What integrations-specialist Knows
|
|
|
|
The integrations-specialist subagent understands:
|
|
|
|
- OAuth 2.0 flows (authorization code, client credentials)
|
|
- REST API integration patterns
|
|
- Webhook security (signature verification)
|
|
- External service error handling
|
|
- Rate limiting and retry strategies
|
|
- API key management
|
|
|
|
## Current Integrations
|
|
|
|
| Service | Purpose | Integration Type | Key Files |
|
|
| ------------- | ---------------------- | ---------------- | ---------------------------------- |
|
|
| Google Gemini | AI flyer extraction | REST API | `src/services/aiService.server.ts` |
|
|
| Bugsink | Error tracking | REST API | MCP: `mcp__bugsink__*` |
|
|
| Gitea | Repository and CI/CD | REST API | MCP: `mcp__gitea-projectium__*` |
|
|
| Redis | Caching and job queues | Native client | `src/services/redis.server.ts` |
|
|
| PostgreSQL | Primary database | Native client | `src/services/db/pool.db.ts` |
|
|
|
|
## Example Requests
|
|
|
|
### Adding External API Integration
|
|
|
|
```
|
|
"Use integrations-specialist to integrate with the Store API
|
|
to automatically fetch store location data. Include proper
|
|
error handling, rate limiting, and caching."
|
|
```
|
|
|
|
### OAuth Implementation
|
|
|
|
```
|
|
"Use integrations-specialist to implement Google OAuth for
|
|
user authentication. Include token refresh handling and
|
|
session management."
|
|
```
|
|
|
|
### Webhook Handler
|
|
|
|
```
|
|
"Use integrations-specialist to create a webhook handler for
|
|
receiving store inventory updates. Include signature verification
|
|
and idempotency handling."
|
|
```
|
|
|
|
### Debugging External Service Issues
|
|
|
|
```
|
|
"Use integrations-specialist to debug why the Gemini API calls
|
|
are intermittently failing with timeout errors. Check connection
|
|
pooling, retry logic, and error handling."
|
|
```
|
|
|
|
## Integration Patterns
|
|
|
|
### REST API Client Pattern
|
|
|
|
```typescript
|
|
// src/services/external/storeApi.server.ts
|
|
import { env } from '@/config/env';
|
|
import { log } from '@/services/logger.server';
|
|
|
|
interface StoreApiConfig {
|
|
baseUrl: string;
|
|
apiKey: string;
|
|
timeout: number;
|
|
}
|
|
|
|
class StoreApiClient {
|
|
private config: StoreApiConfig;
|
|
|
|
constructor(config: StoreApiConfig) {
|
|
this.config = config;
|
|
}
|
|
|
|
async getStoreLocations(storeId: string): Promise<StoreLocation[]> {
|
|
const url = `${this.config.baseUrl}/stores/${storeId}/locations`;
|
|
|
|
try {
|
|
const response = await fetch(url, {
|
|
headers: {
|
|
Authorization: `Bearer ${this.config.apiKey}`,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
signal: AbortSignal.timeout(this.config.timeout),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
throw new ExternalApiError(`Store API error: ${response.status}`, response.status);
|
|
}
|
|
|
|
return response.json();
|
|
} catch (error) {
|
|
log.error({ error, storeId }, 'Failed to fetch store locations');
|
|
throw error;
|
|
}
|
|
}
|
|
}
|
|
|
|
export const storeApiClient = new StoreApiClient({
|
|
baseUrl: env.STORE_API_BASE_URL,
|
|
apiKey: env.STORE_API_KEY,
|
|
timeout: 10000,
|
|
});
|
|
```
|
|
|
|
### Webhook Handler Pattern
|
|
|
|
```typescript
|
|
// src/routes/webhooks.routes.ts
|
|
import { Router } from 'express';
|
|
import crypto from 'crypto';
|
|
import { env } from '@/config/env';
|
|
|
|
const router = Router();
|
|
|
|
function verifyWebhookSignature(payload: string, signature: string, secret: string): boolean {
|
|
const expected = crypto.createHmac('sha256', secret).update(payload).digest('hex');
|
|
return crypto.timingSafeEqual(Buffer.from(signature), Buffer.from(`sha256=${expected}`));
|
|
}
|
|
|
|
router.post('/store-updates', async (req, res, next) => {
|
|
try {
|
|
const signature = req.headers['x-webhook-signature'] as string;
|
|
const payload = JSON.stringify(req.body);
|
|
|
|
if (!verifyWebhookSignature(payload, signature, env.WEBHOOK_SECRET)) {
|
|
return res.status(401).json({ error: 'Invalid signature' });
|
|
}
|
|
|
|
// Process webhook with idempotency check
|
|
const eventId = req.headers['x-event-id'] as string;
|
|
const alreadyProcessed = await checkIdempotencyKey(eventId);
|
|
|
|
if (alreadyProcessed) {
|
|
return res.status(200).json({ status: 'already_processed' });
|
|
}
|
|
|
|
await processStoreUpdate(req.body);
|
|
await markEventProcessed(eventId);
|
|
|
|
res.status(200).json({ status: 'processed' });
|
|
} catch (error) {
|
|
next(error);
|
|
}
|
|
});
|
|
```
|
|
|
|
### OAuth Flow Pattern
|
|
|
|
```typescript
|
|
// src/services/oauth/googleOAuth.server.ts
|
|
import { OAuth2Client } from 'google-auth-library';
|
|
import { env } from '@/config/env';
|
|
|
|
const oauth2Client = new OAuth2Client(
|
|
env.GOOGLE_CLIENT_ID,
|
|
env.GOOGLE_CLIENT_SECRET,
|
|
env.GOOGLE_REDIRECT_URI,
|
|
);
|
|
|
|
export function getAuthorizationUrl(): string {
|
|
return oauth2Client.generateAuthUrl({
|
|
access_type: 'offline',
|
|
scope: ['email', 'profile'],
|
|
prompt: 'consent',
|
|
});
|
|
}
|
|
|
|
export async function exchangeCodeForTokens(code: string) {
|
|
const { tokens } = await oauth2Client.getToken(code);
|
|
return tokens;
|
|
}
|
|
|
|
export async function refreshAccessToken(refreshToken: string) {
|
|
oauth2Client.setCredentials({ refresh_token: refreshToken });
|
|
const { credentials } = await oauth2Client.refreshAccessToken();
|
|
return credentials;
|
|
}
|
|
```
|
|
|
|
## Error Handling for External Services
|
|
|
|
### Custom Error Classes
|
|
|
|
```typescript
|
|
// src/services/external/errors.ts
|
|
export class ExternalApiError extends Error {
|
|
constructor(
|
|
message: string,
|
|
public statusCode: number,
|
|
public retryable: boolean = false,
|
|
) {
|
|
super(message);
|
|
this.name = 'ExternalApiError';
|
|
}
|
|
}
|
|
|
|
export class RateLimitError extends ExternalApiError {
|
|
constructor(
|
|
message: string,
|
|
public retryAfter: number,
|
|
) {
|
|
super(message, 429, true);
|
|
this.name = 'RateLimitError';
|
|
}
|
|
}
|
|
```
|
|
|
|
### Retry with Exponential Backoff
|
|
|
|
```typescript
|
|
async function fetchWithRetry<T>(
|
|
fn: () => Promise<T>,
|
|
options: { maxRetries: number; baseDelay: number },
|
|
): Promise<T> {
|
|
let lastError: Error;
|
|
|
|
for (let attempt = 0; attempt <= options.maxRetries; attempt++) {
|
|
try {
|
|
return await fn();
|
|
} catch (error) {
|
|
lastError = error as Error;
|
|
|
|
if (error instanceof ExternalApiError && !error.retryable) {
|
|
throw error;
|
|
}
|
|
|
|
if (attempt < options.maxRetries) {
|
|
const delay = options.baseDelay * Math.pow(2, attempt);
|
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
}
|
|
}
|
|
}
|
|
|
|
throw lastError!;
|
|
}
|
|
```
|
|
|
|
## Rate Limiting Strategies
|
|
|
|
### Token Bucket Pattern
|
|
|
|
```typescript
|
|
class RateLimiter {
|
|
private tokens: number;
|
|
private lastRefill: number;
|
|
private readonly maxTokens: number;
|
|
private readonly refillRate: number; // tokens per second
|
|
|
|
constructor(maxTokens: number, refillRate: number) {
|
|
this.maxTokens = maxTokens;
|
|
this.tokens = maxTokens;
|
|
this.refillRate = refillRate;
|
|
this.lastRefill = Date.now();
|
|
}
|
|
|
|
async acquire(): Promise<void> {
|
|
this.refill();
|
|
|
|
if (this.tokens < 1) {
|
|
const waitTime = ((1 - this.tokens) / this.refillRate) * 1000;
|
|
await new Promise((resolve) => setTimeout(resolve, waitTime));
|
|
this.refill();
|
|
}
|
|
|
|
this.tokens -= 1;
|
|
}
|
|
|
|
private refill(): void {
|
|
const now = Date.now();
|
|
const elapsed = (now - this.lastRefill) / 1000;
|
|
this.tokens = Math.min(this.maxTokens, this.tokens + elapsed * this.refillRate);
|
|
this.lastRefill = now;
|
|
}
|
|
}
|
|
```
|
|
|
|
## Testing Integrations
|
|
|
|
### Mocking External Services
|
|
|
|
```typescript
|
|
// src/tests/mocks/storeApi.mock.ts
|
|
import { vi } from 'vitest';
|
|
|
|
export const mockStoreApiClient = {
|
|
getStoreLocations: vi.fn(),
|
|
};
|
|
|
|
vi.mock('@/services/external/storeApi.server', () => ({
|
|
storeApiClient: mockStoreApiClient,
|
|
}));
|
|
```
|
|
|
|
### Integration Test with Real Service
|
|
|
|
```typescript
|
|
// src/tests/integration/storeApi.integration.test.ts
|
|
describe('Store API Integration', () => {
|
|
it.skipIf(!env.STORE_API_KEY)('fetches real store locations', async () => {
|
|
const locations = await storeApiClient.getStoreLocations('test-store');
|
|
expect(locations).toBeInstanceOf(Array);
|
|
});
|
|
});
|
|
```
|
|
|
|
## MCP Tools for Integrations
|
|
|
|
### Gitea Integration
|
|
|
|
```
|
|
// List repositories
|
|
mcp__gitea-projectium__list_my_repos()
|
|
|
|
// Create issue
|
|
mcp__gitea-projectium__create_issue({
|
|
owner: "projectium",
|
|
repo: "flyer-crawler",
|
|
title: "Issue title",
|
|
body: "Issue description"
|
|
})
|
|
```
|
|
|
|
### Bugsink Integration
|
|
|
|
```
|
|
// List projects
|
|
mcp__bugsink__list_projects()
|
|
|
|
// Get issue details
|
|
mcp__bugsink__get_issue({ issue_id: "..." })
|
|
|
|
// Get stacktrace
|
|
mcp__bugsink__get_stacktrace({ event_id: "..." })
|
|
```
|
|
|
|
## Security Considerations
|
|
|
|
### API Key Storage
|
|
|
|
- Never commit API keys to version control
|
|
- Use environment variables via `src/config/env.ts`
|
|
- Rotate keys periodically
|
|
- Use separate keys for dev/test/prod
|
|
|
|
### Webhook Security
|
|
|
|
- Always verify webhook signatures
|
|
- Use HTTPS for webhook endpoints
|
|
- Implement idempotency
|
|
- Log webhook events for audit
|
|
|
|
### OAuth Security
|
|
|
|
- Use state parameter to prevent CSRF
|
|
- Store tokens securely (encrypted at rest)
|
|
- Implement token refresh before expiration
|
|
- Validate token scopes
|
|
|
|
## Related Documentation
|
|
|
|
- [OVERVIEW.md](./OVERVIEW.md) - Subagent system overview
|
|
- [SECURITY-DEBUG-GUIDE.md](./SECURITY-DEBUG-GUIDE.md) - Security patterns
|
|
- [AI-USAGE-GUIDE.md](./AI-USAGE-GUIDE.md) - Gemini API integration
|
|
- [../adr/0041-ai-gemini-integration-architecture.md](../adr/0041-ai-gemini-integration-architecture.md) - AI integration ADR
|
|
- [../adr/0016-api-security-hardening.md](../adr/0016-api-security-hardening.md) - API security
|
|
- [../adr/0048-authentication-strategy.md](../adr/0048-authentication-strategy.md) - Authentication
|