mirror of
https://github.com/supabase/supabase.git
synced 2026-10-08 19:05:06 +03:00
Scoped PAT: refactor the scope permission map to be computed from live
OPEN API specs
This commit is contained in:
19 files changed
+639
-122
No files matched your search
+66
@@ -0,0 +1,66 @@
|
||||
import { describe, expect, test } from 'vitest'
|
||||
|
||||
import { getEndpointsAndMCPToolsForAPI } from './buildAPIPermissionScopeMap'
|
||||
|
||||
describe('getEndpointsAndMCPToolsForAPI', () => {
|
||||
const openAPISpecs = {
|
||||
paths: {
|
||||
'/v1/projects/{ref}/database/migrations': {
|
||||
get: {
|
||||
security: [
|
||||
{
|
||||
fga_permissions: ['database_migrations_read'],
|
||||
},
|
||||
],
|
||||
},
|
||||
post: {
|
||||
security: [
|
||||
{
|
||||
fga_permissions: ['database_migrations_write'],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
'/v1/projects/{ref}/database/migrations/{version}': {
|
||||
patch: {
|
||||
security: [
|
||||
{
|
||||
fga_permissions: ['database_migrations_write'],
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const mcp_tools = {
|
||||
list_migrations: ['database_migrations_read'],
|
||||
apply_migration: ['database_migrations_write', 'database_write'],
|
||||
}
|
||||
|
||||
test('returns an object with permissions as keys and endpoints and mcp_tools as values', () => {
|
||||
const permissionScopeMap = getEndpointsAndMCPToolsForAPI(openAPISpecs, mcp_tools)
|
||||
|
||||
expect(permissionScopeMap).toEqual({
|
||||
scopes: {
|
||||
database_migrations_read: {
|
||||
endpoints: ['GET /v1/projects/{ref}/database/migrations'],
|
||||
mcp_tools: ['list_migrations'],
|
||||
},
|
||||
database_migrations_write: {
|
||||
endpoints: [
|
||||
'POST /v1/projects/{ref}/database/migrations',
|
||||
'PATCH /v1/projects/{ref}/database/migrations/{version}',
|
||||
],
|
||||
mcp_tools: ['apply_migration'],
|
||||
},
|
||||
},
|
||||
endpoints: {
|
||||
'GET /v1/projects/{ref}/database/migrations': ['database_migrations_read'],
|
||||
'POST /v1/projects/{ref}/database/migrations': ['database_migrations_write'],
|
||||
'PATCH /v1/projects/{ref}/database/migrations/{version}': ['database_migrations_write'],
|
||||
},
|
||||
mcp_tools,
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,137 @@
|
||||
import lodash from 'lodash'
|
||||
import z from 'zod'
|
||||
|
||||
// We don't have an OpenAPI that describes mcp tools security requirements so
|
||||
// we have this json file that must be updated when they change
|
||||
import mcp_tools_permissions_map from './mcp_tools_permissions_map.json'
|
||||
import {
|
||||
EndpointMap,
|
||||
McpMap,
|
||||
PermissionScopeMap,
|
||||
ScopeMap,
|
||||
} from '@/data/scoped-access-tokens/permission-scope-map-query'
|
||||
|
||||
// Default lodash merge does not correctly merge arrays so this custom merger fixes it
|
||||
function mergeArrays(objValue: unknown, srcValue: unknown) {
|
||||
if (Array.isArray(objValue) && Array.isArray(srcValue)) {
|
||||
return objValue.concat(srcValue)
|
||||
}
|
||||
}
|
||||
|
||||
export const buildAPIPermissionScopeMap = async (): Promise<PermissionScopeMap> => {
|
||||
// Get the permissions map for the API v1
|
||||
const apiV1SpecsJSON = await fetchAPIPermissionScope('v1')
|
||||
const apiV1Specs = API_SPECS_SCHEMA.parse(apiV1SpecsJSON)
|
||||
const permissionScopeMapV1 = getEndpointsAndMCPToolsForAPI(apiV1Specs, mcp_tools_permissions_map)
|
||||
|
||||
// Get the permissions map for the API v2
|
||||
const apiV2SpecsJSON = await fetchAPIPermissionScope('v2')
|
||||
const apiV2Specs = API_SPECS_SCHEMA.parse(apiV2SpecsJSON)
|
||||
const permissionScopeMapV2 = getEndpointsAndMCPToolsForAPI(apiV2Specs, mcp_tools_permissions_map)
|
||||
|
||||
const permissionScope = lodash.mergeWith(
|
||||
{},
|
||||
permissionScopeMapV1,
|
||||
permissionScopeMapV2,
|
||||
mergeArrays
|
||||
)
|
||||
|
||||
return permissionScope
|
||||
}
|
||||
|
||||
// OPEN API specs look like this (only kept the parts we're interested in):
|
||||
// {
|
||||
// "paths": {
|
||||
// "/v2/projects/{ref}/analytics/log-drains": {
|
||||
// "get": {
|
||||
// "security": [
|
||||
// {
|
||||
// "fga_permissions": [
|
||||
// "analytics_config_read"
|
||||
// ]
|
||||
// }
|
||||
// ]
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
export const getEndpointsAndMCPToolsForAPI = (
|
||||
apiSpecs: z.output<typeof API_SPECS_SCHEMA>,
|
||||
mcp_tools: McpMap
|
||||
): PermissionScopeMap => {
|
||||
const scopes: ScopeMap = {}
|
||||
const endpoints: EndpointMap = {}
|
||||
|
||||
// Loop over each API path to assign endpoints to their scopes and
|
||||
// scopes to their endpoints
|
||||
Object.entries(apiSpecs.paths).forEach(([path, methods]) => {
|
||||
// Loop over each API path method (get, post, etc.)
|
||||
Object.entries(methods).forEach(([method, methodSpecs]) => {
|
||||
if (methodSpecs.security == null) return
|
||||
|
||||
methodSpecs.security.forEach((security) => {
|
||||
if (security.fga_permissions == null) return
|
||||
|
||||
security.fga_permissions.forEach((permission) => {
|
||||
const endpoint = `${method.toUpperCase()} ${path}`
|
||||
|
||||
// Initialize scope object if needed
|
||||
scopes[permission] = scopes[permission] || { endpoints: [], mcp_tools: [] }
|
||||
|
||||
// Initialize endpoints array if needed
|
||||
endpoints[endpoint] = endpoints[endpoint] || []
|
||||
|
||||
if (!scopes[permission].endpoints.includes(endpoint)) {
|
||||
scopes[permission].endpoints.push(endpoint)
|
||||
}
|
||||
if (!endpoints[endpoint].includes(permission)) {
|
||||
endpoints[endpoint].push(permission)
|
||||
}
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// Assign the mcp tools to their scopes
|
||||
Object.entries(mcp_tools).forEach(([mcpTool, toolScopes]) => {
|
||||
toolScopes.forEach((toolScope) => {
|
||||
if (scopes[toolScope] && !scopes[toolScope].mcp_tools.includes(mcpTool)) {
|
||||
scopes[toolScope].mcp_tools.push(mcpTool)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
return { scopes, endpoints, mcp_tools }
|
||||
}
|
||||
|
||||
const fetchAPIPermissionScope = async (version: 'v1' | 'v2') => {
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_API_DOMAIN}/api/${version}-json`, {
|
||||
method: 'get',
|
||||
})
|
||||
if (response.ok) {
|
||||
return response.json()
|
||||
}
|
||||
throw new Error(`Unable to fetch scoped token permission scope: ${response.statusText}`)
|
||||
}
|
||||
|
||||
// Simplified OPEN API specs schemas that only defines what we care about for scoped tokens
|
||||
|
||||
const OPEN_API_PATH_METHOD_SCHEMA = z.object({
|
||||
security: z
|
||||
.array(
|
||||
z.object({
|
||||
fga_permissions: z.array(z.string()).optional(),
|
||||
})
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
|
||||
const API_SPECS_SCHEMA = z.object({
|
||||
paths: z.record(
|
||||
z.string(),
|
||||
z.record(
|
||||
z.enum(['get', 'post', 'put', 'patch', 'delete', 'options', 'head', 'trace']),
|
||||
OPEN_API_PATH_METHOD_SCHEMA
|
||||
)
|
||||
),
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"apply_migration": ["database_migrations_write", "database_write"],
|
||||
"create_branch": ["branching_development_create", "branching_production_create"],
|
||||
"create_project": ["organization_projects_create"],
|
||||
"delete_branch": ["branching_development_delete", "branching_production_delete"],
|
||||
"deploy_edge_function": ["edge_functions_write"],
|
||||
"execute_sql": ["database_read", "database_write"],
|
||||
"generate_typescript_types": ["database_read"],
|
||||
"get_advisors": ["advisors_read", "database_read"],
|
||||
"get_cost": ["organization_admin_read"],
|
||||
"get_edge_function": ["edge_functions_read"],
|
||||
"get_logs": ["analytics_logs_read"],
|
||||
"get_organization": ["organization_admin_read"],
|
||||
"get_project": ["project_admin_read"],
|
||||
"get_project_url": ["project_admin_read"],
|
||||
"get_publishable_keys": ["api_gateway_keys_read"],
|
||||
"get_storage_config": ["storage_config_read"],
|
||||
"list_branches": ["branching_development_read", "branching_production_read"],
|
||||
"list_edge_functions": ["edge_functions_read"],
|
||||
"list_extensions": ["database_read"],
|
||||
"list_migrations": ["database_migrations_read"],
|
||||
"list_organizations": ["organizations_read"],
|
||||
"list_projects": ["projects_read"],
|
||||
"list_storage_buckets": ["storage_read"],
|
||||
"list_tables": ["database_read"],
|
||||
"merge_branch": ["branching_development_write", "branching_production_write"],
|
||||
"pause_project": ["project_admin_write"],
|
||||
"rebase_branch": ["branching_development_write", "branching_production_write"],
|
||||
"reset_branch": ["branching_development_write", "branching_production_write"],
|
||||
"restore_project": ["project_admin_write"],
|
||||
"update_storage_config": ["storage_config_write"]
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import { IS_PLATFORM } from 'common'
|
||||
import { NextResponse } from 'next/server'
|
||||
|
||||
import { buildAPIPermissionScopeMap } from './buildAPIPermissionScopeMap'
|
||||
import { InternalServerError } from '@/lib/api/apiHelpers'
|
||||
|
||||
/**
|
||||
* Cache on CDN for 5 minutes
|
||||
* Allow serving stale content for 1 minute while revalidating
|
||||
*/
|
||||
const CACHE_CONTROL_SETTINGS = 'public, s-maxage=300, stale-while-revalidate=60'
|
||||
|
||||
export async function OPTIONS() {
|
||||
if (!IS_PLATFORM) return new Response(null, { status: 404 })
|
||||
return new Response(null, {
|
||||
status: 204,
|
||||
headers: {
|
||||
Allow: 'GET, HEAD, OPTIONS',
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export async function HEAD() {
|
||||
if (!IS_PLATFORM) return new Response(null, { status: 404 })
|
||||
return new Response(null, {
|
||||
status: 200,
|
||||
headers: { 'Cache-Control': CACHE_CONTROL_SETTINGS },
|
||||
})
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
if (!IS_PLATFORM) return new Response(null, { status: 404 })
|
||||
|
||||
try {
|
||||
const permissionsScopes = await buildAPIPermissionScopeMap()
|
||||
return NextResponse.json(permissionsScopes, {
|
||||
headers: { 'Cache-Control': CACHE_CONTROL_SETTINGS },
|
||||
})
|
||||
} catch (error) {
|
||||
let errorCode = 500
|
||||
const headers = new Headers()
|
||||
|
||||
if (error instanceof InternalServerError) {
|
||||
if (typeof error.details?.status === 'number') errorCode = error.details.status
|
||||
if (errorCode === 420) errorCode = 429
|
||||
if (errorCode === 429 && typeof error.details?.retryAfter === 'string') {
|
||||
headers.set('Retry-After', error.details.retryAfter)
|
||||
}
|
||||
console.error('Failed to fetch scoped token permission scope map: %O', {
|
||||
message: error.message,
|
||||
details: error.details,
|
||||
})
|
||||
} else {
|
||||
console.error('Unexpected error fetching scoped token permission scope map: %O', error)
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'Unable to scoped token permission scope map at this time' },
|
||||
{ status: errorCode, headers }
|
||||
)
|
||||
}
|
||||
}
|
||||
+49
-5
@@ -7,7 +7,10 @@ import {
|
||||
selectionToScopes,
|
||||
type PermissionSelection,
|
||||
} from './AccessToken.permissions'
|
||||
import { getEnabledEndpoints, getEnabledMcpTools } from '@/data/access-tokens/permission-scope-map'
|
||||
import {
|
||||
getEnabledEndpoints,
|
||||
getEnabledMcpTools,
|
||||
} from '@/data/scoped-access-tokens/permission-scope-map-query'
|
||||
|
||||
describe('selectionToScopes', () => {
|
||||
it('ignores none and returns read scope for read mode', () => {
|
||||
@@ -75,12 +78,53 @@ describe('countConfigured', () => {
|
||||
describe('permission scope map (dual-scope enforcement)', () => {
|
||||
it('enables a dual-scope MCP tool only when all required scopes are granted', () => {
|
||||
// execute_sql requires both database_read and database_write
|
||||
expect(getEnabledMcpTools(['database_read'])).not.toContain('execute_sql')
|
||||
expect(getEnabledMcpTools(['database_read', 'database_write'])).toContain('execute_sql')
|
||||
expect(
|
||||
getEnabledMcpTools({
|
||||
// Only scope one is granted
|
||||
grantedScopes: ['database_read'],
|
||||
permissionScopeMap: {
|
||||
scopes: {},
|
||||
endpoints: {},
|
||||
mcp_tools: {
|
||||
execute_sql: ['database_read', 'database_write'],
|
||||
},
|
||||
},
|
||||
})
|
||||
).not.toContain('execute_sql')
|
||||
expect(
|
||||
getEnabledMcpTools({
|
||||
// Both scopes are granted
|
||||
grantedScopes: ['database_read', 'database_write'],
|
||||
permissionScopeMap: {
|
||||
scopes: {},
|
||||
endpoints: {},
|
||||
mcp_tools: {
|
||||
execute_sql: ['database_read', 'database_write'],
|
||||
},
|
||||
},
|
||||
})
|
||||
).toContain('execute_sql')
|
||||
})
|
||||
|
||||
it('only lists endpoints whose every required scope is granted', () => {
|
||||
const endpoints = getEnabledEndpoints(['database_read', 'database_write'])
|
||||
expect(endpoints.every((e) => e.method.length > 0 && e.path.startsWith('/'))).toBe(true)
|
||||
const endpoints = getEnabledEndpoints({
|
||||
grantedScopes: ['database_read', 'database_write'],
|
||||
permissionScopeMap: {
|
||||
scopes: {},
|
||||
endpoints: {
|
||||
'GET /api/valid_read': ['database_read'],
|
||||
'POST /api/valid_write': ['database_write'],
|
||||
'PUT /api/valid_both': ['database_read', 'database_write'],
|
||||
'PUT /api/invalid': ['project_write'],
|
||||
'PUT /api/incomplete': ['database_read', 'project_write'],
|
||||
},
|
||||
mcp_tools: {},
|
||||
},
|
||||
})
|
||||
expect(endpoints).toEqual([
|
||||
{ raw: 'GET /api/valid_read', method: 'GET', path: '/api/valid_read' },
|
||||
{ raw: 'POST /api/valid_write', method: 'POST', path: '/api/valid_write' },
|
||||
{ raw: 'PUT /api/valid_both', method: 'PUT', path: '/api/valid_both' },
|
||||
])
|
||||
})
|
||||
})
|
||||
+8
-2
@@ -12,6 +12,7 @@ import { PermissionsAccordion } from './PermissionsAccordion'
|
||||
import { ResourceAccessStep } from './ResourceAccessStep'
|
||||
import { StepIndicator } from './StepIndicator'
|
||||
import { TokenDetails } from './TokenDetails'
|
||||
import { useGetEnabledEndpointsForCapability } from '@/data/scoped-access-tokens/permission-scope-map-query'
|
||||
|
||||
const FORM_ID = 'scoped-token-form'
|
||||
|
||||
@@ -50,6 +51,7 @@ export const NewScopedTokenForm = ({
|
||||
const values = form.watch()
|
||||
const selection = values.permissions
|
||||
const configuredCount = countConfigured(selection)
|
||||
const { data: permissionScopeMap } = useGetEnabledEndpointsForCapability()
|
||||
|
||||
const handleReviewAccess = async () => {
|
||||
if (configuredCount === 0) {
|
||||
@@ -76,7 +78,11 @@ export const NewScopedTokenForm = ({
|
||||
<ResourceAccessStep form={form} />
|
||||
</div>
|
||||
<Separator />
|
||||
<PermissionsAccordion selection={selection} onChange={handlePermissionChange} />
|
||||
<PermissionsAccordion
|
||||
selection={selection}
|
||||
onChange={handlePermissionChange}
|
||||
permissionScopeMap={permissionScopeMap}
|
||||
/>
|
||||
{showMissingPermissionsWarning && (
|
||||
<div className="space-y-3 px-5 sm:px-6 pb-6">
|
||||
<Admonition
|
||||
@@ -92,7 +98,7 @@ export const NewScopedTokenForm = ({
|
||||
</form>
|
||||
</Form>
|
||||
) : (
|
||||
<NewScopedTokenFormReview values={values} />
|
||||
<NewScopedTokenFormReview values={values} permissionScopeMap={permissionScopeMap} />
|
||||
)}
|
||||
</ScrollArea>
|
||||
<SheetFooter className="mt-auto flex w-full items-center justify-between! border-t py-4">
|
||||
|
||||
+21
-8
@@ -17,10 +17,12 @@ import { RiskMarker } from './RiskMarker'
|
||||
import {
|
||||
getEnabledEndpointsForCapability,
|
||||
getEnabledMcpTools,
|
||||
} from '@/data/access-tokens/permission-scope-map'
|
||||
PermissionScopeMap,
|
||||
} from '@/data/scoped-access-tokens/permission-scope-map-query'
|
||||
|
||||
interface ReviewStepProps {
|
||||
values: TokenFormValues
|
||||
permissionScopeMap: PermissionScopeMap | undefined
|
||||
}
|
||||
|
||||
const RISK_TEXT_CLASS: Record<OverallRisk['tone'], string> = {
|
||||
@@ -33,10 +35,10 @@ const RISK_TEXT_CLASS: Record<OverallRisk['tone'], string> = {
|
||||
const modeLabel = (mode: PermissionMode) =>
|
||||
mode === 'readwrite' ? 'Read-write' : mode === 'read' ? 'Read' : 'None'
|
||||
|
||||
export const NewScopedTokenFormReview = ({ values }: ReviewStepProps) => {
|
||||
export const NewScopedTokenFormReview = ({ values, permissionScopeMap }: ReviewStepProps) => {
|
||||
const { organizations, projects } = useOrgAndProjectData()
|
||||
const selection = values.permissions
|
||||
const grantedScopes = useMemo(() => selectionToScopes(selection), [selection])
|
||||
const allGrantedScopes = useMemo(() => selectionToScopes(selection), [selection])
|
||||
const risk = useMemo(
|
||||
() => computeOverallRisk(selection, values.resourceAccess),
|
||||
[selection, values.resourceAccess]
|
||||
@@ -76,9 +78,12 @@ export const NewScopedTokenFormReview = ({ values }: ReviewStepProps) => {
|
||||
[selection]
|
||||
)
|
||||
|
||||
const hasCapabilities = grantedScopes.length > 0
|
||||
const hasCapabilities = allGrantedScopes.length > 0
|
||||
|
||||
const mcpTools = useMemo(() => getEnabledMcpTools(grantedScopes), [grantedScopes])
|
||||
const mcpTools = useMemo(
|
||||
() => getEnabledMcpTools({ grantedScopes: allGrantedScopes, permissionScopeMap }),
|
||||
[allGrantedScopes, permissionScopeMap]
|
||||
)
|
||||
|
||||
const capabilityGroups = useMemo(() => {
|
||||
const groups: { entry: PermissionCatalogEntry; mode: PermissionMode; endpoints: string[][] }[] =
|
||||
@@ -87,14 +92,18 @@ export const NewScopedTokenFormReview = ({ values }: ReviewStepProps) => {
|
||||
for (const { entry, mode } of category.entries) {
|
||||
const capabilityScopes =
|
||||
mode === 'readwrite' ? [...entry.readScopes, ...entry.writeScopes] : entry.readScopes
|
||||
const endpoints = getEnabledEndpointsForCapability(capabilityScopes, grantedScopes)
|
||||
const endpoints = getEnabledEndpointsForCapability({
|
||||
capabilityScopes,
|
||||
allGrantedScopes,
|
||||
permissionScopeMap,
|
||||
})
|
||||
if (endpoints.length > 0) {
|
||||
groups.push({ entry, mode, endpoints: endpoints.map((e) => [e.method, e.path]) })
|
||||
}
|
||||
}
|
||||
}
|
||||
return groups
|
||||
}, [activeByCategory, grantedScopes])
|
||||
}, [activeByCategory, allGrantedScopes, permissionScopeMap])
|
||||
|
||||
const rows: [string, React.ReactNode][] = [
|
||||
['Name', values.tokenName || <span className="text-foreground-lighter">Untitled token</span>],
|
||||
@@ -112,7 +121,11 @@ export const NewScopedTokenFormReview = ({ values }: ReviewStepProps) => {
|
||||
<div key={entry.key} className="flex items-center gap-2 text-sm">
|
||||
<span className="text-foreground">{entry.name}</span>
|
||||
<span className="text-foreground-light">· {modeLabel(mode)}</span>
|
||||
<RiskMarker entry={entry} withTooltip={false} />
|
||||
<RiskMarker
|
||||
entry={entry}
|
||||
withTooltip={false}
|
||||
permissionScopeMap={permissionScopeMap}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
+9
-2
@@ -2,14 +2,21 @@ import { Label, Select, SelectContent, SelectItem, SelectTrigger, SelectValue }
|
||||
|
||||
import type { PermissionCatalogEntry, PermissionMode } from '../../AccessToken.permissions'
|
||||
import { RiskMarker } from './RiskMarker'
|
||||
import { PermissionScopeMap } from '@/data/scoped-access-tokens/permission-scope-map-query'
|
||||
|
||||
interface PermissionRowProps {
|
||||
entry: PermissionCatalogEntry
|
||||
mode: PermissionMode
|
||||
onChange: (mode: PermissionMode) => void
|
||||
permissionScopeMap: PermissionScopeMap | undefined
|
||||
}
|
||||
|
||||
export const PermissionRow = ({ entry, mode, onChange }: PermissionRowProps) => {
|
||||
export const PermissionRow = ({
|
||||
entry,
|
||||
mode,
|
||||
onChange,
|
||||
permissionScopeMap,
|
||||
}: PermissionRowProps) => {
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-4 py-3">
|
||||
<div className="min-w-0 space-y-1">
|
||||
@@ -17,7 +24,7 @@ export const PermissionRow = ({ entry, mode, onChange }: PermissionRowProps) =>
|
||||
<span className="text-sm text-foreground">
|
||||
{entry.name} <span className="sr-only">permissions</span>
|
||||
</span>
|
||||
<RiskMarker entry={entry} />
|
||||
<RiskMarker entry={entry} permissionScopeMap={permissionScopeMap} />
|
||||
</Label>
|
||||
<p id={`${entry.key}-permissions-description`} className="text-xs text-foreground-light">
|
||||
{entry.description}
|
||||
|
||||
+8
-1
@@ -18,13 +18,19 @@ import {
|
||||
type PermissionSelection,
|
||||
} from '../../AccessToken.permissions'
|
||||
import { PermissionRow } from './PermissionRow'
|
||||
import { PermissionScopeMap } from '@/data/scoped-access-tokens/permission-scope-map-query'
|
||||
|
||||
interface PermissionsAccordionProps {
|
||||
selection: PermissionSelection
|
||||
onChange: (key: string, mode: PermissionMode) => void
|
||||
permissionScopeMap: PermissionScopeMap | undefined
|
||||
}
|
||||
|
||||
export const PermissionsAccordion = ({ selection, onChange }: PermissionsAccordionProps) => {
|
||||
export const PermissionsAccordion = ({
|
||||
selection,
|
||||
onChange,
|
||||
permissionScopeMap,
|
||||
}: PermissionsAccordionProps) => {
|
||||
const [openCategories, setOpenCategories] = useState<string[]>([
|
||||
PERMISSION_CATALOG_BY_CATEGORY[0]?.key,
|
||||
])
|
||||
@@ -80,6 +86,7 @@ export const PermissionsAccordion = ({ selection, onChange }: PermissionsAccordi
|
||||
entry={entry}
|
||||
mode={selection[entry.key] ?? 'none'}
|
||||
onChange={(mode) => onChange(entry.key, mode)}
|
||||
permissionScopeMap={permissionScopeMap}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -5,7 +5,10 @@ import {
|
||||
type PermissionCatalogEntry,
|
||||
type RiskLevel,
|
||||
} from '../../AccessToken.permissions'
|
||||
import { getMcpToolsForScopes } from '@/data/access-tokens/permission-scope-map'
|
||||
import {
|
||||
getMcpToolsForScopes,
|
||||
PermissionScopeMap,
|
||||
} from '@/data/scoped-access-tokens/permission-scope-map-query'
|
||||
|
||||
const DOT_CLASS: Record<RiskLevel, string> = {
|
||||
low: 'bg-brand',
|
||||
@@ -24,9 +27,15 @@ interface RiskMarkerProps {
|
||||
/** When false, renders the dot + label without the explanatory tooltip (used in the review list). */
|
||||
withTooltip?: boolean
|
||||
className?: string
|
||||
permissionScopeMap: PermissionScopeMap | undefined
|
||||
}
|
||||
|
||||
export const RiskMarker = ({ entry, withTooltip = true, className }: RiskMarkerProps) => {
|
||||
export const RiskMarker = ({
|
||||
entry,
|
||||
withTooltip = true,
|
||||
className,
|
||||
permissionScopeMap,
|
||||
}: RiskMarkerProps) => {
|
||||
const marker = (
|
||||
<span
|
||||
className={cn(
|
||||
@@ -43,7 +52,10 @@ export const RiskMarker = ({ entry, withTooltip = true, className }: RiskMarkerP
|
||||
|
||||
if (!withTooltip) return marker
|
||||
|
||||
const mcpTools = getMcpToolsForScopes([...entry.readScopes, ...entry.writeScopes])
|
||||
const mcpTools = getMcpToolsForScopes({
|
||||
scopeIds: [...entry.readScopes, ...entry.writeScopes],
|
||||
permissionScopeMap,
|
||||
})
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
|
||||
@@ -69,7 +69,6 @@ export const NewScopedTokenSheet = () => {
|
||||
setIsOpen(open)
|
||||
}
|
||||
|
||||
console.log({ createdToken })
|
||||
return (
|
||||
<Sheet open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<SheetTrigger asChild>
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
import rawScopeMap from './permission-scope-map.json'
|
||||
|
||||
/**
|
||||
* Cross-reference between OpenFGA permission scopes, Management API endpoints, and MCP tools.
|
||||
*
|
||||
* TODO: replace with control-plane endpoint. This map is currently checked in as a static
|
||||
* snapshot generated from the mgmt-api OpenAPI specs + the MCP server tool list. Once the
|
||||
* control plane exposes a scope -> endpoints/tools endpoint, fetch it via a react-query hook
|
||||
* instead of importing this JSON.
|
||||
*/
|
||||
|
||||
export interface ScopeMapEntry {
|
||||
endpoints: string[]
|
||||
mcp_tools: string[]
|
||||
}
|
||||
|
||||
export interface PermissionScopeMap {
|
||||
_meta: Record<string, unknown>
|
||||
/** scope id -> the endpoints / MCP tools it (partially) authorizes */
|
||||
scopes: Record<string, ScopeMapEntry>
|
||||
/** endpoint -> ALL scopes it requires (conjunctive) */
|
||||
endpoints: Record<string, string[]>
|
||||
/** MCP tool -> ALL scopes it requires (conjunctive) */
|
||||
mcp_tools: Record<string, string[]>
|
||||
}
|
||||
|
||||
export const PERMISSION_SCOPE_MAP = rawScopeMap as unknown as PermissionScopeMap
|
||||
|
||||
export interface EnabledEndpoint {
|
||||
/** HTTP method, e.g. "GET" */
|
||||
method: string
|
||||
/** Path, e.g. "/v1/projects/{ref}" */
|
||||
path: string
|
||||
/** The raw "METHOD /path" key */
|
||||
raw: string
|
||||
}
|
||||
|
||||
const splitEndpoint = (raw: string): EnabledEndpoint => {
|
||||
const spaceIndex = raw.indexOf(' ')
|
||||
if (spaceIndex === -1) return { method: '', path: raw, raw }
|
||||
return { method: raw.slice(0, spaceIndex), path: raw.slice(spaceIndex + 1), raw }
|
||||
}
|
||||
|
||||
/**
|
||||
* Given the set of granted scope ids, returns the Management API endpoints the token can call.
|
||||
* An endpoint is only enabled when ALL of its required scopes are granted (conjunctive), which is
|
||||
* how the mgmt-api `FgaPermissionsGuard` evaluates the `@AuthWithFgaPermissions` decorator.
|
||||
*/
|
||||
export const getEnabledEndpoints = (grantedScopes: Iterable<string>): EnabledEndpoint[] => {
|
||||
const granted = new Set(grantedScopes)
|
||||
return Object.entries(PERMISSION_SCOPE_MAP.endpoints)
|
||||
.filter(([, required]) => required.length > 0 && required.every((scope) => granted.has(scope)))
|
||||
.map(([raw]) => splitEndpoint(raw))
|
||||
}
|
||||
|
||||
/**
|
||||
* Given the set of granted scope ids, returns the MCP tools the token can call. As with endpoints,
|
||||
* a tool is only enabled when ALL of its required scopes are granted.
|
||||
*/
|
||||
export const getEnabledMcpTools = (grantedScopes: Iterable<string>): string[] => {
|
||||
const granted = new Set(grantedScopes)
|
||||
return Object.entries(PERMISSION_SCOPE_MAP.mcp_tools)
|
||||
.filter(([, required]) => required.length > 0 && required.every((scope) => granted.has(scope)))
|
||||
.map(([tool]) => tool)
|
||||
}
|
||||
|
||||
/**
|
||||
* Endpoints that (a) are fully satisfied by the complete granted-scope set AND (b) require at least
|
||||
* one of `capabilityScopes`. Used by the review step to group enabled endpoints under the capability
|
||||
* that contributes them, while still honouring dual-scope requirements (a dual-scope endpoint only
|
||||
* appears once all its scopes are granted, and shows under each contributing capability).
|
||||
*/
|
||||
export const getEnabledEndpointsForCapability = (
|
||||
capabilityScopes: Iterable<string>,
|
||||
allGrantedScopes: Iterable<string>
|
||||
): EnabledEndpoint[] => {
|
||||
const granted = new Set(allGrantedScopes)
|
||||
const capability = new Set(capabilityScopes)
|
||||
return Object.entries(PERMISSION_SCOPE_MAP.endpoints)
|
||||
.filter(
|
||||
([, required]) =>
|
||||
required.length > 0 &&
|
||||
required.every((scope) => granted.has(scope)) &&
|
||||
required.some((scope) => capability.has(scope))
|
||||
)
|
||||
.map(([raw]) => splitEndpoint(raw))
|
||||
}
|
||||
|
||||
/**
|
||||
* Informational lookup for the per-permission risk tooltip: the MCP tools associated with any of
|
||||
* the given scopes. Unlike getEnabledMcpTools this is not conjunctive — it surfaces every tool that
|
||||
* lists one of these scopes, so users can see what a capability relates to before granting it.
|
||||
*/
|
||||
export const getMcpToolsForScopes = (scopeIds: Iterable<string>): string[] => {
|
||||
const tools = new Set<string>()
|
||||
for (const id of scopeIds) {
|
||||
PERMISSION_SCOPE_MAP.scopes[id]?.mcp_tools.forEach((tool) => tools.add(tool))
|
||||
}
|
||||
return Array.from(tools)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export const scopedAccessTokenKeys = {
|
||||
list: () => ['scoped-access-tokens'] as const,
|
||||
detail: (id: string) => ['scoped-access-tokens', id] as const,
|
||||
permissions: () => ['scoped-access-token-permissions'],
|
||||
}
|
||||
@@ -0,0 +1,188 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import { scopedAccessTokenKeys } from './keys'
|
||||
import { BASE_PATH } from '@/lib/constants'
|
||||
import { ResponseError } from '@/types'
|
||||
|
||||
/**
|
||||
* Cross-reference between OpenFGA permission scopes, Management API endpoints, and MCP tools.
|
||||
*/
|
||||
|
||||
export interface ScopeMapEntry {
|
||||
endpoints: string[]
|
||||
mcp_tools: string[]
|
||||
}
|
||||
|
||||
/* e.g
|
||||
{
|
||||
"branching_production_read": {
|
||||
"endpoints": [
|
||||
"GET /v1/branches/{branch_id_or_ref}",
|
||||
"GET /v1/projects/{ref}/branches",
|
||||
"GET /v1/projects/{ref}/branches/{name}"
|
||||
],
|
||||
"mcp_tools": [
|
||||
"list_branches"
|
||||
]
|
||||
}
|
||||
}
|
||||
*/
|
||||
export type ScopeMap = Record<string, ScopeMapEntry>
|
||||
// e.g { 'GET /v2/projects/{ref}/analytics/log-drains': ['analytics_config_read'] }
|
||||
export type EndpointMap = Record<string, string[]>
|
||||
// e.g { 'deploy_edge_function': ['edge_functions_write'] }
|
||||
export type McpMap = Record<string, string[]>
|
||||
|
||||
export interface PermissionScopeMap {
|
||||
/** scope id -> the endpoints / MCP tools it (partially) authorizes */
|
||||
scopes: ScopeMap
|
||||
/** endpoint -> ALL scopes it requires (conjunctive) */
|
||||
endpoints: EndpointMap
|
||||
/** MCP tool -> ALL scopes it requires (conjunctive) */
|
||||
mcp_tools: McpMap
|
||||
}
|
||||
|
||||
export interface EnabledEndpoint {
|
||||
/** HTTP method, e.g. "GET" */
|
||||
method: string
|
||||
/** Path, e.g. "/v1/projects/{ref}" */
|
||||
path: string
|
||||
/** The raw "METHOD /path" key */
|
||||
raw: string
|
||||
}
|
||||
|
||||
const splitEndpoint = (raw: string): EnabledEndpoint => {
|
||||
const spaceIndex = raw.indexOf(' ')
|
||||
if (spaceIndex === -1) return { method: '', path: raw, raw }
|
||||
return { method: raw.slice(0, spaceIndex), path: raw.slice(spaceIndex + 1), raw }
|
||||
}
|
||||
|
||||
/**
|
||||
* Given the set of granted scope ids, returns the Management API endpoints the token can call.
|
||||
* An endpoint is only enabled when ALL of its required scopes are granted (conjunctive), which is
|
||||
* how the mgmt-api `FgaPermissionsGuard` evaluates the `@AuthWithFgaPermissions` decorator.
|
||||
*/
|
||||
export const getEnabledEndpoints = ({
|
||||
grantedScopes,
|
||||
permissionScopeMap,
|
||||
}: {
|
||||
grantedScopes: Iterable<string>
|
||||
permissionScopeMap: PermissionScopeMap | undefined
|
||||
}): EnabledEndpoint[] => {
|
||||
if (permissionScopeMap == null) return []
|
||||
|
||||
const granted = new Set(grantedScopes)
|
||||
return Object.entries(permissionScopeMap.endpoints)
|
||||
.filter(([, required]) => required.length > 0 && required.every((scope) => granted.has(scope)))
|
||||
.map(([raw]) => splitEndpoint(raw))
|
||||
}
|
||||
|
||||
/**
|
||||
* Given the set of granted scope ids, returns the MCP tools the token can call. As with endpoints,
|
||||
* a tool is only enabled when ALL of its required scopes are granted.
|
||||
*/
|
||||
export const getEnabledMcpTools = ({
|
||||
grantedScopes,
|
||||
permissionScopeMap,
|
||||
}: {
|
||||
grantedScopes: Iterable<string>
|
||||
permissionScopeMap: PermissionScopeMap | undefined
|
||||
}): string[] => {
|
||||
if (permissionScopeMap == null) return []
|
||||
|
||||
const granted = new Set(grantedScopes)
|
||||
return Object.entries(permissionScopeMap.mcp_tools)
|
||||
.filter(([, required]) => required.length > 0 && required.every((scope) => granted.has(scope)))
|
||||
.map(([tool]) => tool)
|
||||
}
|
||||
|
||||
/**
|
||||
* Endpoints that (a) are fully satisfied by the complete granted-scope set AND (b) require at least
|
||||
* one of `capabilityScopes`. Used by the review step to group enabled endpoints under the capability
|
||||
* that contributes them, while still honouring dual-scope requirements (a dual-scope endpoint only
|
||||
* appears once all its scopes are granted, and shows under each contributing capability).
|
||||
*/
|
||||
export const getEnabledEndpointsForCapability = ({
|
||||
capabilityScopes,
|
||||
allGrantedScopes,
|
||||
permissionScopeMap,
|
||||
}: {
|
||||
capabilityScopes: Iterable<string>
|
||||
allGrantedScopes: Iterable<string>
|
||||
permissionScopeMap: PermissionScopeMap | undefined
|
||||
}): EnabledEndpoint[] => {
|
||||
if (permissionScopeMap == null) return []
|
||||
|
||||
const granted = new Set(allGrantedScopes)
|
||||
const capability = new Set(capabilityScopes)
|
||||
return Object.entries(permissionScopeMap.endpoints)
|
||||
.filter(
|
||||
([, required]) =>
|
||||
required.length > 0 &&
|
||||
required.every((scope) => granted.has(scope)) &&
|
||||
required.some((scope) => capability.has(scope))
|
||||
)
|
||||
.map(([raw]) => splitEndpoint(raw))
|
||||
}
|
||||
|
||||
/**
|
||||
* Informational lookup for the per-permission risk tooltip: the MCP tools associated with any of
|
||||
* the given scopes. Unlike getEnabledMcpTools this is not conjunctive — it surfaces every tool that
|
||||
* lists one of these scopes, so users can see what a capability relates to before granting it.
|
||||
*/
|
||||
export const getMcpToolsForScopes = ({
|
||||
scopeIds,
|
||||
permissionScopeMap,
|
||||
}: {
|
||||
scopeIds: Iterable<string>
|
||||
permissionScopeMap: PermissionScopeMap | undefined
|
||||
}): string[] => {
|
||||
if (permissionScopeMap == null) return []
|
||||
|
||||
const tools = new Set<string>()
|
||||
for (const id of scopeIds) {
|
||||
permissionScopeMap.scopes[id]?.mcp_tools.forEach((tool) => tools.add(tool))
|
||||
}
|
||||
return Array.from(tools)
|
||||
}
|
||||
|
||||
export async function getGetScopedTokenPermissionsForScope(signal?: AbortSignal) {
|
||||
const response = await fetch(`${BASE_PATH}/api/scoped-access-token-permissions`, {
|
||||
signal,
|
||||
method: 'GET',
|
||||
credentials: 'omit',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
console.error('[getIncidentStatus] Failed:', response.status, errorText)
|
||||
|
||||
let retryAfter: number | undefined
|
||||
const retryAfterHeader = response.headers.get('Retry-After')
|
||||
if (retryAfterHeader !== null) {
|
||||
const parsed = Number(retryAfterHeader)
|
||||
if (Number.isFinite(parsed) && parsed > 0) retryAfter = parsed
|
||||
}
|
||||
|
||||
throw new ResponseError(
|
||||
`Failed to fetch incident status: ${response.statusText}`,
|
||||
response.status,
|
||||
undefined,
|
||||
retryAfter
|
||||
)
|
||||
}
|
||||
|
||||
return await response.json()
|
||||
}
|
||||
|
||||
export type ScopedAccessTokenPermissionsForScopeError = ResponseError
|
||||
|
||||
export const useGetEnabledEndpointsForCapability = <TData = PermissionScopeMap>() => {
|
||||
return useQuery<TData, ScopedAccessTokenPermissionsForScopeError, TData>({
|
||||
queryKey: scopedAccessTokenKeys.permissions(),
|
||||
queryFn: ({ signal }) => getGetScopedTokenPermissionsForScope(signal),
|
||||
})
|
||||
}
|
||||
File renamed without changes.
@@ -27,6 +27,7 @@ export const HOSTED_SUPPORTED_API_URLS = [
|
||||
'/api/integrations/stripe-sync',
|
||||
'/content/graphql',
|
||||
'/parse-query',
|
||||
'/scoped-access-token-permissions',
|
||||
]
|
||||
|
||||
// `pathname` must be basePath-relative — Next's `nextUrl.pathname` already is,
|
||||
|
||||
@@ -26,6 +26,7 @@ import { Route as OrgChar91_Char93RouteImport } from './routes/org.[_]'
|
||||
import { Route as NewSlugRouteImport } from './routes/new/$slug'
|
||||
import { Route as IntegrationsVercelRouteImport } from './routes/integrations/vercel'
|
||||
import { Route as ApiStatusOverrideRouteImport } from './routes/api/status-override'
|
||||
import { Route as ApiScopedAccessTokenPermissionsRouteImport } from './routes/api/scoped-access-token-permissions'
|
||||
import { Route as ApiParseQueryRouteImport } from './routes/api/parse-query'
|
||||
import { Route as ApiIncidentStatusRouteImport } from './routes/api/incident-status'
|
||||
import { Route as ApiIncidentBannerRouteImport } from './routes/api/incident-banner'
|
||||
@@ -400,6 +401,12 @@ const ApiStatusOverrideRoute = ApiStatusOverrideRouteImport.update({
|
||||
path: '/api/status-override',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiScopedAccessTokenPermissionsRoute =
|
||||
ApiScopedAccessTokenPermissionsRouteImport.update({
|
||||
id: '/api/scoped-access-token-permissions',
|
||||
path: '/api/scoped-access-token-permissions',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const ApiParseQueryRoute = ApiParseQueryRouteImport.update({
|
||||
id: '/api/parse-query',
|
||||
path: '/api/parse-query',
|
||||
@@ -2060,6 +2067,7 @@ export interface FileRoutesByFullPath {
|
||||
'/api/incident-banner': typeof ApiIncidentBannerRoute
|
||||
'/api/incident-status': typeof ApiIncidentStatusRoute
|
||||
'/api/parse-query': typeof ApiParseQueryRoute
|
||||
'/api/scoped-access-token-permissions': typeof ApiScopedAccessTokenPermissionsRoute
|
||||
'/api/status-override': typeof ApiStatusOverrideRoute
|
||||
'/integrations/vercel': typeof IntegrationsVercelRouteWithChildren
|
||||
'/new/$slug': typeof NewSlugRoute
|
||||
@@ -2365,6 +2373,7 @@ export interface FileRoutesByTo {
|
||||
'/api/incident-banner': typeof ApiIncidentBannerRoute
|
||||
'/api/incident-status': typeof ApiIncidentStatusRoute
|
||||
'/api/parse-query': typeof ApiParseQueryRoute
|
||||
'/api/scoped-access-token-permissions': typeof ApiScopedAccessTokenPermissionsRoute
|
||||
'/api/status-override': typeof ApiStatusOverrideRoute
|
||||
'/integrations/vercel': typeof IntegrationsVercelRouteWithChildren
|
||||
'/new/$slug': typeof NewSlugRoute
|
||||
@@ -2663,6 +2672,7 @@ export interface FileRoutesById {
|
||||
'/api/incident-banner': typeof ApiIncidentBannerRoute
|
||||
'/api/incident-status': typeof ApiIncidentStatusRoute
|
||||
'/api/parse-query': typeof ApiParseQueryRoute
|
||||
'/api/scoped-access-token-permissions': typeof ApiScopedAccessTokenPermissionsRoute
|
||||
'/api/status-override': typeof ApiStatusOverrideRoute
|
||||
'/integrations/vercel': typeof IntegrationsVercelRouteWithChildren
|
||||
'/new/$slug': typeof NewSlugRoute
|
||||
@@ -2971,6 +2981,7 @@ export interface FileRouteTypes {
|
||||
| '/api/incident-banner'
|
||||
| '/api/incident-status'
|
||||
| '/api/parse-query'
|
||||
| '/api/scoped-access-token-permissions'
|
||||
| '/api/status-override'
|
||||
| '/integrations/vercel'
|
||||
| '/new/$slug'
|
||||
@@ -3276,6 +3287,7 @@ export interface FileRouteTypes {
|
||||
| '/api/incident-banner'
|
||||
| '/api/incident-status'
|
||||
| '/api/parse-query'
|
||||
| '/api/scoped-access-token-permissions'
|
||||
| '/api/status-override'
|
||||
| '/integrations/vercel'
|
||||
| '/new/$slug'
|
||||
@@ -3573,6 +3585,7 @@ export interface FileRouteTypes {
|
||||
| '/api/incident-banner'
|
||||
| '/api/incident-status'
|
||||
| '/api/parse-query'
|
||||
| '/api/scoped-access-token-permissions'
|
||||
| '/api/status-override'
|
||||
| '/integrations/vercel'
|
||||
| '/new/$slug'
|
||||
@@ -3871,6 +3884,7 @@ export interface RootRouteChildren {
|
||||
ApiIncidentBannerRoute: typeof ApiIncidentBannerRoute
|
||||
ApiIncidentStatusRoute: typeof ApiIncidentStatusRoute
|
||||
ApiParseQueryRoute: typeof ApiParseQueryRoute
|
||||
ApiScopedAccessTokenPermissionsRoute: typeof ApiScopedAccessTokenPermissionsRoute
|
||||
ApiStatusOverrideRoute: typeof ApiStatusOverrideRoute
|
||||
IntegrationsVercelRoute: typeof IntegrationsVercelRouteWithChildren
|
||||
NewSlugRoute: typeof NewSlugRoute
|
||||
@@ -4091,6 +4105,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof ApiStatusOverrideRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/scoped-access-token-permissions': {
|
||||
id: '/api/scoped-access-token-permissions'
|
||||
path: '/api/scoped-access-token-permissions'
|
||||
fullPath: '/api/scoped-access-token-permissions'
|
||||
preLoaderRoute: typeof ApiScopedAccessTokenPermissionsRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/api/parse-query': {
|
||||
id: '/api/parse-query'
|
||||
path: '/api/parse-query'
|
||||
@@ -6814,6 +6835,7 @@ const rootRouteChildren: RootRouteChildren = {
|
||||
ApiIncidentBannerRoute: ApiIncidentBannerRoute,
|
||||
ApiIncidentStatusRoute: ApiIncidentStatusRoute,
|
||||
ApiParseQueryRoute: ApiParseQueryRoute,
|
||||
ApiScopedAccessTokenPermissionsRoute: ApiScopedAccessTokenPermissionsRoute,
|
||||
ApiStatusOverrideRoute: ApiStatusOverrideRoute,
|
||||
IntegrationsVercelRoute: IntegrationsVercelRouteWithChildren,
|
||||
NewSlugRoute: NewSlugRoute,
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
|
||||
// App Router route (apps/studio/app/api/scoped-access-token/route.ts) — already
|
||||
// Web-native, uses NextResponse which extends `Response`. Direct re-export of
|
||||
// each HTTP method; no shim needed.
|
||||
import { GET, HEAD, OPTIONS } from '@/app/api/scoped-access-token-permissions/route'
|
||||
|
||||
export const Route = createFileRoute('/api/scoped-access-token-permissions')({
|
||||
server: {
|
||||
handlers: {
|
||||
GET: () => GET(),
|
||||
HEAD: () => HEAD(),
|
||||
OPTIONS: () => OPTIONS(),
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -547,6 +547,10 @@ export default defineConfig(({ command, mode }) => {
|
||||
postcss: { plugins: [] },
|
||||
},
|
||||
ssr: {
|
||||
optimizeDeps: {
|
||||
include: ['lodash'],
|
||||
},
|
||||
|
||||
// `lodash` is CJS; its named-export interop fails in Node ESM unless bundled.
|
||||
// `next/*` must be bundled so our nextCompat shim wins — otherwise Vite's
|
||||
// SSR externalizer leaves `next/router` as a runtime package import and
|
||||
|
||||
Reference in new issue
Block a user