From ff06a7b5d711ce0b7771704c75a3e0a237583efe Mon Sep 17 00:00:00 2001 From: jl Date: Mon, 27 Jul 2026 14:01:14 +0800 Subject: [PATCH 1/4] feat(cms): add public read access and draft mode support - add `anyone` access control function with draft-token bypass for unpublished/draft documents - generate and wire up CMS_DRAFT_SECRET_TOKEN for draft mode auth - add custom /durianpy-website-types endpoint to sync only relevant collection types to the website repo --- .env.example | 1 + src/access/anyone.ts | 16 +- src/endpoints/durianpy-website-types/index.ts | 152 ++++++++++++++++++ src/payload.config.ts | 2 + tests/int/api.int.spec.ts | 19 +++ 5 files changed, 189 insertions(+), 1 deletion(-) create mode 100644 src/endpoints/durianpy-website-types/index.ts diff --git a/.env.example b/.env.example index c1198c6..db72ada 100644 --- a/.env.example +++ b/.env.example @@ -2,6 +2,7 @@ DATABASE_URL='mongodb://' PAYLOAD_SECRET= # openssl rand -hex 32 NEXT_PUBLIC_SERVER_URL=http://localhost:3000 +DRAFT_SECRET_TOKEN= # openssl rand -hex 32 SMTP_HOST=email-smtp.ap-southeast-1.amazonaws.com SMTP_USER= diff --git a/src/access/anyone.ts b/src/access/anyone.ts index bf37c3a..2d54cc0 100644 --- a/src/access/anyone.ts +++ b/src/access/anyone.ts @@ -1,3 +1,17 @@ import type { Access } from 'payload' -export const anyone: Access = () => true +export const anyone: Access = ({ req: { user, headers } }) => { + if (user) { + return true + } + const authorization = headers.get('authorization') + if ( + process.env.DRAFT_SECRET_TOKEN && + authorization === `Bearer ${process.env.DRAFT_SECRET_TOKEN}` + ) { + return true + } + return { + or: [{ _status: { equals: 'published' } }, { _status: { exists: false } }], + } +} diff --git a/src/endpoints/durianpy-website-types/index.ts b/src/endpoints/durianpy-website-types/index.ts new file mode 100644 index 0000000..57a5c26 --- /dev/null +++ b/src/endpoints/durianpy-website-types/index.ts @@ -0,0 +1,152 @@ +import fs from 'fs' +import path from 'path' + +import type { Endpoint } from 'payload' + +import { getCollectionGroupItems } from '@/constants/collections' + +const GROUP_SLUG = 'durianpy-website' as const + +function getCommentStartBefore(content: string, index: number): number { + const commentEnd = content.lastIndexOf('*/', index) + if (commentEnd === -1) return index + + const commentStart = content.lastIndexOf('/**', commentEnd) + if (commentStart === -1) return index + + const between = content.slice(commentEnd + 2, index) + return /^\s*$/.test(between) ? commentStart : index +} + +function extractInterfaceBlock(content: string, interfaceName: string): string | null { + const declaration = `export interface ${interfaceName}` + const declarationIndex = content.indexOf(declaration) + + if (declarationIndex === -1) return null + + const startIndex = getCommentStartBefore(content, declarationIndex) + const braceStart = content.indexOf('{', declarationIndex) + + if (braceStart === -1) return null + + let braceDepth = 0 + for (let i = braceStart; i < content.length; i += 1) { + const char = content[i] + + if (char === '{') braceDepth += 1 + if (char === '}') braceDepth -= 1 + + if (braceDepth === 0) { + return content.slice(startIndex, i + 1) + } + } + + return null +} + +function getCollectionTypeMapFromConfig(typesFileContent: string): Map { + const map = new Map() + const collectionsBlockMatch = typesFileContent.match(/collections:\s*\{([\s\S]*?)\n\s*\};/) + + if (!collectionsBlockMatch) return map + + const collectionsBlock = collectionsBlockMatch[1] + const collectionEntryRegex = /^\s*(?:'([^']+)'|([A-Za-z0-9_-]+)):\s*([A-Za-z0-9_]+);\s*$/gm + + let entryMatch: RegExpExecArray | null + while ((entryMatch = collectionEntryRegex.exec(collectionsBlock)) !== null) { + const slug = entryMatch[1] ?? entryMatch[2] + const typeName = entryMatch[3] + + if (!slug || !typeName) continue + map.set(slug, typeName) + } + + return map +} + +export const durianpyWebsiteTypesEndpoint: Endpoint = { + method: 'get', + path: '/durianpy-website-types', + handler: async () => { + try { + const fullTypes = fs.readFileSync( + path.resolve(process.cwd(), 'src/payload-types.ts'), + 'utf-8', + ) + + const groupCollectionSlugs = [...getCollectionGroupItems(GROUP_SLUG)].sort((a, b) => + a.localeCompare(b), + ) + + const collectionTypeMap = getCollectionTypeMapFromConfig(fullTypes) + + const missingTypeMappings: string[] = [] + const selectedTypeNames = groupCollectionSlugs + .map((slug) => { + const typeName = collectionTypeMap.get(slug) + if (!typeName) missingTypeMappings.push(slug) + return typeName + }) + .filter((name): name is string => Boolean(name)) + + if (missingTypeMappings.length > 0) { + return new Response( + JSON.stringify({ + error: 'Missing collection to type mappings in payload-types.ts', + group: GROUP_SLUG, + missingCollectionSlugs: missingTypeMappings, + }), + { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }, + ) + } + + const missingInterfaceBlocks: string[] = [] + const extractedInterfaces = selectedTypeNames + .map((typeName) => { + const block = extractInterfaceBlock(fullTypes, typeName) + if (!block) missingInterfaceBlocks.push(typeName) + return block + }) + .filter((block): block is string => Boolean(block)) + + if (missingInterfaceBlocks.length > 0) { + return new Response( + JSON.stringify({ + error: 'Could not extract one or more interfaces from payload-types.ts', + group: GROUP_SLUG, + missingInterfaces: missingInterfaceBlocks, + }), + { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }, + ) + } + + const typeSyncHeader = `// Auto-generated from src/payload-types.ts\n// Group: ${GROUP_SLUG}\n// Collections: ${groupCollectionSlugs.join(', ')}` + const payload = `${typeSyncHeader}\n\n${extractedInterfaces.join('\n\n')}` + + return new Response(payload, { + headers: { + 'Content-Type': 'text/plain; charset=utf-8', + 'Cache-Control': 'no-store', + }, + }) + } catch (error) { + return new Response( + JSON.stringify({ + error: 'Failed to generate durianpy-website type sync payload', + message: error instanceof Error ? error.message : 'Unknown error', + }), + { + status: 500, + headers: { 'Content-Type': 'application/json' }, + }, + ) + } + }, +} diff --git a/src/payload.config.ts b/src/payload.config.ts index 3beaf73..e5ae8e2 100644 --- a/src/payload.config.ts +++ b/src/payload.config.ts @@ -23,6 +23,7 @@ import { Sponsors } from './collections/durianpy-website/Sponsors' import { Carousel } from './globals/durianpy-website/Carousel' import { SIGs } from './collections/durianpy-website/SIGs' import { CodeOfConduct } from './globals/durianpy-website/CodeOfConduct' +import { durianpyWebsiteTypesEndpoint } from './endpoints/durianpy-website-types' const filename = fileURLToPath(import.meta.url) const dirname = path.dirname(filename) @@ -103,6 +104,7 @@ export default buildConfig({ }), }), collections: [Media, Categories, Users, Sample, ServiceAccounts, Events, Sponsors, SIGs], + endpoints: [durianpyWebsiteTypesEndpoint], globals: [HomepageConfig, CTASection, StatisticsConfig, Carousel, CodeOfConduct], cors: [getServerSideOrigin()].filter(Boolean).map((url) => { try { diff --git a/tests/int/api.int.spec.ts b/tests/int/api.int.spec.ts index 9bd5adb..33fccbc 100644 --- a/tests/int/api.int.spec.ts +++ b/tests/int/api.int.spec.ts @@ -1,5 +1,6 @@ import { getPayload, Payload } from 'payload' import config from '@/payload.config' +import { GET as getDurianTypes } from '@/app/(payload)/api/durianpy-website-types/route' import { describe, it, beforeAll, expect } from 'vitest' @@ -17,4 +18,22 @@ describe('API', () => { }) expect(users).toBeDefined() }) + + it('returns only durianpy-website collection interfaces', async () => { + const response = await getDurianTypes() + + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toContain('text/plain') + + const body = await response.text() + + expect(body).toContain('Group: durianpy-website') + expect(body).toContain('Collections: sample, users') + expect(body).toContain('export interface Sample') + expect(body).toContain('export interface User') + + // Ensure non-group collections are excluded. + expect(body).not.toContain('export interface Media') + expect(body).not.toContain('export interface Category') + }) }) From 36e201c7ca13779ace264cad9ef6f4e504b2bd53 Mon Sep 17 00:00:00 2001 From: jl-hurdman Date: Mon, 3 Aug 2026 22:39:59 +0800 Subject: [PATCH 2/4] fix(access): support globals endpoint in anyone access control Extend the `anyone` access function to handle global reads, which require a boolean return instead of a where clause. Detects global requests via routeParams/pathname and gates access on the `draft` query param so unpublished draft content isn't exposed to anonymous requests. --- src/access/anyone.ts | 16 +++++++++++++++- src/endpoints/durianpy-website-types/index.ts | 3 ++- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/access/anyone.ts b/src/access/anyone.ts index 2d54cc0..ca34619 100644 --- a/src/access/anyone.ts +++ b/src/access/anyone.ts @@ -1,16 +1,30 @@ import type { Access } from 'payload' -export const anyone: Access = ({ req: { user, headers } }) => { +export const anyone: Access = ({ req: { user, headers, query, routeParams, pathname } }) => { if (user) { return true } + const authorization = headers.get('authorization') if ( + authorization && process.env.DRAFT_SECRET_TOKEN && authorization === `Bearer ${process.env.DRAFT_SECRET_TOKEN}` ) { return true } + + const isGlobalRequest = + typeof routeParams?.global === 'string' || + (typeof pathname === 'string' && pathname.includes('/api/globals/')) + + // Global reads should return boolean access, not a where clause. + if (isGlobalRequest) { + const draftParam = query?.draft + const isDraftRequested = draftParam === true || draftParam === 'true' + return !isDraftRequested + } + return { or: [{ _status: { equals: 'published' } }, { _status: { exists: false } }], } diff --git a/src/endpoints/durianpy-website-types/index.ts b/src/endpoints/durianpy-website-types/index.ts index 57a5c26..e03a692 100644 --- a/src/endpoints/durianpy-website-types/index.ts +++ b/src/endpoints/durianpy-website-types/index.ts @@ -1,11 +1,12 @@ import fs from 'fs' import path from 'path' +import { COLLECTION_GROUPS } from '@/constants/collections' import type { Endpoint } from 'payload' import { getCollectionGroupItems } from '@/constants/collections' -const GROUP_SLUG = 'durianpy-website' as const +const GROUP_SLUG = COLLECTION_GROUPS.DURIANPY_WEBSITE function getCommentStartBefore(content: string, index: number): number { const commentEnd = content.lastIndexOf('*/', index) From fa42c07e294241ddafae26e4306a7a753ae7fd71 Mon Sep 17 00:00:00 2001 From: Anakin Skywalker Pactores Date: Tue, 18 Aug 2026 01:33:42 +0800 Subject: [PATCH 3/4] fix(types-endpoint): update to use sidebarGroup constants --- src/endpoints/durianpy-website-types/index.ts | 8 +++----- tests/int/api.int.spec.ts | 4 ++-- 2 files changed, 5 insertions(+), 7 deletions(-) diff --git a/src/endpoints/durianpy-website-types/index.ts b/src/endpoints/durianpy-website-types/index.ts index e03a692..87c5c65 100644 --- a/src/endpoints/durianpy-website-types/index.ts +++ b/src/endpoints/durianpy-website-types/index.ts @@ -1,12 +1,10 @@ import fs from 'fs' import path from 'path' -import { COLLECTION_GROUPS } from '@/constants/collections' +import { SIDEBAR_GROUPS, getSidebarGroupItems } from '@/constants/sidebarGroup' import type { Endpoint } from 'payload' -import { getCollectionGroupItems } from '@/constants/collections' - -const GROUP_SLUG = COLLECTION_GROUPS.DURIANPY_WEBSITE +const GROUP_SLUG = SIDEBAR_GROUPS.DURIANPY_WEBSITE function getCommentStartBefore(content: string, index: number): number { const commentEnd = content.lastIndexOf('*/', index) @@ -76,7 +74,7 @@ export const durianpyWebsiteTypesEndpoint: Endpoint = { 'utf-8', ) - const groupCollectionSlugs = [...getCollectionGroupItems(GROUP_SLUG)].sort((a, b) => + const groupCollectionSlugs = [...getSidebarGroupItems(GROUP_SLUG)].sort((a, b) => a.localeCompare(b), ) diff --git a/tests/int/api.int.spec.ts b/tests/int/api.int.spec.ts index 33fccbc..c324dfb 100644 --- a/tests/int/api.int.spec.ts +++ b/tests/int/api.int.spec.ts @@ -1,6 +1,6 @@ import { getPayload, Payload } from 'payload' import config from '@/payload.config' -import { GET as getDurianTypes } from '@/app/(payload)/api/durianpy-website-types/route' +import { durianpyWebsiteTypesEndpoint } from '@/endpoints/durianpy-website-types' import { describe, it, beforeAll, expect } from 'vitest' @@ -20,7 +20,7 @@ describe('API', () => { }) it('returns only durianpy-website collection interfaces', async () => { - const response = await getDurianTypes() + const response = await (durianpyWebsiteTypesEndpoint.handler as () => Promise)() expect(response.status).toBe(200) expect(response.headers.get('content-type')).toContain('text/plain') From 3ea3584f1fe49c71595dee1d8dc537320c5204eb Mon Sep 17 00:00:00 2001 From: Anakin Skywalker Pactores Date: Wed, 19 Aug 2026 09:09:50 +0800 Subject: [PATCH 4/4] chore: remove role field in service account and apply checkResourceAccess util to type endpoint --- src/access/admin.ts | 3 +- src/access/adminOrSelf.ts | 10 ++- src/access/checkResourceAccess.ts | 3 +- src/access/superAdmin.ts | 3 +- src/collections/ServiceAccounts.ts | 21 +---- src/collections/Users/index.ts | 5 +- src/endpoints/durianpy-website-types/index.ts | 80 +++++++++++-------- src/payload-types.ts | 2 - src/seed/admin/collections/ServiceAccounts.ts | 17 +++- tests/int/api.int.spec.ts | 48 ++++++++++- tests/unit/access/checkResourceAccess.spec.ts | 17 ++++ 11 files changed, 143 insertions(+), 66 deletions(-) diff --git a/src/access/admin.ts b/src/access/admin.ts index 0b030d6..ccef2e7 100644 --- a/src/access/admin.ts +++ b/src/access/admin.ts @@ -6,5 +6,6 @@ import { USER_ROLES } from '@/constants/userRoles' type isAuthenticated = (args: AccessArgs) => boolean export const admin: isAuthenticated = ({ req: { user } }) => { - return Boolean(user && user.role.includes(USER_ROLES.ADMIN)) + if (!user || !('role' in user) || !user.role) return false + return Boolean(user.role.includes(USER_ROLES.ADMIN)) } diff --git a/src/access/adminOrSelf.ts b/src/access/adminOrSelf.ts index 853dd99..c1612ac 100644 --- a/src/access/adminOrSelf.ts +++ b/src/access/adminOrSelf.ts @@ -3,8 +3,10 @@ import type { Access } from 'payload' export const adminOrSelf: Access = ({ req: { user } }) => { if (!user) return false - return ( - user.role.includes(USER_ROLES.ADMIN) || - user.role.includes(USER_ROLES.SUPER_ADMIN) || { id: { equals: user.id } } - ) + if ('role' in user && user.role) { + if (user.role.includes(USER_ROLES.ADMIN) || user.role.includes(USER_ROLES.SUPER_ADMIN)) { + return true + } + } + return { id: { equals: user.id } } } diff --git a/src/access/checkResourceAccess.ts b/src/access/checkResourceAccess.ts index 39b82e4..a21ee8f 100644 --- a/src/access/checkResourceAccess.ts +++ b/src/access/checkResourceAccess.ts @@ -35,7 +35,8 @@ export function checkResourceAccess( if (slugType === 'group') { const groupItems = getSidebarGroupItems(assignedSlug as SidebarGroupSlug) - isApplicable = groupItems.includes(resourceSlug as CollectionSlug) + isApplicable = + groupItems.includes(resourceSlug as CollectionSlug) || assignedSlug === resourceSlug } else if (slugType === 'collection') { isApplicable = assignedSlug === resourceSlug } diff --git a/src/access/superAdmin.ts b/src/access/superAdmin.ts index 5da5269..e13eb43 100644 --- a/src/access/superAdmin.ts +++ b/src/access/superAdmin.ts @@ -3,5 +3,6 @@ import type { AccessArgs } from 'payload' import type { User } from '@/payload-types' export const superAdmin = ({ req: { user } }: AccessArgs) => { - return Boolean(user && user.role.includes('super-admin')) + if (!user || !('role' in user) || !user.role) return false + return Boolean(user.role.includes('super-admin')) } diff --git a/src/collections/ServiceAccounts.ts b/src/collections/ServiceAccounts.ts index 79ba358..b4c6878 100644 --- a/src/collections/ServiceAccounts.ts +++ b/src/collections/ServiceAccounts.ts @@ -1,6 +1,6 @@ import type { CollectionConfig } from 'payload' import { anyAdmin } from '@/access/anyAdmin' -import { USER_ROLE_LABELS, USER_ROLES } from '@/constants/userRoles' +import { USER_ROLES } from '@/constants/userRoles' import { COLLECTIONS, COLLECTION_LABELS } from '@/constants/collections' import { adminOrSelf } from '@/access/adminOrSelf' import { getSidebarGroupLabel, SIDEBAR_GROUPS } from '@/constants/sidebarGroup' @@ -21,10 +21,10 @@ export const ServiceAccounts: CollectionConfig = { update: adminOrSelf, }, admin: { - defaultColumns: ['name', 'email', 'createdAt'], + defaultColumns: ['name', 'createdAt'], useAsTitle: 'name', hidden({ user }) { - if (!user) return true + if (!user || !user.role) return true return !user.role.includes(USER_ROLES.SUPER_ADMIN) && !user.role.includes(USER_ROLES.ADMIN) }, group: getSidebarGroupLabel(SIDEBAR_GROUPS.ADMIN), @@ -40,21 +40,6 @@ export const ServiceAccounts: CollectionConfig = { required: true, label: 'Service Account Name', }, - { - name: 'role', - type: 'select', - required: true, - saveToJWT: true, - hasMany: true, - access: { - create: ({ req }) => anyAdmin({ req }), - update: ({ req }) => anyAdmin({ req }), - }, - options: Object.values(USER_ROLES).map((value) => ({ - value, - label: USER_ROLE_LABELS[value], - })), - }, { name: 'permissions', type: 'array', diff --git a/src/collections/Users/index.ts b/src/collections/Users/index.ts index bbd7416..fe938eb 100644 --- a/src/collections/Users/index.ts +++ b/src/collections/Users/index.ts @@ -33,8 +33,9 @@ export const Users: CollectionConfig = { }, access: { admin: ({ req: { user } }) => { + if (!user || !('role' in user) || !user.role) return false const allowedRoles = Object.values(USER_ROLES) - return Boolean(user && user.role.some((role) => allowedRoles.includes(role))) + return Boolean(user.role.some((r) => allowedRoles.includes(r))) }, create: anyAdmin, delete: anyAdmin, @@ -48,7 +49,7 @@ export const Users: CollectionConfig = { defaultColumns: ['email'], useAsTitle: 'email', hidden({ user }) { - if (!user) return true + if (!user || !('role' in user) || !user.role) return true return !user.role.includes(USER_ROLES.SUPER_ADMIN) && !user.role.includes(USER_ROLES.ADMIN) }, group: getSidebarGroupLabel(SIDEBAR_GROUPS.ADMIN), diff --git a/src/endpoints/durianpy-website-types/index.ts b/src/endpoints/durianpy-website-types/index.ts index 87c5c65..038025b 100644 --- a/src/endpoints/durianpy-website-types/index.ts +++ b/src/endpoints/durianpy-website-types/index.ts @@ -1,8 +1,9 @@ import fs from 'fs' import path from 'path' +import { checkResourceAccess } from '@/access/checkResourceAccess' import { SIDEBAR_GROUPS, getSidebarGroupItems } from '@/constants/sidebarGroup' -import type { Endpoint } from 'payload' +import { APIError, type Endpoint } from 'payload' const GROUP_SLUG = SIDEBAR_GROUPS.DURIANPY_WEBSITE @@ -43,47 +44,63 @@ function extractInterfaceBlock(content: string, interfaceName: string): string | return null } -function getCollectionTypeMapFromConfig(typesFileContent: string): Map { +function getTypeMapFromConfig(typesFileContent: string): Map { const map = new Map() - const collectionsBlockMatch = typesFileContent.match(/collections:\s*\{([\s\S]*?)\n\s*\};/) - if (!collectionsBlockMatch) return map - - const collectionsBlock = collectionsBlockMatch[1] - const collectionEntryRegex = /^\s*(?:'([^']+)'|([A-Za-z0-9_-]+)):\s*([A-Za-z0-9_]+);\s*$/gm - - let entryMatch: RegExpExecArray | null - while ((entryMatch = collectionEntryRegex.exec(collectionsBlock)) !== null) { - const slug = entryMatch[1] ?? entryMatch[2] - const typeName = entryMatch[3] - - if (!slug || !typeName) continue - map.set(slug, typeName) + const extractEntriesFromBlock = (blockMatch: RegExpMatchArray | null) => { + if (!blockMatch) return + const blockContent = blockMatch[1] + const entryRegex = /^\s*(?:'([^']+)'|([A-Za-z0-9_-]+)):\s*([A-Za-z0-9_]+);\s*$/gm + let entryMatch: RegExpExecArray | null + while ((entryMatch = entryRegex.exec(blockContent)) !== null) { + const slug = entryMatch[1] ?? entryMatch[2] + const typeName = entryMatch[3] + if (slug && typeName) { + map.set(slug, typeName) + } + } } + extractEntriesFromBlock(typesFileContent.match(/collections:\s*\{([\s\S]*?)\n\s*\};/)) + extractEntriesFromBlock(typesFileContent.match(/globals:\s*\{([\s\S]*?)\n\s*\};/)) + return map } export const durianpyWebsiteTypesEndpoint: Endpoint = { method: 'get', path: '/durianpy-website-types', - handler: async () => { + handler: async (req) => { + if (!req.user && req.payload) { + const { user } = await req.payload.auth({ headers: req.headers }) + req.user = user + } + + if (!req.user) { + throw new APIError('Unauthorized', 401) + } + + const hasAccess = checkResourceAccess({ req }, GROUP_SLUG, 'read') + if (!hasAccess) { + throw new APIError('Forbidden', 403) + } + try { const fullTypes = fs.readFileSync( path.resolve(process.cwd(), 'src/payload-types.ts'), 'utf-8', ) - const groupCollectionSlugs = [...getSidebarGroupItems(GROUP_SLUG)].sort((a, b) => + const groupResourceSlugs = [...getSidebarGroupItems(GROUP_SLUG)].sort((a, b) => a.localeCompare(b), ) - const collectionTypeMap = getCollectionTypeMapFromConfig(fullTypes) + const resourceTypeMap = getTypeMapFromConfig(fullTypes) const missingTypeMappings: string[] = [] - const selectedTypeNames = groupCollectionSlugs + const selectedTypeNames = groupResourceSlugs .map((slug) => { - const typeName = collectionTypeMap.get(slug) + const typeName = resourceTypeMap.get(slug) if (!typeName) missingTypeMappings.push(slug) return typeName }) @@ -92,9 +109,9 @@ export const durianpyWebsiteTypesEndpoint: Endpoint = { if (missingTypeMappings.length > 0) { return new Response( JSON.stringify({ - error: 'Missing collection to type mappings in payload-types.ts', + error: 'Missing resource to type mappings in payload-types.ts', group: GROUP_SLUG, - missingCollectionSlugs: missingTypeMappings, + missingResourceSlugs: missingTypeMappings, }), { status: 500, @@ -126,7 +143,7 @@ export const durianpyWebsiteTypesEndpoint: Endpoint = { ) } - const typeSyncHeader = `// Auto-generated from src/payload-types.ts\n// Group: ${GROUP_SLUG}\n// Collections: ${groupCollectionSlugs.join(', ')}` + const typeSyncHeader = `// Auto-generated from src/payload-types.ts\n// Group: ${GROUP_SLUG}\n// Resources: ${groupResourceSlugs.join(', ')}` const payload = `${typeSyncHeader}\n\n${extractedInterfaces.join('\n\n')}` return new Response(payload, { @@ -136,15 +153,14 @@ export const durianpyWebsiteTypesEndpoint: Endpoint = { }, }) } catch (error) { - return new Response( - JSON.stringify({ - error: 'Failed to generate durianpy-website type sync payload', - message: error instanceof Error ? error.message : 'Unknown error', - }), - { - status: 500, - headers: { 'Content-Type': 'application/json' }, - }, + if (error instanceof APIError) { + throw error + } + throw new APIError( + error instanceof Error + ? error.message + : 'Failed to generate durianpy-website type sync payload', + 500, ) } }, diff --git a/src/payload-types.ts b/src/payload-types.ts index 77df88a..ed45087 100644 --- a/src/payload-types.ts +++ b/src/payload-types.ts @@ -390,7 +390,6 @@ export interface Sample { export interface ServiceAccount { id: string; name: string; - role: ('super-admin' | 'admin' | 'writer' | 'reader')[]; permissions?: | { resource: @@ -1035,7 +1034,6 @@ export interface SampleSelect { */ export interface ServiceAccountsSelect { name?: T; - role?: T; permissions?: | T | { diff --git a/src/seed/admin/collections/ServiceAccounts.ts b/src/seed/admin/collections/ServiceAccounts.ts index 337fc64..abcc153 100644 --- a/src/seed/admin/collections/ServiceAccounts.ts +++ b/src/seed/admin/collections/ServiceAccounts.ts @@ -1,6 +1,7 @@ import { Payload, PayloadRequest } from 'payload' import { COLLECTIONS } from '@/constants/collections' -import { USER_ROLES } from '@/constants/userRoles' +import { PERMISSIONS } from '@/constants/permissions' +import { SIDEBAR_GROUPS } from '@/constants/sidebarGroup' export async function seedServiceAccounts({ payload, @@ -12,12 +13,22 @@ export async function seedServiceAccounts({ const serviceAccounts = [ { name: 'GitHub Actions Bot', - role: [USER_ROLES.SUPER_ADMIN], + permissions: [ + { + resource: SIDEBAR_GROUPS.ADMIN, + accessLevel: PERMISSIONS.FULL_ACCESS, + }, + ], enableAPIKey: true, }, { name: 'Integration Service', - role: [USER_ROLES.ADMIN], + permissions: [ + { + resource: SIDEBAR_GROUPS.DURIANPY_WEBSITE, + accessLevel: PERMISSIONS.READ, + }, + ], enableAPIKey: true, }, ] diff --git a/tests/int/api.int.spec.ts b/tests/int/api.int.spec.ts index c324dfb..1f59bbd 100644 --- a/tests/int/api.int.spec.ts +++ b/tests/int/api.int.spec.ts @@ -19,8 +19,15 @@ describe('API', () => { expect(users).toBeDefined() }) - it('returns only durianpy-website collection interfaces', async () => { - const response = await (durianpyWebsiteTypesEndpoint.handler as () => Promise)() + it('returns only durianpy-website collection interfaces when authorized via admin role', async () => { + const mockReq = { + user: { + role: ['admin'], + }, + } + const response = await ( + durianpyWebsiteTypesEndpoint.handler as (req: unknown) => Promise + )(mockReq) expect(response.status).toBe(200) expect(response.headers.get('content-type')).toContain('text/plain') @@ -36,4 +43,41 @@ describe('API', () => { expect(body).not.toContain('export interface Media') expect(body).not.toContain('export interface Category') }) + + it('returns only durianpy-website collection interfaces when authorized via group read permission', async () => { + const mockReq = { + user: { + permissions: [ + { + resource: 'durianpy-website', + accessLevel: 'read', + }, + ], + }, + } + const response = await ( + durianpyWebsiteTypesEndpoint.handler as (req: unknown) => Promise + )(mockReq) + + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toContain('text/plain') + }) + + it('rejects unauthenticated access to website collection interfaces', async () => { + const unauthReq = {} + await expect( + (durianpyWebsiteTypesEndpoint.handler as (req: unknown) => Promise)(unauthReq), + ).rejects.toThrow('Unauthorized') + }) + + it('rejects forbidden access when user lacks read permission on website group', async () => { + const forbiddenReq = { + user: { + permissions: [], + }, + } + await expect( + (durianpyWebsiteTypesEndpoint.handler as (req: unknown) => Promise)(forbiddenReq), + ).rejects.toThrow('Forbidden') + }) }) diff --git a/tests/unit/access/checkResourceAccess.spec.ts b/tests/unit/access/checkResourceAccess.spec.ts index fd7a1be..188b6a4 100644 --- a/tests/unit/access/checkResourceAccess.spec.ts +++ b/tests/unit/access/checkResourceAccess.spec.ts @@ -90,6 +90,23 @@ describe('checkResourceAccess', () => { ).toBe(true) }) + it('should return true if checking group-level access directly and permission grants the access type', () => { + vi.mocked(anyAdmin).mockReturnValue(false) + const args = { + req: { + user: { + id: 1, + permissions: [ + { resource: SIDEBAR_GROUPS.DURIANPY_WEBSITE, accessLevel: PERMISSIONS.READ }, + ], + }, + }, + } as unknown as AccessArgs + expect(checkResourceAccess(args, SIDEBAR_GROUPS.DURIANPY_WEBSITE, ACCESS_TYPES.READ)).toBe( + true, + ) + }) + it('should return false if group contains the resource but access level does not grant the access type', () => { vi.mocked(anyAdmin).mockReturnValue(false) const args = {