From 7bf36118bc5da7b1600e10c4202e475d68306a0e Mon Sep 17 00:00:00 2001 From: k00b Date: Tue, 26 Aug 2025 17:22:45 -0500 Subject: [PATCH 1/2] Reenable GraphQL playground in dev only This partially reverts commit 2a8085a9956edbaa96906da50050f1472adddeef. --- pages/api/graphql.js | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/pages/api/graphql.js b/pages/api/graphql.js index f6ba066ef7..98c2f3b06a 100644 --- a/pages/api/graphql.js +++ b/pages/api/graphql.js @@ -1,4 +1,5 @@ import { ApolloServer } from '@apollo/server' +import { ApolloServerPluginLandingPageLocalDefault } from '@apollo/server/plugin/landingPage/default' import { startServerAndCreateNextHandler } from '@as-integrations/next' import resolvers from '@/api/resolvers' import models from '@/api/models' @@ -8,7 +9,6 @@ import { getServerSession } from 'next-auth/next' import { getAuthOptions } from './auth/[...nextauth]' import search from '@/api/search' import { multiAuthMiddleware } from '@/lib/auth' -import { ApolloServerPluginLandingPageDisabled } from '@apollo/server/plugin/disabled' const apolloServer = new ApolloServer({ typeDefs, @@ -42,7 +42,10 @@ const apolloServer = new ApolloServer({ } } } - }, ApolloServerPluginLandingPageDisabled()] + }, + process.env.NODE_ENV === 'development' && ApolloServerPluginLandingPageLocalDefault( + { embed: { endpointIsEditable: false, persistExplorerState: true, displayOptions: { theme: 'dark' } }, footer: false }) + ].filter(Boolean) }) export default startServerAndCreateNextHandler(apolloServer, { From a9a4c6d475d3e11cfec118f8ff9b82a4050b74c4 Mon Sep 17 00:00:00 2001 From: ekzyis Date: Sun, 12 Oct 2025 01:11:41 +0200 Subject: [PATCH 2/2] PoC for authorization with schema directives This is only a proof of concept how to guard fields with schema directives so it is only applied to UserPrivates. It can be tested using this GraphQL query: ``` fragment UserFields on User { id name privates { sats } } query users { me { ...UserFields } user(id: 624) { ...UserFields } } ``` However, I am not sure how schema directives can support conditional validation like we need for fields that are conditionally private. Conditional validation might need to continue exist as custom code in the resolvers. Next steps could be to also apply these directives to limit API key usage. --- api/directives/auth.js | 57 +++++++++++++++++++++++++++++++++++++++++ api/directives/index.js | 14 ++++++++++ api/directives/upper.js | 30 ++++++++++++++++++++++ api/resolvers/user.js | 4 --- api/ssrApollo.js | 9 ++----- api/typeDefs/user.js | 2 +- pages/api/graphql.js | 8 +++--- 7 files changed, 109 insertions(+), 15 deletions(-) create mode 100644 api/directives/auth.js create mode 100644 api/directives/index.js create mode 100644 api/directives/upper.js diff --git a/api/directives/auth.js b/api/directives/auth.js new file mode 100644 index 0000000000..43c9dfa15e --- /dev/null +++ b/api/directives/auth.js @@ -0,0 +1,57 @@ +import gql from 'graphql-tag' +import { defaultFieldResolver } from 'graphql' +import { getDirective, MapperKind, mapSchema } from '@graphql-tools/utils' +import { GqlAuthorizationError } from '@/lib/error' + +const DIRECTIVE_NAME = 'auth' + +export const typeDef = gql` + directive @${DIRECTIVE_NAME}(allow: [Role!]!) on FIELD_DEFINITION + enum Role { + ADMIN + OWNER + USER + } +` + +export function apply (schema) { + return mapSchema(schema, { + [MapperKind.OBJECT_FIELD]: fieldConfig => { + const upperDirective = getDirective(schema, fieldConfig, DIRECTIVE_NAME)?.[0] + if (upperDirective) { + const { resolve = defaultFieldResolver } = fieldConfig + const { allow } = upperDirective + return { + ...fieldConfig, + resolve: async function (parent, args, context, info) { + checkFieldPermissions(allow, parent, args, context, info) + return await resolve(parent, args, context, info) + } + } + } + } + }) +} + +function checkFieldPermissions (allow, parent, args, { me }, { parentType }) { + // TODO: should admin users always have access to all fields? + + if (allow.indexOf('OWNER') >= 0) { + if (!me) { + throw new GqlAuthorizationError('you must be logged in to access this field') + } + + switch (parentType.name) { + case 'User': + if (me.id !== parent.id) { + throw new GqlAuthorizationError('you must be the owner to access this field') + } + break + default: + // we could just try the userId column and not care about the type + // but we want to be explicit and throw on unexpected types instead + // to catch potential issues in our authorization layer fast + throw new GqlAuthorizationError('failed to check owner: unknown type') + } + } +} diff --git a/api/directives/index.js b/api/directives/index.js new file mode 100644 index 0000000000..d900f45f93 --- /dev/null +++ b/api/directives/index.js @@ -0,0 +1,14 @@ +import { makeExecutableSchema } from '@graphql-tools/schema' + +import * as upper from './upper' +import * as auth from './auth' + +const DIRECTIVES = [upper, auth] + +export function makeExecutableSchemaWithDirectives (typeDefs, resolvers) { + const schema = makeExecutableSchema({ + typeDefs: [...typeDefs, ...DIRECTIVES.map(({ typeDef }) => typeDef)], + resolvers + }) + return DIRECTIVES.reduce((acc, directive) => directive.apply(acc), schema) +} diff --git a/api/directives/upper.js b/api/directives/upper.js new file mode 100644 index 0000000000..6c65ead729 --- /dev/null +++ b/api/directives/upper.js @@ -0,0 +1,30 @@ +import gql from 'graphql-tag' +import { defaultFieldResolver } from 'graphql' +import { getDirective, MapperKind, mapSchema } from '@graphql-tools/utils' + +/** Example schema directive that uppercases the value of the field before returning it to the client */ + +const DIRECTIVE_NAME = 'upper' + +export const typeDef = gql`directive @${DIRECTIVE_NAME} on FIELD_DEFINITION` + +export function apply (schema) { + return mapSchema(schema, { + [MapperKind.OBJECT_FIELD]: fieldConfig => { + const upperDirective = getDirective(schema, fieldConfig, DIRECTIVE_NAME)?.[0] + if (upperDirective) { + const { resolve = defaultFieldResolver } = fieldConfig + return { + ...fieldConfig, + resolve: async function (parent, args, context, info) { + const result = await resolve(parent, args, context, info) + if (typeof result === 'string') { + return result.toUpperCase() + } + return result + } + } + } + } + }) +} diff --git a/api/resolvers/user.js b/api/resolvers/user.js index 056b5df300..49a76af393 100644 --- a/api/resolvers/user.js +++ b/api/resolvers/user.js @@ -936,10 +936,6 @@ export default { User: { privates: async (user, args, { me, models }) => { - if (!me || me.id !== user.id) { - return null - } - return user }, optional: user => user, diff --git a/api/ssrApollo.js b/api/ssrApollo.js index f83dd42167..bdc96ac78a 100644 --- a/api/ssrApollo.js +++ b/api/ssrApollo.js @@ -1,8 +1,6 @@ import { ApolloClient, InMemoryCache } from '@apollo/client' import { SchemaLink } from '@apollo/client/link/schema' -import { makeExecutableSchema } from '@graphql-tools/schema' -import resolvers from './resolvers' -import typeDefs from './typeDefs' +import { schema } from '@/pages/api/graphql' import models from './models' import { print } from 'graphql' import lnd from './lnd' @@ -22,10 +20,7 @@ export default async function getSSRApolloClient ({ req, res, me = null }) { const client = new ApolloClient({ ssrMode: true, link: new SchemaLink({ - schema: makeExecutableSchema({ - typeDefs, - resolvers - }), + schema, context: { models, me: session diff --git a/api/typeDefs/user.js b/api/typeDefs/user.js index c41bb2676f..48cc403b0b 100644 --- a/api/typeDefs/user.js +++ b/api/typeDefs/user.js @@ -79,7 +79,7 @@ export default gql` proportion: Float optional: UserOptional! - privates: UserPrivates + privates: UserPrivates @auth(allow: [OWNER]) meMute: Boolean! meSubscriptionPosts: Boolean! diff --git a/pages/api/graphql.js b/pages/api/graphql.js index 98c2f3b06a..ff2d060d1b 100644 --- a/pages/api/graphql.js +++ b/pages/api/graphql.js @@ -1,18 +1,20 @@ import { ApolloServer } from '@apollo/server' import { ApolloServerPluginLandingPageLocalDefault } from '@apollo/server/plugin/landingPage/default' import { startServerAndCreateNextHandler } from '@as-integrations/next' -import resolvers from '@/api/resolvers' import models from '@/api/models' import lnd from '@/api/lnd' import typeDefs from '@/api/typeDefs' +import { makeExecutableSchemaWithDirectives } from '@/api/directives' +import resolvers from '@/api/resolvers' import { getServerSession } from 'next-auth/next' import { getAuthOptions } from './auth/[...nextauth]' import search from '@/api/search' import { multiAuthMiddleware } from '@/lib/auth' +export const schema = makeExecutableSchemaWithDirectives(typeDefs, resolvers) + const apolloServer = new ApolloServer({ - typeDefs, - resolvers, + schema, introspection: true, allowBatchedHttpRequests: true, plugins: [{