Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
37401dc
feat(public-api): portal-session bearer auth helper
apple-techie May 29, 2026
9111264
fix(public-api): harden portal-auth (nullable email, empty returning …
apple-techie May 29, 2026
505fac9
feat(public-api): GET /api/public/v1/config
apple-techie May 29, 2026
8590cb1
feat(public-api): listPublicPosts feed query
apple-techie May 29, 2026
50010e3
fix(public-api): listPublicPostFeed uses inner join for board visibil…
apple-techie May 29, 2026
2e609e2
feat(public-api): GET posts feed + boards
apple-techie May 29, 2026
31f776b
fix(public-api): validate boardId query param (drop as-never cast)
apple-techie May 29, 2026
6ef87dc
feat(public-api): GET post detail + comments
apple-techie May 29, 2026
bfec429
feat(public-api): GET changelog list + entry
apple-techie May 29, 2026
8f0c614
feat(public-api): GET help categories, article, search
apple-techie May 29, 2026
ce1507f
feat(public-api): POST submit post (auth required)
apple-techie May 29, 2026
cb8e339
fix(public-api): submit — uniform board-not-found response, author-in…
apple-techie May 29, 2026
63641f6
feat(public-api): POST toggle vote (auth required)
apple-techie May 29, 2026
af0e5c1
feat(public-api): POST create comment (auth required)
apple-techie May 29, 2026
7471260
feat(public-api): publish /api/public/v1/openapi.json
apple-techie May 29, 2026
1525523
fix(public-api): enforce board/deleted/private visibility on public r…
apple-techie May 29, 2026
afa2026
fix(public-api): type-correct handler invocation in detail tests
apple-techie May 29, 2026
1c96915
fix(ci): update stale package filters (@quackback/* -> @opencoven*)
apple-techie May 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,11 @@ jobs:
# Build the widget bundle first — apps/web imports
# packages/widget/dist/browser.js via Vite ?raw, so it must exist
# before the web build runs.
- run: bun run --filter @quackback/widget build
- run: bun run --filter @opencoven/feedback-widget build
- run: bun run build
env:
SKIP_ENV_VALIDATION: true
- run: bun run --filter @quackback/web typecheck
- run: bun run --filter @opencoven-feedback/web typecheck

test:
runs-on: ubuntu-latest
Expand Down
190 changes: 190 additions & 0 deletions apps/web/src/lib/server/domains/api/__tests__/portal-auth.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { PrincipalId, UserId } from '@opencoven-feedback/ids'

const mockSessionFindFirst = vi.fn()
const mockPrincipalFindFirst = vi.fn()
const mockInsertPrincipal = vi.fn()
const mockGenerateId = vi.fn()

vi.mock('@/lib/server/db', () => ({
db: {
query: {
session: { findFirst: (...args: unknown[]) => mockSessionFindFirst(...args) },
principal: { findFirst: (...args: unknown[]) => mockPrincipalFindFirst(...args) },
},
insert: () => ({
values: () => ({
returning: () => mockInsertPrincipal(),
}),
}),
},
session: { token: 'token', expiresAt: 'expires_at' },
principal: { userId: 'user_id' },
eq: vi.fn(),
and: vi.fn(),
gt: vi.fn(),
}))

vi.mock('@opencoven-feedback/ids', () => ({
generateId: (...args: unknown[]) => mockGenerateId(...args),
}))

import { optionalPortalSession, requirePortalSession } from '../portal-auth'
import { UnauthorizedError } from '@/lib/shared/errors'

const USER_ID = 'user_abc123' as unknown as UserId
const PRINCIPAL_ID = 'principal_01kqhxq697fvgat0fn8rr1r7ew' as unknown as PrincipalId

const sessionRow = {
token: 'tok_valid',
expiresAt: new Date(Date.now() + 3600_000),
userId: USER_ID,
user: {
id: USER_ID,
email: 'alice@example.com',
name: 'Alice',
image: 'https://example.com/avatar.png',
},
}

const principalRow = {
id: PRINCIPAL_ID,
userId: USER_ID,
role: 'user',
type: 'user',
displayName: 'Alice',
avatarUrl: null,
}

function makeRequest(headers: Record<string, string> = {}): Request {
return new Request('http://test/api/public/v1/test', { headers })
}

describe('optionalPortalSession', () => {
beforeEach(() => {
mockSessionFindFirst.mockReset()
mockPrincipalFindFirst.mockReset()
mockInsertPrincipal.mockReset()
mockGenerateId.mockReset()
})

it('returns null when no authorization header is present', async () => {
const result = await optionalPortalSession(makeRequest())
expect(result).toBeNull()
expect(mockSessionFindFirst).not.toHaveBeenCalled()
})

it('returns null when authorization header is not a Bearer token', async () => {
const result = await optionalPortalSession(makeRequest({ authorization: 'Basic dXNlcjpwYXNz' }))
expect(result).toBeNull()
expect(mockSessionFindFirst).not.toHaveBeenCalled()
})

it('returns null when session lookup returns undefined', async () => {
mockSessionFindFirst.mockResolvedValue(undefined)
const result = await optionalPortalSession(makeRequest({ authorization: 'Bearer tok_missing' }))
expect(result).toBeNull()
})

it('returns null when session row has no user', async () => {
mockSessionFindFirst.mockResolvedValue({ ...sessionRow, user: null })
const result = await optionalPortalSession(makeRequest({ authorization: 'Bearer tok_valid' }))
expect(result).toBeNull()
})

it('returns null when session user has a null email', async () => {
mockSessionFindFirst.mockResolvedValue({
...sessionRow,
user: { ...sessionRow.user, email: null },
})
const result = await optionalPortalSession(makeRequest({ authorization: 'Bearer tok_valid' }))
expect(result).toBeNull()
})

it('returns null when session user has an empty string email', async () => {
mockSessionFindFirst.mockResolvedValue({
...sessionRow,
user: { ...sessionRow.user, email: '' },
})
const result = await optionalPortalSession(makeRequest({ authorization: 'Bearer tok_valid' }))
expect(result).toBeNull()
})

it('returns null when insert returning() yields an empty array', async () => {
mockSessionFindFirst.mockResolvedValue(sessionRow)
mockPrincipalFindFirst.mockResolvedValue(null)
mockGenerateId.mockReturnValue('principal_new_01')
mockInsertPrincipal.mockResolvedValue([])

const result = await optionalPortalSession(makeRequest({ authorization: 'Bearer tok_valid' }))
expect(result).toBeNull()
})

it('returns user + principal when session is valid and principal exists', async () => {
mockSessionFindFirst.mockResolvedValue(sessionRow)
mockPrincipalFindFirst.mockResolvedValue(principalRow)

const result = await optionalPortalSession(makeRequest({ authorization: 'Bearer tok_valid' }))

expect(result).not.toBeNull()
expect(result?.user.id).toBe(USER_ID)
expect(result?.user.email).toBe('alice@example.com')
expect(result?.user.name).toBe('Alice')
expect(result?.user.image).toBe('https://example.com/avatar.png')
expect(result?.principal.id).toBe(PRINCIPAL_ID)
expect(result?.principal.role).toBe('user')
expect(result?.principal.type).toBe('user')
})

it('inserts a principal and returns it when no principal exists for the user', async () => {
const newPrincipalId = 'principal_new_01' as unknown as PrincipalId
const newPrincipalRow = {
id: newPrincipalId,
userId: USER_ID,
role: 'user',
type: 'user',
displayName: 'Alice',
avatarUrl: null,
}
mockSessionFindFirst.mockResolvedValue(sessionRow)
mockPrincipalFindFirst.mockResolvedValue(null)
mockGenerateId.mockReturnValue(newPrincipalId)
mockInsertPrincipal.mockResolvedValue([newPrincipalRow])

const result = await optionalPortalSession(makeRequest({ authorization: 'Bearer tok_valid' }))

expect(mockGenerateId).toHaveBeenCalledWith('principal')
expect(mockInsertPrincipal).toHaveBeenCalled()
expect(result?.principal.id).toBe(newPrincipalId)
expect(result?.principal.role).toBe('user')
})
})

describe('requirePortalSession', () => {
beforeEach(() => {
mockSessionFindFirst.mockReset()
mockPrincipalFindFirst.mockReset()
mockInsertPrincipal.mockReset()
mockGenerateId.mockReset()
})

it('throws UnauthorizedError when no authorization header', async () => {
await expect(requirePortalSession(makeRequest())).rejects.toThrow(UnauthorizedError)
})

it('throws UnauthorizedError when token is invalid (no session found)', async () => {
mockSessionFindFirst.mockResolvedValue(undefined)
await expect(
requirePortalSession(makeRequest({ authorization: 'Bearer tok_bad' }))
).rejects.toThrow(UnauthorizedError)
})

it('returns session when valid', async () => {
mockSessionFindFirst.mockResolvedValue(sessionRow)
mockPrincipalFindFirst.mockResolvedValue(principalRow)

const result = await requirePortalSession(makeRequest({ authorization: 'Bearer tok_valid' }))
expect(result.user.email).toBe('alice@example.com')
expect(result.principal.role).toBe('user')
})
})
74 changes: 74 additions & 0 deletions apps/web/src/lib/server/domains/api/portal-auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
import type { PrincipalId, UserId } from '@opencoven-feedback/ids'
import { generateId } from '@opencoven-feedback/ids'
import type { Role } from '@/lib/server/auth'
import { db, session, principal, eq, and, gt } from '@/lib/server/db'
import { UnauthorizedError } from '@/lib/shared/errors'

export interface PortalSession {
user: { id: UserId; email: string; name: string; image: string | null }
principal: { id: PrincipalId; role: Role; type: string }
}

/** Resolves a portal session from an `Authorization: Bearer <token>` header. Returns null if absent/invalid/expired. */
export async function optionalPortalSession(request: Request): Promise<PortalSession | null> {
const authHeader = request.headers.get('authorization')
if (!authHeader?.startsWith('Bearer ')) return null

const token = authHeader.slice(7)
if (!token) return null

const row = await db.query.session.findFirst({
where: and(eq(session.token, token), gt(session.expiresAt, new Date())),
with: { user: true },
})

if (!row?.user) return null
if (!row.user.email) return null

const userId = row.userId as UserId

let principalRecord = await db.query.principal.findFirst({
where: eq(principal.userId, userId),
})

if (!principalRecord) {
const [created] = await db
.insert(principal)
.values({
id: generateId('principal'),
userId,
role: 'user',
displayName: row.user.name,
avatarUrl: row.user.image ?? null,
createdAt: new Date(),
})
.returning()
if (!created) return null
principalRecord = created
}

return {
user: {
id: userId,
email: row.user.email,
name: row.user.name,
image: row.user.image ?? null,
},
principal: {
id: principalRecord.id as PrincipalId,
role: principalRecord.role as Role,
type: principalRecord.type ?? 'user',
},
}
}

/** Same as `optionalPortalSession` but throws `UnauthorizedError` when there is no valid session. */
export async function requirePortalSession(request: Request): Promise<PortalSession> {
const portalSession = await optionalPortalSession(request)
if (!portalSession) {
throw new UnauthorizedError(
'Authentication required. Provide a valid session token in the Authorization header: Bearer <token>'
)
}
return portalSession
}
Loading