From fbe63540c4ef7ff624667e22e01a1872ed2fb7e4 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Wed, 3 Jun 2026 06:49:42 -0500 Subject: [PATCH 1/5] fix(storage): harden proxy object responses --- apps/web/src/routes/api/storage/$.ts | 36 ++++++++++++++----- .../storage/__tests__/proxy-upload.test.ts | 22 +++++++++++- 2 files changed, 49 insertions(+), 9 deletions(-) diff --git a/apps/web/src/routes/api/storage/$.ts b/apps/web/src/routes/api/storage/$.ts index 2558dfa68..6b68c7b1f 100644 --- a/apps/web/src/routes/api/storage/$.ts +++ b/apps/web/src/routes/api/storage/$.ts @@ -7,6 +7,32 @@ const PROXY_CACHE_TTL = 60 * 60 * 1000 // 1 hour const KEY_PREFIX = '/api/storage/' +const INLINE_PROXY_CONTENT_TYPES = new Set(['image/jpeg', 'image/png', 'image/gif', 'image/webp']) + +function isInlineProxyContentType(contentType: string): boolean { + return INLINE_PROXY_CONTENT_TYPES.has(contentType.split(';')[0]?.trim().toLowerCase() ?? '') +} + +function attachmentFilename(key: string): string { + const filename = key.split('/').pop()?.replace(/[^a-zA-Z0-9._-]/g, '_') + return filename || 'download' +} + +export function buildProxyObjectHeaders(key: string, contentType: string): Record { + const headers: Record = { + 'Content-Type': contentType, + 'Cache-Control': 'public, max-age=31536000, immutable', + 'X-Content-Type-Options': 'nosniff', + } + + if (!isInlineProxyContentType(contentType)) { + headers['Content-Disposition'] = `attachment; filename="${attachmentFilename(key)}"` + headers['Content-Security-Policy'] = 'sandbox' + } + + return headers +} + function extractKey(url: URL): string | null { const key = decodeURIComponent(url.pathname.slice(KEY_PREFIX.length)) return key && !key.includes('..') ? key : null @@ -128,10 +154,7 @@ export const Route = createFileRoute('/api/storage/$')({ if (Date.now() - cached.cachedAt < PROXY_CACHE_TTL) { return new Response(cached.data, { status: 200, - headers: { - 'Content-Type': cached.contentType, - 'Cache-Control': 'public, max-age=31536000, immutable', - }, + headers: buildProxyObjectHeaders(key, cached.contentType), }) } proxyCache.delete(key) @@ -144,10 +167,7 @@ export const Route = createFileRoute('/api/storage/$')({ return new Response(data, { status: 200, - headers: { - 'Content-Type': contentType, - 'Cache-Control': 'public, max-age=31536000, immutable', - }, + headers: buildProxyObjectHeaders(key, contentType), }) } diff --git a/apps/web/src/routes/api/storage/__tests__/proxy-upload.test.ts b/apps/web/src/routes/api/storage/__tests__/proxy-upload.test.ts index 30a452e3d..31b3bc8a4 100644 --- a/apps/web/src/routes/api/storage/__tests__/proxy-upload.test.ts +++ b/apps/web/src/routes/api/storage/__tests__/proxy-upload.test.ts @@ -19,7 +19,7 @@ vi.mock('@/lib/server/storage/s3', () => ({ const mockConfig = { s3Proxy: true } vi.mock('@/lib/server/config', () => ({ config: mockConfig })) -const { handleProxyUpload } = await import('../$.js') +const { buildProxyObjectHeaders, handleProxyUpload } = await import('../$.js') const KEY = 'avatars/2024/01/abc123-photo.png' const CT = 'image/png' @@ -52,6 +52,26 @@ beforeEach(() => { mockUploadObject.mockResolvedValue(undefined) }) +describe('buildProxyObjectHeaders', () => { + it('forces active content to download from same-origin proxy responses', () => { + const headers = buildProxyObjectHeaders('uploads/2026/06/xss.html', 'text/html') + + expect(headers['Content-Type']).toBe('text/html') + expect(headers['X-Content-Type-Options']).toBe('nosniff') + expect(headers['Content-Disposition']).toBe('attachment; filename="xss.html"') + expect(headers['Content-Security-Policy']).toBe('sandbox') + }) + + it('keeps supported raster images inline', () => { + const headers = buildProxyObjectHeaders('avatars/2026/06/photo.png', 'image/png') + + expect(headers['Content-Type']).toBe('image/png') + expect(headers['X-Content-Type-Options']).toBe('nosniff') + expect(headers['Content-Disposition']).toBeUndefined() + expect(headers['Content-Security-Policy']).toBeUndefined() + }) +}) + describe('PUT /api/storage/* (proxy upload)', () => { it('returns 403 when S3 is not configured', async () => { mockIsS3Configured.mockReturnValue(false) From 6e5d85c3ab76e049fd6263673aaa65c65c0f8622 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Wed, 3 Jun 2026 06:51:10 -0500 Subject: [PATCH 2/5] fix(docker): bind Mailpit ports to localhost --- docker-compose.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index 05642c20c..3b4caead4 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -65,8 +65,8 @@ services: container_name: quackback-mailpit restart: unless-stopped ports: - - '8025:8025' # Web UI - - '1025:1025' # SMTP + - '127.0.0.1:8025:8025' # Web UI + - '127.0.0.1:1025:1025' # SMTP healthcheck: test: ['CMD', 'wget', '--spider', '-q', 'http://localhost:8025/api/v1/info'] interval: 5s From f06dd133541e8ebc21a55b4782a43d366c999c63 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Wed, 3 Jun 2026 06:51:28 -0500 Subject: [PATCH 3/5] fix: harden proxied storage responses --- apps/web/src/routes/api/storage/$.ts | 156 +++++++++++------- .../api/storage/__tests__/proxy-get.test.ts | 94 +++++++++++ 2 files changed, 187 insertions(+), 63 deletions(-) create mode 100644 apps/web/src/routes/api/storage/__tests__/proxy-get.test.ts diff --git a/apps/web/src/routes/api/storage/$.ts b/apps/web/src/routes/api/storage/$.ts index 2558dfa68..b8528cf45 100644 --- a/apps/web/src/routes/api/storage/$.ts +++ b/apps/web/src/routes/api/storage/$.ts @@ -5,6 +5,14 @@ import { createFileRoute } from '@tanstack/react-router' const proxyCache = new Map() const PROXY_CACHE_TTL = 60 * 60 * 1000 // 1 hour +const SAFE_PROXY_IMAGE_TYPES = new Set([ + 'image/jpeg', + 'image/png', + 'image/gif', + 'image/webp', + 'image/avif', +]) + const KEY_PREFIX = '/api/storage/' function extractKey(url: URL): string | null { @@ -12,6 +20,32 @@ function extractKey(url: URL): string | null { return key && !key.includes('..') ? key : null } +function getMediaType(contentType: string): string { + return contentType.split(';', 1)[0].trim().toLowerCase() +} + +function getAttachmentFilename(key: string): string { + const filename = key.split('/').pop() || 'download' + return filename.replace(/[\r\n"]/g, '_') +} + +function buildProxyGetHeaders(key: string, contentType: string): HeadersInit { + const mediaType = getMediaType(contentType) + const headers: Record = { + 'Cache-Control': 'public, max-age=31536000, immutable', + 'X-Content-Type-Options': 'nosniff', + } + + if (SAFE_PROXY_IMAGE_TYPES.has(mediaType)) { + headers['Content-Type'] = contentType + } else { + headers['Content-Type'] = 'application/octet-stream' + headers['Content-Disposition'] = `attachment; filename="${getAttachmentFilename(key)}"` + } + + return headers +} + // Reads up to maxBytes from the request body stream, cancelling early if exceeded. // Returns null when the body exceeds the limit, avoiding full buffering of oversized payloads. export async function readBodyWithLimit( @@ -80,6 +114,64 @@ export async function handleProxyUpload({ request }: { request: Request }): Prom return new Response(null, { status: 200 }) } +export async function handleProxyGet({ request }: { request: Request }): Promise { + const { isS3Configured, generatePresignedGetUrl, getS3Object } = + await import('@/lib/server/storage/s3') + const { config } = await import('@/lib/server/config') + + if (!isS3Configured()) { + return Response.json({ error: 'Storage not configured' }, { status: 503 }) + } + + const url = new URL(request.url) + const key = extractKey(url) + + if (!key) { + return Response.json({ error: 'Invalid storage key' }, { status: 400 }) + } + + // Force proxy for email embeds (?email=1) since email clients don't follow redirects + const forceProxy = url.searchParams.has('email') + + try { + if (config.s3Proxy || forceProxy) { + const cached = proxyCache.get(key) + if (cached) { + if (Date.now() - cached.cachedAt < PROXY_CACHE_TTL) { + return new Response(cached.data, { + status: 200, + headers: buildProxyGetHeaders(key, cached.contentType), + }) + } + proxyCache.delete(key) + } + + const { body, contentType } = await getS3Object(key) + const data = await new Response(body).arrayBuffer() + + proxyCache.set(key, { data, contentType, cachedAt: Date.now() }) + + return new Response(data, { + status: 200, + headers: buildProxyGetHeaders(key, contentType), + }) + } + + const presignedUrl = await generatePresignedGetUrl(key) + + return new Response(null, { + status: 302, + headers: { + Location: presignedUrl, + 'Cache-Control': 'public, max-age=86400', + }, + }) + } catch (error) { + console.error('Error serving storage object:', error) + return Response.json({ error: 'Failed to resolve storage URL' }, { status: 500 }) + } +} + export const Route = createFileRoute('/api/storage/$')({ server: { handlers: { @@ -102,69 +194,7 @@ export const Route = createFileRoute('/api/storage/$')({ * Otherwise, redirects to a presigned S3 URL (302) so the browser fetches * directly from S3 — no bytes are proxied through the server. */ - GET: async ({ request }) => { - const { isS3Configured, generatePresignedGetUrl, getS3Object } = - await import('@/lib/server/storage/s3') - const { config } = await import('@/lib/server/config') - - if (!isS3Configured()) { - return Response.json({ error: 'Storage not configured' }, { status: 503 }) - } - - const url = new URL(request.url) - const key = extractKey(url) - - if (!key) { - return Response.json({ error: 'Invalid storage key' }, { status: 400 }) - } - - // Force proxy for email embeds (?email=1) since email clients don't follow redirects - const forceProxy = url.searchParams.has('email') - - try { - if (config.s3Proxy || forceProxy) { - const cached = proxyCache.get(key) - if (cached) { - if (Date.now() - cached.cachedAt < PROXY_CACHE_TTL) { - return new Response(cached.data, { - status: 200, - headers: { - 'Content-Type': cached.contentType, - 'Cache-Control': 'public, max-age=31536000, immutable', - }, - }) - } - proxyCache.delete(key) - } - - const { body, contentType } = await getS3Object(key) - const data = await new Response(body).arrayBuffer() - - proxyCache.set(key, { data, contentType, cachedAt: Date.now() }) - - return new Response(data, { - status: 200, - headers: { - 'Content-Type': contentType, - 'Cache-Control': 'public, max-age=31536000, immutable', - }, - }) - } - - const presignedUrl = await generatePresignedGetUrl(key) - - return new Response(null, { - status: 302, - headers: { - Location: presignedUrl, - 'Cache-Control': 'public, max-age=86400', - }, - }) - } catch (error) { - console.error('Error serving storage object:', error) - return Response.json({ error: 'Failed to resolve storage URL' }, { status: 500 }) - } - }, + GET: handleProxyGet, }, }, }) diff --git a/apps/web/src/routes/api/storage/__tests__/proxy-get.test.ts b/apps/web/src/routes/api/storage/__tests__/proxy-get.test.ts new file mode 100644 index 000000000..83906d137 --- /dev/null +++ b/apps/web/src/routes/api/storage/__tests__/proxy-get.test.ts @@ -0,0 +1,94 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mockIsS3Configured = vi.fn(() => true) +const mockGeneratePresignedGetUrl = vi.fn(async (key: string) => `https://s3.example/${key}?signed=1`) +const mockGetS3Object = vi.fn() + +vi.mock('@/lib/server/storage/s3', () => ({ + isS3Configured: mockIsS3Configured, + generatePresignedGetUrl: mockGeneratePresignedGetUrl, + getS3Object: mockGetS3Object, +})) + +const mockConfig = { s3Proxy: true } +vi.mock('@/lib/server/config', () => ({ config: mockConfig })) + +const { handleProxyGet } = await import('../$.js') + +function streamFromText(text: string): ReadableStream { + return new Response(text).body! +} + +function makeRequest(key: string, search = ''): Request { + return new Request(`http://localhost/api/storage/${key}${search}`) +} + +beforeEach(() => { + vi.clearAllMocks() + mockConfig.s3Proxy = true + mockIsS3Configured.mockReturnValue(true) + mockGeneratePresignedGetUrl.mockImplementation(async (key: string) => `https://s3.example/${key}?signed=1`) + mockGetS3Object.mockResolvedValue({ + body: streamFromText('file-body'), + contentType: 'image/png', + }) +}) + +describe('GET /api/storage/* (proxy)', () => { + it('preserves safe image content types and sets nosniff', async () => { + mockGetS3Object.mockResolvedValueOnce({ + body: streamFromText('png-bytes'), + contentType: 'image/png', + }) + + const res = await handleProxyGet({ request: makeRequest('uploads/safe-image.png') }) + + expect(res.status).toBe(200) + expect(res.headers.get('Content-Type')).toBe('image/png') + expect(res.headers.get('X-Content-Type-Options')).toBe('nosniff') + expect(res.headers.get('Content-Disposition')).toBeNull() + expect(await res.text()).toBe('png-bytes') + }) + + it('serves non-image objects as attachments without reflecting active content types', async () => { + mockGetS3Object.mockResolvedValueOnce({ + body: streamFromText(''), + contentType: 'text/html', + }) + + const res = await handleProxyGet({ request: makeRequest('uploads/evil.html') }) + + expect(res.status).toBe(200) + expect(res.headers.get('Content-Type')).toBe('application/octet-stream') + expect(res.headers.get('X-Content-Type-Options')).toBe('nosniff') + expect(res.headers.get('Content-Disposition')).toBe('attachment; filename="evil.html"') + expect(await res.text()).toBe('') + }) + + it('applies safe headers to cached non-image objects', async () => { + mockGetS3Object.mockResolvedValueOnce({ + body: streamFromText(''), + contentType: 'image/svg+xml', + }) + + const key = 'uploads/evil.svg' + const first = await handleProxyGet({ request: makeRequest(key) }) + const second = await handleProxyGet({ request: makeRequest(key) }) + + expect(first.headers.get('Content-Type')).toBe('application/octet-stream') + expect(second.headers.get('Content-Type')).toBe('application/octet-stream') + expect(second.headers.get('X-Content-Type-Options')).toBe('nosniff') + expect(second.headers.get('Content-Disposition')).toBe('attachment; filename="evil.svg"') + expect(mockGetS3Object).toHaveBeenCalledTimes(1) + }) + + it('redirects to S3 when proxy mode is disabled and email proxy is not requested', async () => { + mockConfig.s3Proxy = false + + const res = await handleProxyGet({ request: makeRequest('uploads/file.html') }) + + expect(res.status).toBe(302) + expect(res.headers.get('Location')).toBe('https://s3.example/uploads/file.html?signed=1') + expect(mockGetS3Object).not.toHaveBeenCalled() + }) +}) From 7e1b943e91d3e8a4d3b65915a607c668cf398c9d Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Wed, 3 Jun 2026 07:12:12 -0500 Subject: [PATCH 4/5] fix: scope Zendesk sidebar API keys --- .../api-keys/create-api-key-dialog.tsx | 27 +++++++++++++- .../integrations/zendesk/use-zaf-client.tsx | 4 +- .../domains/api-keys/api-key.service.ts | 37 +++++++++++++++---- .../server/domains/api-keys/api-key.types.ts | 1 + apps/web/src/lib/server/domains/api/auth.ts | 11 ++++-- apps/web/src/lib/server/functions/api-keys.ts | 4 ++ .../integrations/zendesk/app/manifest.json | 4 +- apps/web/src/routes/api/v1/apps/boards.ts | 3 +- apps/web/src/routes/api/v1/apps/link.ts | 6 ++- apps/web/src/routes/api/v1/apps/linked.ts | 3 +- apps/web/src/routes/api/v1/apps/posts.ts | 6 ++- apps/web/src/routes/api/v1/apps/search.ts | 3 +- apps/web/src/routes/api/v1/apps/suggest.ts | 3 +- apps/web/src/routes/api/v1/apps/unlink.ts | 3 +- 14 files changed, 91 insertions(+), 24 deletions(-) diff --git a/apps/web/src/components/admin/settings/api-keys/create-api-key-dialog.tsx b/apps/web/src/components/admin/settings/api-keys/create-api-key-dialog.tsx index 4c7a82593..678fe3ed5 100644 --- a/apps/web/src/components/admin/settings/api-keys/create-api-key-dialog.tsx +++ b/apps/web/src/components/admin/settings/api-keys/create-api-key-dialog.tsx @@ -28,6 +28,7 @@ export function CreateApiKeyDialog({ open, onOpenChange, onKeyCreated }: CreateA const queryClient = useQueryClient() const [isPending, startTransition] = useTransition() const [name, setName] = useState('') + const [restrictToAppIntegrations, setRestrictToAppIntegrations] = useState(false) const [error, setError] = useState(null) const handleSubmit = async (e: React.FormEvent) => { @@ -40,7 +41,12 @@ export function CreateApiKeyDialog({ open, onOpenChange, onKeyCreated }: CreateA } try { - const result = await createApiKeyFn({ data: { name: name.trim() } }) + const result = await createApiKeyFn({ + data: { + name: name.trim(), + scopes: restrictToAppIntegrations ? ['apps:integrations'] : undefined, + }, + }) // Invalidate queries to refresh the list startTransition(() => { @@ -50,6 +56,7 @@ export function CreateApiKeyDialog({ open, onOpenChange, onKeyCreated }: CreateA // Reset form and notify parent setName('') + setRestrictToAppIntegrations(false) onKeyCreated(result.apiKey, result.plainTextKey) } catch (err) { console.error('Failed to create API key:', err) @@ -60,6 +67,7 @@ export function CreateApiKeyDialog({ open, onOpenChange, onKeyCreated }: CreateA const handleOpenChange = (newOpen: boolean) => { if (!newOpen) { setName('') + setRestrictToAppIntegrations(false) setError(null) } onOpenChange(newOpen) @@ -90,6 +98,23 @@ export function CreateApiKeyDialog({ open, onOpenChange, onKeyCreated }: CreateA Give your key a descriptive name so you can identify it later.

