[FE-3496] feat(studio): hide unexposed tables from Data API docs (#46508)

The autogenerated Data API docs listed every table and database function
from the PostgREST OpenAPI spec, even ones that aren't actually
accessible via the Data API (i.e. with grants revoked). This filters the
docs down to only the entities that are exposed, and surfaces a count of
the excluded ones with a link to enable them.

This applies to **both** autogenerated docs surfaces:
- the **API Docs side panel** (the slide-over opened from the API docs
button), and
- the **full-page Data API docs** at `/integrations/data_api/docs`.

<img width="259" height="272" alt="Screenshot 2026-06-01 at 5 48 21 PM"
src="https://github.com/user-attachments/assets/d2af86f2-5436-4e94-8295-83ecc74a77d9"
/>

**Changed:**
- Both docs UIs now only list tables and functions that have Data API
access (any `anon`/`authenticated`/`service_role` grant). Fully-revoked
entities are hidden.
- Side panel: both the sidebar list and the drilled-in resource picker
are filtered.
- Full page: the menu's Tables/Functions groups are filtered, with a
footer note under each.

**Added:**
- A footer under each list — "N table(s)/function(s) not exposed via
**Data API**" — linking to Data API settings
(`/integrations/data_api/settings`) so the entity can be granted access.
- One-shot `useExposedTablesQuery` / `useExposedFunctionsQuery` hooks
reusing the same granted/custom/revoked SQL as the Data API settings
page (no new SQL).
- Pure, unit-tested `partitionExposedDocsEntities()` helper (fails open
if grant status hasn't loaded / errors, so docs are never blanked).
- Optional `footer` slot on `ProductMenuGroup` (rendered by `DocsMenu`)
so the full-page menu can show the not-exposed note under a group.

**Note on the "all" queries:** the new `useExposedTablesQuery` /
`useExposedFunctionsQuery` fetch the full grant-status list in a single
request (rather than paginating like the Data API settings page does).
This is deliberate — the docs sections aren't paginated and render every
entity from the OpenAPI spec at once, so we need the complete status set
to cross-reference against. Ideally we'd refactor the docs to be
paginated in future, at which point these queries should move to a
paginated approach too; until then, the one-shot "all" fetch is what
matches the current (unpaginated) docs behavior.

## To test

- On a project, revoke a `public` table's Data API access (Data API
settings → uncheck it)
- Open the **full-page** docs at `/integrations/data_api/docs`: the
table should no longer appear under Tables and Views, and you should see
"1 table not exposed via Data API" under that menu group
- Open the **API Docs side panel** and expand Tables and Views: same
behavior
- Click the "Data API" link → goes to Data API settings (closes the side
panel if open)
- Same for a database function under Functions
- Tables/functions that are still granted (or have custom/partial
grants) should remain visible

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Data API docs now reflect actual exposure: tables/functions not
exposed by permissions are hidden and counted.
* Sections display footer indicators with counts of hidden entities and
links to Data API settings.
* Navigation lists and docs menu updated to show only exposed entities
and the new "not exposed" cues.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com>
This commit is contained in:
Alaister YoungandAlaister Young authored and GitHub committed 2026-06-02 23:36:14 +10:00
1 parent ac79b1bc81
commit ea695fdfa9
13 files changed
+441 -41

No files matched your search

@@ -75,6 +75,7 @@ export const DocsMenu = ({
</Link>
)
})}
{group.footer}
</div>
</div>
))}
@@ -9,7 +9,11 @@ import { DocsMenu } from '@/components/interfaces/Integrations/DataApi/DocsMenu'
import { DocsMobileNav } from '@/components/interfaces/Integrations/DataApi/DocsMobileNav'
import { DocView } from '@/components/interfaces/Integrations/DataApi/DocView'
import { generateDocsMenu, getActivePage } from '@/components/layouts/DocsLayout/DocsLayout.utils'
import { NotExposedEntitiesIndicator } from '@/components/ui/NotExposedEntitiesIndicator'
import { useOpenAPISpecQuery } from '@/data/open-api/api-spec-query'
import { partitionExposedDocsEntities } from '@/data/privileges/exposed-docs-entities'
import { useExposedFunctionsQuery } from '@/data/privileges/exposed-functions-query'
import { useExposedTablesQuery } from '@/data/privileges/exposed-tables-query'
import { useIsDataApiEnabled } from '@/hooks/misc/useIsDataApiEnabled'
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
@@ -27,21 +31,42 @@ export const DataApiDocsTab = () => {
const { isEnabled, isPending: isConfigLoading } = useIsDataApiEnabled({ projectRef })
const { data: openApiSpec } = useOpenAPISpecQuery(
{ projectRef },
{
enabled: !!projectRef && !isPaused && isEnabled,
}
const dataApiEnabled = !!projectRef && !isPaused && isEnabled
const { data: openApiSpec } = useOpenAPISpecQuery({ projectRef }, { enabled: dataApiEnabled })
// Cross-reference the spec against grant status so tables/functions that exist
// in the spec but aren't actually exposed to the Data API are hidden + counted.
const { data: exposedTables } = useExposedTablesQuery(
{ projectRef, connectionString: project?.connectionString },
{ enabled: dataApiEnabled }
)
const { data: exposedFunctions } = useExposedFunctionsQuery(
{ projectRef, connectionString: project?.connectionString },
{ enabled: dataApiEnabled }
)
const tableNames = useMemo(
() => (openApiSpec?.tables ?? []).map((table) => table.name),
[openApiSpec]
)
const functionNames = useMemo(
() => (openApiSpec?.functions ?? []).map((fn) => fn.name),
[openApiSpec]
)
const { tableNames, excludedTablesCount } = useMemo(() => {
const { visibleEntities, excludedCount } = partitionExposedDocsEntities(
openApiSpec?.tables ?? [],
exposedTables
)
return {
tableNames: visibleEntities.map((table) => table.name),
excludedTablesCount: excludedCount,
}
}, [openApiSpec?.tables, exposedTables])
const { functionNames, excludedFunctionsCount } = useMemo(() => {
const { visibleEntities, excludedCount } = partitionExposedDocsEntities(
openApiSpec?.functions ?? [],
exposedFunctions
)
return {
functionNames: visibleEntities.map((fn) => fn.name),
excludedFunctionsCount: excludedCount,
}
}, [openApiSpec?.functions, exposedFunctions])
const activePage = useMemo(() => getActivePage({ page, resource, rpc }), [page, resource, rpc])
@@ -49,8 +74,51 @@ export const DataApiDocsTab = () => {
const menu = useMemo(() => {
if (!projectRef) return []
return generateDocsMenu(projectRef, tableNames, functionNames, { authEnabled }, docsBasePath)
}, [projectRef, tableNames, functionNames, authEnabled, docsBasePath])
const groups = generateDocsMenu(
projectRef,
tableNames,
functionNames,
{ authEnabled },
docsBasePath
)
return groups.map((group) => {
if (group.key === 'tables' && excludedTablesCount > 0) {
return {
...group,
footer: (
<NotExposedEntitiesIndicator
count={excludedTablesCount}
entityNoun="table"
entityNounPlural="tables"
className="pt-1"
/>
),
}
}
if (group.key === 'functions' && excludedFunctionsCount > 0) {
return {
...group,
footer: (
<NotExposedEntitiesIndicator
count={excludedFunctionsCount}
entityNoun="function"
entityNounPlural="functions"
className="pt-1"
/>
),
}
}
return group
})
}, [
projectRef,
tableNames,
functionNames,
authEnabled,
docsBasePath,
excludedTablesCount,
excludedFunctionsCount,
])
if (isConfigLoading) {
return (
@@ -8,9 +8,10 @@ import { ShimmeringLoader } from 'ui-patterns'
import { navigateToSection } from './Content/Content.utils'
import { API_DOCS_CATEGORIES, DOCS_CONTENT, DOCS_MENU } from './ProjectAPIDocs.constants'
import { useApiDocsFunctions, useApiDocsTables } from './useApiDocsEntities'
import { InfiniteListDefault, type RowComponentBaseProps } from '@/components/ui/InfiniteList'
import { NotExposedEntitiesIndicator } from '@/components/ui/NotExposedEntitiesIndicator'
import { useEdgeFunctionsQuery } from '@/data/edge-functions/edge-functions-query'
import { useOpenAPISpecQuery } from '@/data/open-api/api-spec-query'
import { usePaginatedBucketsQuery, type Bucket } from '@/data/storage/buckets-query'
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
import { BASE_PATH, DOCS_URL } from '@/lib/constants'
@@ -184,20 +185,15 @@ const Subsections = ({ category }: SubsectionsProps): ReactNode => {
}
const TablesSubsections = (): ReactNode => {
const { ref } = useParams()
const snap = useAppStateSnapshot()
const { data, isLoading } = useOpenAPISpecQuery(
{ projectRef: ref },
{ staleTime: 1000 * 60 * 10 }
)
const tables = data?.tables ?? []
const { visibleEntities: tables, excludedCount, isLoading } = useApiDocsTables()
// TODO: handle infinite loading of tables
return (
<>
{isLoading && <LoadingIndicator />}
{tables.length > 0 && <Separator />}
{(tables.length > 0 || excludedCount > 0) && <Separator />}
{tables.map((table) => (
<button
key={table.name}
@@ -207,25 +203,26 @@ const TablesSubsections = (): ReactNode => {
{table.name}
</button>
))}
<NotExposedEntitiesIndicator
count={excludedCount}
entityNoun="table"
entityNounPlural="tables"
onNavigate={() => snap.setShowProjectApiDocs(false)}
/>
</>
)
}
const DbFunctionsSubsections = (): ReactNode => {
const { ref } = useParams()
const snap = useAppStateSnapshot()
const { data, isLoading } = useOpenAPISpecQuery(
{ projectRef: ref },
{ staleTime: 1000 * 60 * 10 }
)
const functions = data?.functions ?? []
const { visibleEntities: functions, excludedCount, isLoading } = useApiDocsFunctions()
// TODO: handle virtualization of DB functions
return (
<>
{isLoading && <LoadingIndicator />}
{functions.length > 0 && <Separator />}
{(functions.length > 0 || excludedCount > 0) && <Separator />}
{functions.map((fn) => (
<button
key={fn.name}
@@ -237,6 +234,12 @@ const DbFunctionsSubsections = (): ReactNode => {
{fn.name}
</button>
))}
<NotExposedEntitiesIndicator
count={excludedCount}
entityNoun="function"
entityNounPlural="functions"
onNavigate={() => snap.setShowProjectApiDocs(false)}
/>
</>
)
}
@@ -5,19 +5,14 @@ import { API_DOCS_CATEGORIES } from './ProjectAPIDocs.constants'
import { SecondLevelNavLayout, type MenuItemFilter } from './SecondLevelNav.Layout'
import { ResourcePickerList } from './SecondLevelNav.ResourcePicker'
import { StorageResourceList } from './SecondLevelNav.StoragePicker'
import { useApiDocsFunctions, useApiDocsTables } from './useApiDocsEntities'
import { useEdgeFunctionsQuery } from '@/data/edge-functions/edge-functions-query'
import { useOpenAPISpecQuery } from '@/data/open-api/api-spec-query'
import { useBucketInfoQueryPreferCached } from '@/data/storage/buckets-query'
import { DOCS_URL } from '@/lib/constants'
import { useAppStateSnapshot } from '@/state/app-state'
const OPEN_API_SPEC_STALE_TIME = 1000 * 60 * 10
const EntitiesSecondLevelNav = () => {
const { ref } = useParams()
const { data } = useOpenAPISpecQuery({ projectRef: ref }, { staleTime: OPEN_API_SPEC_STALE_TIME })
const tables = data?.tables ?? []
const { visibleEntities: tables } = useApiDocsTables()
return (
<SecondLevelNavLayout
@@ -32,10 +27,7 @@ const EntitiesSecondLevelNav = () => {
}
const StoredProceduresSecondLevelNav = () => {
const { ref } = useParams()
const { data } = useOpenAPISpecQuery({ projectRef: ref }, { staleTime: OPEN_API_SPEC_STALE_TIME })
const functions = data?.functions ?? []
const { visibleEntities: functions } = useApiDocsFunctions()
return (
<SecondLevelNavLayout
@@ -0,0 +1,61 @@
import { useMemo } from 'react'
import { useOpenAPISpecQuery } from '@/data/open-api/api-spec-query'
import { partitionExposedDocsEntities } from '@/data/privileges/exposed-docs-entities'
import { useExposedFunctionsQuery } from '@/data/privileges/exposed-functions-query'
import { useExposedTablesQuery } from '@/data/privileges/exposed-tables-query'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
const OPEN_API_SPEC_STALE_TIME = 1000 * 60 * 10
/**
* Tables shown in the autogenerated Data API docs, with unexposed (revoked)
* tables filtered out and counted.
*/
export const useApiDocsTables = () => {
const { data: project } = useSelectedProjectQuery()
const projectRef = project?.ref
const { data: spec, isLoading } = useOpenAPISpecQuery(
{ projectRef },
{ staleTime: OPEN_API_SPEC_STALE_TIME }
)
const { data: exposedTables } = useExposedTablesQuery({
projectRef,
connectionString: project?.connectionString,
})
return useMemo(
() => ({
...partitionExposedDocsEntities(spec?.tables ?? [], exposedTables),
isLoading,
}),
[spec?.tables, exposedTables, isLoading]
)
}
/**
* Database functions shown in the autogenerated Data API docs, with unexposed
* (revoked) functions filtered out and counted.
*/
export const useApiDocsFunctions = () => {
const { data: project } = useSelectedProjectQuery()
const projectRef = project?.ref
const { data: spec, isLoading } = useOpenAPISpecQuery(
{ projectRef },
{ staleTime: OPEN_API_SPEC_STALE_TIME }
)
const { data: exposedFunctions } = useExposedFunctionsQuery({
projectRef,
connectionString: project?.connectionString,
})
return useMemo(
() => ({
...partitionExposedDocsEntities(spec?.functions ?? [], exposedFunctions),
isLoading,
}),
[spec?.functions, exposedFunctions, isLoading]
)
}
@@ -48,6 +48,7 @@ export const generateDocsMenu = (
},
{
title: 'Tables and Views',
key: 'tables',
items: [
{
name: 'Introduction',
@@ -67,6 +68,7 @@ export const generateDocsMenu = (
},
{
title: 'Functions',
key: 'functions',
items: [
{
name: 'Introduction',
@@ -0,0 +1,48 @@
import { useParams } from 'common'
import Link from 'next/link'
import { type ReactNode } from 'react'
import { cn } from 'ui'
interface NotExposedEntitiesIndicatorProps {
count: number
/** Singular noun, e.g. "table" or "function" */
entityNoun: string
/** Plural noun, e.g. "tables" or "functions" */
entityNounPlural: string
/** Optional callback fired when the link is clicked (e.g. to close a panel) */
onNavigate?: () => void
/** Overrides the default layout classes (padding/size) — color/hover styling is always applied */
className?: string
}
/**
* Quiet link shown beneath a docs entity list when some entities are hidden
* because they aren't exposed to the Data API. Styled like a dimmer nav item and
* links to the Data API settings where the user can grant access.
*/
export const NotExposedEntitiesIndicator = ({
count,
entityNoun,
entityNounPlural,
onNavigate,
className,
}: NotExposedEntitiesIndicatorProps): ReactNode => {
const { ref } = useParams()
if (count <= 0) return null
const noun = count === 1 ? entityNoun : entityNounPlural
return (
<Link
href={`/project/${ref}/integrations/data_api/settings`}
onClick={onNavigate}
className={cn(
'block text-foreground-lighter transition hover:text-foreground',
className ?? 'px-4 pt-2 text-sm'
)}
>
{count} {noun} not exposed
</Link>
)
}
@@ -10,6 +10,8 @@ export interface ProductMenuGroup {
name?: string
items: ProductMenuGroupItem[]
link?: string
/** Optional node rendered after the group's items (e.g. a footer note) */
footer?: ReactNode
}
export interface ProductMenuGroupItem {
@@ -0,0 +1,73 @@
import { describe, expect, it } from 'vitest'
import { partitionExposedDocsEntities, type ExposedEntityStatus } from './exposed-docs-entities'
type SpecEntity = { name: string }
type StatusItem = { name: string; status: ExposedEntityStatus }
const spec = (...names: string[]): SpecEntity[] => names.map((name) => ({ name }))
const status = (name: string, status: ExposedEntityStatus): StatusItem => ({ name, status })
describe('partitionExposedDocsEntities', () => {
it('fails open when statuses are undefined (not yet loaded)', () => {
const specEntities = spec('todos', 'profiles')
const result = partitionExposedDocsEntities(specEntities, undefined)
expect(result.visibleEntities).toEqual(specEntities)
expect(result.excludedCount).toBe(0)
})
it('keeps all entities when every entity is fully granted', () => {
const result = partitionExposedDocsEntities(spec('todos', 'profiles'), [
status('todos', 'granted'),
status('profiles', 'granted'),
])
expect(result.visibleEntities.map((e) => e.name)).toEqual(['todos', 'profiles'])
expect(result.excludedCount).toBe(0)
})
it('hides and counts revoked entities', () => {
const result = partitionExposedDocsEntities(spec('todos', 'secret'), [
status('todos', 'granted'),
status('secret', 'revoked'),
])
expect(result.visibleEntities.map((e) => e.name)).toEqual(['todos'])
expect(result.excludedCount).toBe(1)
})
it('treats custom (partial) grants as exposed', () => {
const result = partitionExposedDocsEntities(spec('partial'), [status('partial', 'custom')])
expect(result.visibleEntities.map((e) => e.name)).toEqual(['partial'])
expect(result.excludedCount).toBe(0)
})
it('keeps an entity when a same-named entity in another schema is accessible', () => {
// e.g. public.foo (granted) vs private.foo (revoked) — both surface as `foo`
const result = partitionExposedDocsEntities(spec('foo'), [
status('foo', 'revoked'),
status('foo', 'granted'),
])
expect(result.visibleEntities.map((e) => e.name)).toEqual(['foo'])
expect(result.excludedCount).toBe(0)
})
it('fails open per-entity when a spec entity has no matching status', () => {
const result = partitionExposedDocsEntities(spec('unknown'), [status('other', 'revoked')])
expect(result.visibleEntities.map((e) => e.name)).toEqual(['unknown'])
expect(result.excludedCount).toBe(0)
})
it('only counts revoked entities that appear in the spec', () => {
const result = partitionExposedDocsEntities(spec('todos'), [
status('todos', 'granted'),
status('revoked_not_in_spec', 'revoked'),
])
expect(result.visibleEntities.map((e) => e.name)).toEqual(['todos'])
expect(result.excludedCount).toBe(0)
})
it('handles an empty spec list', () => {
const result = partitionExposedDocsEntities([], [status('todos', 'revoked')])
expect(result.visibleEntities).toEqual([])
expect(result.excludedCount).toBe(0)
})
})
@@ -0,0 +1,55 @@
export type ExposedEntityStatus = 'granted' | 'revoked' | 'custom'
type ExposedEntityStatusItem = {
name: string
status: ExposedEntityStatus
}
export type FilteredDocsEntities<T> = {
visibleEntities: T[]
excludedCount: number
}
/**
* Splits the entities listed in the PostgREST OpenAPI spec into those that are
* actually accessible via the Data API and those that are not.
*
* An entity is considered exposed when it has at least one API-role grant
* (`granted` or `custom`). It is excluded only when every entry matching its
* name is `revoked` (no API role has any privilege) — mirroring the
* granted/custom/revoked classification used by the Data API settings page.
*
* Fails open: when the grant statuses haven't loaded (or errored), all spec
* entities are returned so the docs are never blanked out.
*/
export function partitionExposedDocsEntities<T extends { name: string }>(
specEntities: T[],
exposedStatuses: ExposedEntityStatusItem[] | undefined
): FilteredDocsEntities<T> {
if (!exposedStatuses) {
return { visibleEntities: specEntities, excludedCount: 0 }
}
const accessibleNames = new Set<string>()
const revokedNames = new Set<string>()
for (const { name, status } of exposedStatuses) {
if (status === 'revoked') {
revokedNames.add(name)
} else {
accessibleNames.add(name)
}
}
const visibleEntities: T[] = []
let excludedCount = 0
for (const entity of specEntities) {
const isExcluded = revokedNames.has(entity.name) && !accessibleNames.has(entity.name)
if (isExcluded) {
excludedCount += 1
} else {
visibleEntities.push(entity)
}
}
return { visibleEntities, excludedCount }
}
@@ -0,0 +1,46 @@
import { useQuery } from '@tanstack/react-query'
import {
getExposedFunctions,
type ExposedFunction,
type ExposedFunctionsError,
type ExposedFunctionsVariables,
} from './exposed-functions-infinite-query'
import { privilegeKeys } from './keys'
import type { UseCustomQueryOptions } from '@/types'
// The API Docs panel renders every function without virtualization, so we fetch
// the full grant-status list in a single request rather than paginating.
const ALL_EXPOSED_ENTITIES_LIMIT = 100_000
export type ExposedFunctionsAllData = ExposedFunction[]
async function getAllExposedFunctions(
{ projectRef, connectionString }: ExposedFunctionsVariables,
signal?: AbortSignal
): Promise<ExposedFunctionsAllData> {
const { functions } = await getExposedFunctions(
{ projectRef, connectionString, page: 0, limit: ALL_EXPOSED_ENTITIES_LIMIT },
signal
)
return functions
}
/**
* Returns the grant status (`granted` | `custom` | `revoked`) for every database
* function, used to filter unexposed functions out of the autogenerated Data API
* docs.
*/
export const useExposedFunctionsQuery = <TData = ExposedFunctionsAllData>(
{ projectRef, connectionString }: ExposedFunctionsVariables,
{
enabled = true,
...options
}: UseCustomQueryOptions<ExposedFunctionsAllData, ExposedFunctionsError, TData> = {}
) =>
useQuery<ExposedFunctionsAllData, ExposedFunctionsError, TData>({
queryKey: privilegeKeys.exposedFunctionsAll(projectRef),
queryFn: ({ signal }) => getAllExposedFunctions({ projectRef, connectionString }, signal),
enabled: enabled && typeof projectRef !== 'undefined',
...options,
})
@@ -0,0 +1,45 @@
import { useQuery } from '@tanstack/react-query'
import {
getExposedTables,
type ExposedTable,
type ExposedTablesError,
type ExposedTablesVariables,
} from './exposed-tables-infinite-query'
import { privilegeKeys } from './keys'
import type { UseCustomQueryOptions } from '@/types'
// The API Docs panel renders every table without virtualization, so we fetch
// the full grant-status list in a single request rather than paginating.
const ALL_EXPOSED_ENTITIES_LIMIT = 100_000
export type ExposedTablesAllData = ExposedTable[]
async function getAllExposedTables(
{ projectRef, connectionString }: ExposedTablesVariables,
signal?: AbortSignal
): Promise<ExposedTablesAllData> {
const { tables } = await getExposedTables(
{ projectRef, connectionString, page: 0, limit: ALL_EXPOSED_ENTITIES_LIMIT },
signal
)
return tables
}
/**
* Returns the grant status (`granted` | `custom` | `revoked`) for every table,
* used to filter unexposed tables out of the autogenerated Data API docs.
*/
export const useExposedTablesQuery = <TData = ExposedTablesAllData>(
{ projectRef, connectionString }: ExposedTablesVariables,
{
enabled = true,
...options
}: UseCustomQueryOptions<ExposedTablesAllData, ExposedTablesError, TData> = {}
) =>
useQuery<ExposedTablesAllData, ExposedTablesError, TData>({
queryKey: privilegeKeys.exposedTablesAll(projectRef),
queryFn: ({ signal }) => getAllExposedTables({ projectRef, connectionString }, signal),
enabled: enabled && typeof projectRef !== 'undefined',
...options,
})
+4
View File
@@ -11,6 +11,10 @@ export const privilegeKeys = {
'exposed-tables-infinite',
...(search ? ([{ search }] as const) : []),
] as const,
exposedTablesAll: (projectRef: string | undefined) =>
['projects', projectRef, 'privileges', 'exposed-tables-all'] as const,
exposedFunctionsAll: (projectRef: string | undefined) =>
['projects', projectRef, 'privileges', 'exposed-functions-all'] as const,
exposedTableCounts: (projectRef: string | undefined, selectedSchemas?: string[]) =>
[
'projects',