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({