+ + {error &&

{error}

} diff --git a/apps/web/src/components/integrations/zendesk/use-zaf-client.tsx b/apps/web/src/components/integrations/zendesk/use-zaf-client.tsx index 24c341159..1b33d5c3b 100644 --- a/apps/web/src/components/integrations/zendesk/use-zaf-client.tsx +++ b/apps/web/src/components/integrations/zendesk/use-zaf-client.tsx @@ -31,7 +31,7 @@ interface ZafState { /** * Hook that loads the ZAF SDK, initializes the client, and extracts - * ticket context + API key from Zendesk settings. + * ticket context + restricted app key from Zendesk settings. * * Falls back to URL search params for local development. */ @@ -105,7 +105,7 @@ export function useZafClient(): ZafState { setState((s) => ({ ...s, status: 'error', - error: 'API key not configured in Zendesk app settings', + error: 'Sidebar app key not configured in Zendesk app settings', })) return } diff --git a/apps/web/src/lib/server/domains/api-keys/api-key.service.ts b/apps/web/src/lib/server/domains/api-keys/api-key.service.ts index 2fb642bf4..e1827f290 100644 --- a/apps/web/src/lib/server/domains/api-keys/api-key.service.ts +++ b/apps/web/src/lib/server/domains/api-keys/api-key.service.ts @@ -20,6 +20,8 @@ const API_KEY_PREFIX = 'qb_' /** Length of the random part of the key (in bytes, will be hex encoded) */ const KEY_RANDOM_BYTES = 24 // 48 hex chars +export const APP_INTEGRATION_API_KEY_SCOPE = 'apps:integrations' + /** Map a database row to the public ApiKey shape (strips keyHash). */ function toApiKey(row: ApiKey & Record): ApiKey { return { @@ -108,6 +110,7 @@ export async function createApiKey( createdById, principalId: servicePrincipal.id, expiresAt: input.expiresAt ?? null, + scopes: serializeScopes(input.scopes), }) .returning() @@ -126,9 +129,10 @@ export async function createApiKey( * Uses prefix-based DB lookup + timing-safe hash comparison to prevent * timing oracle attacks. Returns null if the key is invalid, expired, or revoked. * - * If `scope` is provided, the key must carry that capability scope or the - * call returns null. Used by /api/v1/internal/* endpoints which require - * the `internal:tier-limits` scope. + * Scoped keys are capability keys: they are accepted only when the + * caller explicitly requires one of their scopes. Normal public REST API + * calls pass no scope, so a scoped key exposed to a browser cannot be + * replayed against broader team/admin endpoints. */ export async function verifyApiKey(key: string, scope?: string): Promise { if (!key || !key.startsWith(API_KEY_PREFIX)) return null @@ -148,7 +152,7 @@ export async function verifyApiKey(key: string, scope?: string): Promise scope.trim()).filter(Boolean))) + return uniqueScopes.length ? JSON.stringify(uniqueScopes) : null +} + +function parseScopes(scopesRaw: string | null): string[] | null { + if (!scopesRaw) return null try { const parsed = JSON.parse(scopesRaw) - return Array.isArray(parsed) && parsed.includes(scope) + return Array.isArray(parsed) ? parsed.filter((scope) => typeof scope === 'string') : [] } catch { - return false + return [] } } +function hasRequiredScope(scopesRaw: string | null, requiredScope?: string): boolean { + const scopes = parseScopes(scopesRaw) + + // Normal API requests must use normal API keys. Capability-scoped keys are + // intentionally rejected unless the endpoint opts into a matching scope. + if (!requiredScope) return scopes === null + + return scopes?.includes(requiredScope) ?? false +} + /** * Rotate an API key - generates a new key and invalidates the old one * diff --git a/apps/web/src/lib/server/domains/api-keys/api-key.types.ts b/apps/web/src/lib/server/domains/api-keys/api-key.types.ts index db37fadda..63a1c2f5f 100644 --- a/apps/web/src/lib/server/domains/api-keys/api-key.types.ts +++ b/apps/web/src/lib/server/domains/api-keys/api-key.types.ts @@ -17,6 +17,7 @@ export interface ApiKey { export interface CreateApiKeyInput { name: string expiresAt?: Date | null + scopes?: string[] | null } export interface CreateApiKeyResult { diff --git a/apps/web/src/lib/server/domains/api/auth.ts b/apps/web/src/lib/server/domains/api/auth.ts index 92b14646b..0b646d652 100644 --- a/apps/web/src/lib/server/domains/api/auth.ts +++ b/apps/web/src/lib/server/domains/api/auth.ts @@ -47,7 +47,10 @@ function extractBearerToken(authHeader: string | null): string | null { * return errorResponse('UNAUTHORIZED', 'Invalid or missing API key', 401) * } */ -export async function requireApiKey(request: Request): Promise { +export async function requireApiKey( + request: Request, + scope?: string +): Promise { const authHeader = request.headers.get('authorization') const token = extractBearerToken(authHeader) @@ -55,7 +58,7 @@ export async function requireApiKey(request: Request): Promise { // Suspended / deleting workspaces are read-blocked at the API // chokepoint with 402 / 410. Self-hosters never set this — state @@ -108,7 +111,7 @@ export async function withApiKeyAuth( throw new RateLimitError(rateLimit.retryAfter ?? 60) } - const auth = await requireApiKey(request) + const auth = await requireApiKey(request, options.scope) if (!auth) { throw new UnauthorizedError( diff --git a/apps/web/src/lib/server/functions/api-keys.ts b/apps/web/src/lib/server/functions/api-keys.ts index 6ff638aa1..f63e456a2 100644 --- a/apps/web/src/lib/server/functions/api-keys.ts +++ b/apps/web/src/lib/server/functions/api-keys.ts @@ -11,9 +11,12 @@ import type { ApiKeyId } from '@/lib/server/domains/api-keys/api-key.service' // Schemas // ============================================ +const apiKeyScopes = ['apps:integrations'] as const + const createApiKeySchema = z.object({ name: z.string().min(1, 'Name is required').max(255, 'Name must be 255 characters or less'), expiresAt: z.string().datetime().optional().nullable(), + scopes: z.array(z.enum(apiKeyScopes)).optional(), }) const getApiKeySchema = z.object({ @@ -106,6 +109,7 @@ export const createApiKeyFn = createServerFn({ method: 'POST' }) { name: data.name, expiresAt: data.expiresAt ? new Date(data.expiresAt) : null, + scopes: data.scopes ?? null, }, auth.principal.id ) diff --git a/apps/web/src/lib/server/integrations/zendesk/app/manifest.json b/apps/web/src/lib/server/integrations/zendesk/app/manifest.json index dded79f04..dc336bb35 100644 --- a/apps/web/src/lib/server/integrations/zendesk/app/manifest.json +++ b/apps/web/src/lib/server/integrations/zendesk/app/manifest.json @@ -29,8 +29,8 @@ "name": "api_key", "type": "text", "required": true, - "label": "API Key", - "helpText": "Your Quackback API key (starts with qb_)" + "label": "Sidebar App Key", + "helpText": "A Quackback API key restricted to sidebar app endpoints (starts with qb_)" } ] } diff --git a/apps/web/src/routes/api/v1/apps/boards.ts b/apps/web/src/routes/api/v1/apps/boards.ts index ea9c3443c..81be7849e 100644 --- a/apps/web/src/routes/api/v1/apps/boards.ts +++ b/apps/web/src/routes/api/v1/apps/boards.ts @@ -1,5 +1,6 @@ import { createFileRoute } from '@tanstack/react-router' import { withApiKeyAuth } from '@/lib/server/domains/api/auth' +import { APP_INTEGRATION_API_KEY_SCOPE } from '@/lib/server/domains/api-keys/api-key.service' import { handleDomainError } from '@/lib/server/domains/api/responses' import { appJsonResponse, preflightResponse } from '@/lib/server/integrations/apps/cors' @@ -10,7 +11,7 @@ export const Route = createFileRoute('/api/v1/apps/boards')({ GET: async ({ request }) => { try { - await withApiKeyAuth(request, { role: 'team' }) + await withApiKeyAuth(request, { role: 'team', scope: APP_INTEGRATION_API_KEY_SCOPE }) const { listPublicBoardsWithStats } = await import('@/lib/server/domains/boards/board.public') diff --git a/apps/web/src/routes/api/v1/apps/link.ts b/apps/web/src/routes/api/v1/apps/link.ts index c460ce45c..56ed3fb71 100644 --- a/apps/web/src/routes/api/v1/apps/link.ts +++ b/apps/web/src/routes/api/v1/apps/link.ts @@ -1,6 +1,7 @@ import { createFileRoute } from '@tanstack/react-router' import { z } from 'zod' import { withApiKeyAuth } from '@/lib/server/domains/api/auth' +import { APP_INTEGRATION_API_KEY_SCOPE } from '@/lib/server/domains/api-keys/api-key.service' import { badRequestResponse, handleDomainError } from '@/lib/server/domains/api/responses' import { parseTypeId } from '@/lib/server/domains/api/validation' import type { PostId } from '@opencoven-feedback/ids' @@ -26,7 +27,10 @@ export const Route = createFileRoute('/api/v1/apps/link')({ POST: async ({ request }) => { try { - const { principalId } = await withApiKeyAuth(request, { role: 'team' }) + const { principalId } = await withApiKeyAuth(request, { + role: 'team', + scope: APP_INTEGRATION_API_KEY_SCOPE, + }) const body = await request.json() const parsed = linkSchema.safeParse(body) diff --git a/apps/web/src/routes/api/v1/apps/linked.ts b/apps/web/src/routes/api/v1/apps/linked.ts index fc754c784..2547362ec 100644 --- a/apps/web/src/routes/api/v1/apps/linked.ts +++ b/apps/web/src/routes/api/v1/apps/linked.ts @@ -1,5 +1,6 @@ import { createFileRoute } from '@tanstack/react-router' import { withApiKeyAuth } from '@/lib/server/domains/api/auth' +import { APP_INTEGRATION_API_KEY_SCOPE } from '@/lib/server/domains/api-keys/api-key.service' import { badRequestResponse, handleDomainError } from '@/lib/server/domains/api/responses' import { appJsonResponse, preflightResponse } from '@/lib/server/integrations/apps/cors' @@ -10,7 +11,7 @@ export const Route = createFileRoute('/api/v1/apps/linked')({ GET: async ({ request }) => { try { - await withApiKeyAuth(request, { role: 'team' }) + await withApiKeyAuth(request, { role: 'team', scope: APP_INTEGRATION_API_KEY_SCOPE }) const url = new URL(request.url) const integrationType = url.searchParams.get('integrationType') const externalId = url.searchParams.get('externalId') diff --git a/apps/web/src/routes/api/v1/apps/posts.ts b/apps/web/src/routes/api/v1/apps/posts.ts index ead3c6f08..5d75c2094 100644 --- a/apps/web/src/routes/api/v1/apps/posts.ts +++ b/apps/web/src/routes/api/v1/apps/posts.ts @@ -1,6 +1,7 @@ import { createFileRoute } from '@tanstack/react-router' import { z } from 'zod' import { withApiKeyAuth } from '@/lib/server/domains/api/auth' +import { APP_INTEGRATION_API_KEY_SCOPE } from '@/lib/server/domains/api-keys/api-key.service' import { badRequestResponse, handleDomainError } from '@/lib/server/domains/api/responses' import { parseTypeId } from '@/lib/server/domains/api/validation' import type { BoardId, PostId } from '@opencoven-feedback/ids' @@ -34,7 +35,10 @@ export const Route = createFileRoute('/api/v1/apps/posts')({ POST: async ({ request }) => { try { - const { principalId } = await withApiKeyAuth(request, { role: 'team' }) + const { principalId } = await withApiKeyAuth(request, { + role: 'team', + scope: APP_INTEGRATION_API_KEY_SCOPE, + }) const body = await request.json() const parsed = createPostSchema.safeParse(body) diff --git a/apps/web/src/routes/api/v1/apps/search.ts b/apps/web/src/routes/api/v1/apps/search.ts index 730e368e6..2a93b431b 100644 --- a/apps/web/src/routes/api/v1/apps/search.ts +++ b/apps/web/src/routes/api/v1/apps/search.ts @@ -1,5 +1,6 @@ import { createFileRoute } from '@tanstack/react-router' import { withApiKeyAuth } from '@/lib/server/domains/api/auth' +import { APP_INTEGRATION_API_KEY_SCOPE } from '@/lib/server/domains/api-keys/api-key.service' import { handleDomainError } from '@/lib/server/domains/api/responses' import { appJsonResponse, preflightResponse } from '@/lib/server/integrations/apps/cors' @@ -10,7 +11,7 @@ export const Route = createFileRoute('/api/v1/apps/search')({ GET: async ({ request }) => { try { - await withApiKeyAuth(request, { role: 'team' }) + await withApiKeyAuth(request, { role: 'team', scope: APP_INTEGRATION_API_KEY_SCOPE }) const url = new URL(request.url) const q = url.searchParams.get('q')?.trim() const limit = Math.min(Number(url.searchParams.get('limit')) || 10, 20) diff --git a/apps/web/src/routes/api/v1/apps/suggest.ts b/apps/web/src/routes/api/v1/apps/suggest.ts index 3136fdf26..81a302841 100644 --- a/apps/web/src/routes/api/v1/apps/suggest.ts +++ b/apps/web/src/routes/api/v1/apps/suggest.ts @@ -1,5 +1,6 @@ import { createFileRoute } from '@tanstack/react-router' import { withApiKeyAuth } from '@/lib/server/domains/api/auth' +import { APP_INTEGRATION_API_KEY_SCOPE } from '@/lib/server/domains/api-keys/api-key.service' import { badRequestResponse, handleDomainError } from '@/lib/server/domains/api/responses' import { fromUuid } from '@opencoven-feedback/ids' import { db, posts, boards } from '@/lib/server/db' @@ -13,7 +14,7 @@ export const Route = createFileRoute('/api/v1/apps/suggest')({ GET: async ({ request }) => { try { - await withApiKeyAuth(request, { role: 'team' }) + await withApiKeyAuth(request, { role: 'team', scope: APP_INTEGRATION_API_KEY_SCOPE }) const url = new URL(request.url) const text = url.searchParams.get('text')?.trim() const limit = Math.min(Number(url.searchParams.get('limit')) || 5, 20) diff --git a/apps/web/src/routes/api/v1/apps/unlink.ts b/apps/web/src/routes/api/v1/apps/unlink.ts index 6eeeeacab..8301988e9 100644 --- a/apps/web/src/routes/api/v1/apps/unlink.ts +++ b/apps/web/src/routes/api/v1/apps/unlink.ts @@ -1,6 +1,7 @@ import { createFileRoute } from '@tanstack/react-router' import { z } from 'zod' import { withApiKeyAuth } from '@/lib/server/domains/api/auth' +import { APP_INTEGRATION_API_KEY_SCOPE } from '@/lib/server/domains/api-keys/api-key.service' import { badRequestResponse, handleDomainError } from '@/lib/server/domains/api/responses' import { parseTypeId } from '@/lib/server/domains/api/validation' import type { PostId } from '@opencoven-feedback/ids' @@ -19,7 +20,7 @@ export const Route = createFileRoute('/api/v1/apps/unlink')({ POST: async ({ request }) => { try { - await withApiKeyAuth(request, { role: 'team' }) + await withApiKeyAuth(request, { role: 'team', scope: APP_INTEGRATION_API_KEY_SCOPE }) const body = await request.json() const parsed = unlinkSchema.safeParse(body) From 3bc3168d77eda8cbf9e32ac1a54e899ae3ebcd12 Mon Sep 17 00:00:00 2001 From: Val Alexander Date: Fri, 5 Jun 2026 16:59:44 -0700 Subject: [PATCH 5/5] fix: accept legacy config kind --- .../src/lib/server/config-file/__tests__/schema.test.ts | 9 +++++++++ apps/web/src/lib/server/config-file/schema.ts | 4 +++- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/apps/web/src/lib/server/config-file/__tests__/schema.test.ts b/apps/web/src/lib/server/config-file/__tests__/schema.test.ts index 35fb15649..76d0467df 100644 --- a/apps/web/src/lib/server/config-file/__tests__/schema.test.ts +++ b/apps/web/src/lib/server/config-file/__tests__/schema.test.ts @@ -36,6 +36,15 @@ describe('parseQuackbackConfig', () => { expect(result.success).toBe(true) }) + it('accepts the rebranded config kind', () => { + const result = parseQuackbackConfig({ + apiVersion: 'quackback.io/v1', + kind: 'quackbackConfig', + spec: {}, + }) + expect(result.success).toBe(true) + }) + it('rejects a missing apiVersion', () => { const result = parseQuackbackConfig({ kind: 'QuackbackConfig', spec: {} }) expect(result.success).toBe(false) diff --git a/apps/web/src/lib/server/config-file/schema.ts b/apps/web/src/lib/server/config-file/schema.ts index 71037a8e6..73d7c37e2 100644 --- a/apps/web/src/lib/server/config-file/schema.ts +++ b/apps/web/src/lib/server/config-file/schema.ts @@ -126,7 +126,9 @@ const authSchema = z export const quackbackConfigSchema = z .object({ apiVersion: z.literal('quackback.io/v1'), - kind: z.literal('QuackbackConfig'), + // Accept both the original public discriminator and the rebranded + // spelling so existing managed deployments continue to reconcile. + kind: z.enum(['QuackbackConfig', 'quackbackConfig']), metadata: z.object({ source: z.string().optional() }).strict().optional(), spec: z .object({